41c4cdc47a4edf82e6a4ab73f812cb3507674a55
22 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41c4cdc47a |
feat(files): add delete file effects (#350)
* feat(files): add delete file effects - Expose Cmd.deleteFile as the primary TypeScript API with checked routing and documentation. - Add real, fake, permission, journal, and replay support through the shared file-effect engine. - Cover compiled cores across macOS, Linux, and Windows, including reliable Windows append behavior. * fix(files): await Windows append completion * fix(files): preserve final symlink on delete |
||
|
|
199b89e06d |
feat: add actionable notifications (#347)
* feat: add actionable notifications - Add stable replacement identifiers and paired notification actions across SDK APIs. - Dispatch activations through the normal command path on macOS, Windows, and Linux. - Advance the TypeScript wire generation and cover validation, test platforms, and docs. * fix: secure notification action dispatch |
||
|
|
baef0d96d3 |
feat(storage): harden file effects (#339)
* feat(storage): harden file effects - Add bounded streaming reads, atomic write sinks, stat, and append effects. - Gate external paths with symlink-safe filesystem permission checks. - Preserve deterministic record/replay through content-addressed stream blobs. * fix(storage): harden file effect lifecycles * fix(storage): harden stream replay and key ownership * fix(storage): keep stream sink on rejected chunks * fix(storage): address file stream review findings |
||
|
|
d7aeea1ea9 |
Promote credentials to core effects (#335)
* Promote credentials to core effects - Add app-scoped TypeScript and Zig credential effects with capability and permission gates plus platform-backed storage. - Redact credential journal results, synthesize deterministic replay placeholders, and provide hermetic devhost and test stores. - Update checker diagnostics, mobile plumbing, SDK surfaces, documentation, skills, and conformance coverage. * fix(runtime): harden credential effect lifecycle * fix: harden credential effect handling |
||
|
|
473cad71ef |
feat(storage): ship checked relational SQLite (#326)
* feat(storage): ship checked relational SQLite - Add capability-gated SQLite effects, migrations, transactions, live queries, and replay across desktop and mobile. - Validate schemas and named SQL at build time, generating typed command and subscription APIs. - Add the relational notes flagship, documentation, tests, skills, and SDK mirror support. * fix relational SQLite correctness gaps * fix(storage): harden relational SQLite checks * fix relational runtime policy parity * fix sqlite tooling on node 22 * fix(storage): harden relational sqlite boundaries * fix(storage): address relational review findings * fix(storage): address remaining relational review findings * fix(storage): address relational review issues * fix(sqlite): retire stale live queries before replacements |
||
|
|
b7c493bab0 |
feat(ts-services): add typed boundary and ecosystem (#321)
* feat(ts-services): add typed boundary and ecosystem - generate record-typed service clients/codecs and enforce hermetic 100% npm-static coverage - run services in the devhost with native-format record/replay parity - add typed streaming, cooperative cancellation, deadlines, fixtures, and guidance * fix(ts-services): harden request admission * fix(ts-services): align devhost runtime behavior * fix(ts-services): enforce request deadlines |
||
|
|
aa7ed9aa52 |
feat(storage): add SQLite-backed record store (#320)
* feat(storage): add SQLite-backed record store - add capability-shed SQLite storage with deterministic effects, replay, and atomic record operations - expose matching TypeScript and Zig APIs across desktop and mobile hosts - add hermetic coverage, devhost support, documentation, and a worked example Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> * fix(storage): restore pristine SQLite amalgamation - restore the upstream byte removed during whitespace cleanup so the vendored source matches its documented checksum Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> * fix(storage): harden store result delivery * fix(storage): align devhost store semantics * fix(storage): address review findings --------- Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> |
||
|
|
cafbf206e8 |
Implement model persistence (#316)
* Implement model persistence - Add atomic engine-owned snapshots with generated codecs, restore and migration routes, backup recovery, debounce, and replay support. - Gate persistence through app manifests and native check while keeping the TypeScript, Zig, and devhost surfaces in parity. - Add store and end-to-end coverage, documentation, and a persisted TypeScript example. Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> * Address model persistence review findings * Address remaining model persistence review findings * Fix persistence identity and restore route checks * fix persistence replay and rollback safety --------- Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com> |
||
|
|
0126d20f30 |
feat(ts): expose platform service commands (#315)
* feat(ts): expose platform service commands - Add first-class Cmd APIs for credentials, external URLs, path reveal, and local-time formatting. - Route commands through validated runtime services and native macOS, Linux, and Windows backends. - Document effect semantics and cover TypeScript encoding, platform behavior, and runtime delivery. * fix(windows): apply timestamp DST rules * fix(platform): align local time formatting |
||
|
|
2fd7c4c3dc |
Add macOS lifecycle hooks for TypeScript apps (#314)
* Add macOS lifecycle hooks for TypeScript apps - Expose hidden-window, fullscreen, Dock-presence, and launch-at-login controls through manifests and TypeScript effects. - Implement AppKit and SMAppService behavior across both macOS hosts with safe cross-platform fallbacks. - Cover the new wire/runtime paths with tests and document the menu-bar app migration workflow. * fix: address platform lifecycle review findings * fix: harden macOS lifecycle hooks * fix: preserve macOS activation behavior * fix: preserve hidden startup window state |
||
|
|
c7e64b647c |
feat: add dynamic TypeScript menu bar items (#311)
* feat: add dynamic TypeScript menu bar items - Drive status-item presentation and rich menus from committed TypeScript model state. - Render and route dynamic status items through the native platform hosts. - Add UTF-8 byte encoding, strict ASCII diagnostics, docs, examples, and end-to-end coverage. * fix tray menu platform parity * fix dynamic tray menu behavior * fix: preserve dynamic tray platform state * fix tray presentation and command routing |
||
|
|
7a29661384 |
Fix TS core PTY event key contract (#307)
- Align echoed PTY keys with the byte-text host and facade contract. - Regenerate SDK declarations and update PTY conformance coverage. - Compile the PTY event arm through the external-core E2E fixture. Co-authored-by: NyxTools-M <243123737+NyxTools-M@users.noreply.github.com> |
||
|
|
7e3a3157d0 |
feat(core): support streaming fetch responses (#300)
* feat(core): support streaming fetch responses - Add a typed line-streaming Cmd.fetch overload and carry it through the command wire and runtime host. - Keep stream lifecycle deterministic with loud cancellation and duplicate-key rejection. - Cover the feature with contract, conformance, runtime, compiled-core, harness, example, and documentation updates. * feat: stream AI chat through Vercel gateway - Render chat-completion SSE deltas as they arrive. - Pin the example to Vercel AI Gateway with official key config. - Cover streaming, failure, and replay paths end to end. * feat: add streaming fetch and chatbot example * feat(chatbot): refine streaming chat experience - Add a compact live model picker and immediate Stop action. - Improve conversation layout, prompt focus, and caret retention. - Expand chatbot documentation and end-to-end regression coverage. * fix: harden streaming fetch limits * fix: harden streaming fetch and textarea behavior * fix(canvas): render lifted rich text |
||
|
|
283ab804c0 |
Add cross-platform audio capture and voice memo example (#303)
* Add cross-platform audio capture and voice memo example - Add microphone and system-audio capture effects for macOS, Windows, and TypeScript core. - Add a polished voice memo example with private app-data WAV saving and playback. - Document platform support and packaging requirements with runtime and conformance coverage. Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> * Fix audio capture teardown and permissions Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> * Fix audio capture lifecycle and metadata Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> * Fix audio capture lifecycle edge cases Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> * fix: harden audio capture lifecycle Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> --------- Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com> |
||
|
|
b230b140b8 |
feat(core): add native drag and drop to TypeScript apps (#285)
* feat(core): add native drag and drop to TypeScript apps - Carry native file drops and widget drag events through the TypeScript core contract. - Rebuild Kanban in TypeScript with animated reordering, cross-column moves, and Escape cancellation. - Add runtime, ABI, rendering, end-to-end, documentation, and example coverage. Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com> * fix: correct canvas drag lifecycle * fix: harden canvas drag dispatch * fix drag lifecycle and preview rendering * fix(runtime): preserve drag gesture arbitration --------- Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com> |
||
|
|
4f0b57f2c2 |
feat(effects): add desktop notifications (#283)
* feat(effects): add desktop notifications - Expose fire-and-forget notifications through the UiApp effects facade. - Validate requests and suppress external alerts during fake execution and replay. - Add focused runtime coverage and document the native Zig API. * feat(ts-core): add desktop notifications - Expose fire-and-forget notifications through Cmd.showNotification. - Route the additive command wire opcode through the native platform effect. - Cover compiled-core delivery and document the primary authoring path. |
||
|
|
f8c14c59c7 |
Terminal: pty effects, libghostty-vt, and a replayable terminal example (#191)
* Add the POSIX pty primitive: openpty, controlling-terminal fork/exec, resize, reap - One transport for terminal children: parent-built argv/envp (clean env plus TERM), async-signal-safe child path via login_tty/execve, group kill and waitpid decode - macOS and Linux-with-libc report supported; every other target compiles to loud PtyUnsupported stubs - Live tests prove the controlling terminal (test -t 0), exit codes, signal decode, and the env policy * Pin libghostty-vt and stage the terminal example skeleton - examples/terminal owns its build: ghostty pinned at 7aa9591 (the first upstream commit that builds under Zig 0.16.0; v1.3.1 still targets 0.15) with simd off so the vt module stays pure Zig - The pin is proven in-tree: the example's test round-trips VT parsing through the pinned module - zig-pkg/ (the project-local package store) joins .gitignore * Add the pty effect vocabulary: spawn, coalesced output, write, resize, kill, replayable exits - fx.ptySpawn/ptyWrite/ptyResize/ptyKill ride the keyed families' seams: one key space, the spawn argv budgets and env policy, exactly one exit per spawn, rejections staged loud and regenerating - Output coalesces through a per-pty staging ring paced by the drain (lossless back-pressure: a full ring parks the reader, never drops), delivered as bounded batches journaled via the content-addressed blob store; replay never spawns a process - The fake pty scripts the whole vocabulary headless (request/write/resize/kill mirrors plus output/exit feeds), and live POSIX tests pin coalescing, losslessness, controlling-terminal wiring, and teardown convergence * Render the terminal example: libghostty-vt grid on the canvas, keyboard-first, replayable - grid.zig wraps one emulator session (cell state, damage, scrollback, keyboard selection) and paints the viewport as real text: per-row background/text runs, theme-mapped ANSI-16 with exact 256-color and truecolor, wide CJK cells, a scrollback thumb - main.zig drives it on the pty vocabulary: typing rides the IME-correct text channel and the emulator key encoder, selection/scroll/copy chords, a variable-length chrome prefix for the grid, and the frame pump resizes the grid and pty together - UiApp gains on_text (the target-less committed-text seam) and a variable_prefix chrome mode; the gpu-surface layer routes unclaimed text_input to the app, the key_down fallback's twin - Tests pin the emulator round trip, CJK width, scrollback, line/block selection, palette honesty, and a fake-pty session that replays byte-identical offline * Document the terminal recipe, platform matrix, and replay story - docs/terminal: the pty vocabulary, coalesced output, the exit taxonomy, grid rendering, and the byte-identical offline replay story, with the honest macOS/Linux/Windows-staged/null matrix - Registered in the Core Concepts nav beside Dynamic Images - Changelog fragments for the pty vocabulary, the terminal example, on_text, and the TS Cmd family * Harden the pty transport: exec-failure detection, teardown, and write accounting - pty spawn distinguishes a failed exec from a real 127 exit via a close-on-exec self-pipe (a bad shebang interpreter now reports spawn_failed, not exited), and rejects argv entries with embedded NULs instead of silently truncating them at the C boundary - The io loop observes shutdown so a reader parked on a full staging ring exits promptly at teardown instead of forcing the abandon path - dropped_writes now counts outbound bytes lost to a write failure or a child that exits with input still staged; a kill racing a natural exit rewrites the terminal to cancelled with the -1 sentinel code - Bounded per-drain output: a delivered batch re-stamps the remaining backlog past the pass boundary, so a continuously writing child delivers one batch per drain instead of starving other events * Harden pty fd hygiene, the kill/reap races, replay provenance, and grid fidelity - pty spawn aborts if CLOEXEC on the exec self-pipe cannot be set (a leaked write end would hang the parent), and the wake pipe is now CLOEXEC so no descriptor leaks into the child; empty PATH components resolve as the current directory (POSIX) - ptyKill no longer signals a reaped pid (the OS may have reused it) and the io loop winds down on kill even when an escaped descendant holds the pty open, so the exit always delivers; dropped_writes counts each lost payload, not one lumped event - Platform-unsupported rejections are executor truth and feed under replay (a journal recorded where ptys are unsupported replays its rejection verbatim); the regenerating marker rides the record's truncated bit so a pty exit reason stays honest, with a replay damage gate for the pairing - feedPtyOutput refuses an over-bound batch instead of truncating, and every fake pty (not just replay parks) refuses a second feed after its exit is queued - Example: inverse-video cells paint text in the background color (not foreground-on-foreground), the grid self-limits to its chrome command budget so a pathological screen degrades to fewer rows, and the Linux default shell is /bin/sh (present on every install, unlike /bin/bash) * Wire the pty command family into the TypeScript tier - Cmd.ptySpawn/ptyWrite/ptyResize/ptyKill (wire opcodes 0x19-0x1C, additive over the current cmd format) expose the pty vocabulary to transpiled cores, with an event arm matched by field name (key/kind/bytes/code/reason/signal/droppedWrites) - The rt kernel encodes the four ops; the emitter lowers and validates them (argv and grid bounds) with teaching diagnostics; the ts-core host decodes them into the runtime Effects pty calls and dispatches events back to the core's event arm - Posting stays native-only (transpiled cores are single-threaded): the TS tier spawns, writes, resizes, kills, and receives. Covered by packages/core effects/conformance tests and the ts_core_host bridge tests, plus a cmdview decoder arm so the evals harness names the new commands * Harden pty close-on-exec fds, the pid-reuse guard, exact write accounting, and NUL-safe TERM - The pty parent end/child end (and the exec self-pipe read end) are close-on-exec, so a concurrent process or pty spawn on another thread cannot inherit another session's descriptors and hold its pty open; pipePair now aborts if either non-blocking fcntl fails instead of returning a pipe that could hang the UI thread in nudge - Closed the pid-reuse window: the io thread publishes a reaping flag under the mutex before waitpid, and ptyKill and teardown decline to signal once it (or exit_staged/io_done) is set, so a reaped child's reused pid is never signalled - dropped_writes now tracks per-payload boundaries: a fully-sent write pops as its bytes flush, so a discard counts only the writes that never reached the child, not already-delivered ones - ptySpawn rejects argv or TERM containing an embedded NUL (validated at the effect boundary so the fake executor and replay agree), never a silently truncated value * Bound the pty exec probe and reap, make kill signalling atomic, and cap the grid command budget - The exec-status probe polls with a timeout instead of a blocking read: a real exec failure writes its byte instantly (always detected), success is EOF, and the timeout is the net for the residual CLOEXEC window so a concurrent fork inheriting the pipe writer can never hang the spawn. Pipes are created close-on-exec atomically on Linux (pipe2); Darwin keeps the immediate fcntl behind the same timeout net - reapEnding replaces the unbounded reapBlocking after the stream ends: the normal case (child already exited) returns at once with no signal, and a child that closed its terminal but kept running is hung up and escalated to SIGKILL within a bounded window, so the exit always arrives and no kill is stranded - ptyKill and teardown signal the child UNDER the shared mutex, atomic against the io thread's pre-waitpid reaping publish, so a reaped pid's reuse is never signalled - dropped_writes stays exact: the outbound record ring is an admission bound (a write past it is refused and counted once, like a full byte buffer), so every accepted write owns a length record and none is folded - Example: the grid's per-row command reserve accounts for background + text + underline per cell (3/column) so a pathological screen truncates safely under the chrome budget; a resize allocation failure leaves the model dimensions uncommitted so the emulator and pty never disagree and the frame pump retries * Open the pty pair with an atomically close-on-exec child end, reset reused header flags - Replace openpty with posix_openpt + grantpt + unlockpt and open the child end O_CLOEXEC atomically: the child end is the descriptor whose inheritance across a concurrent fork's exec would hold the pty open and starve the exit event, and an atomic open closes that window on both platforms (the parent end's Darwin sub-syscall window is benign — an inherited parent end does not hold the pty open) - Reset every reused PtyShared flag on a new spawn (reaping, the outbound write records) so a prior session's state never steers the next: a stale reaping would make ptyKill skip its immediate SIGKILL - Declare ioctl variadic to match the C ABI — a fixed-arg declaration mis-passed the winsize pointer on arm64, silently dropping the initial grid and every resize; the resize test now verifies the applied size, not just the call * Handle already-reaped children, low child end fds, post-exit writes, and pre-output input - reap reports ECHILD (a child reaped by an embedder's own SIGCHLD handling) as a gone child, never as still-running, so the escalation path cannot signal a reused pid - Relocate a child end that lands on fd 0/1/2 to a high descriptor before login_tty: a same-fd dup2 does not clear close-on-exec, which would make the child's stdio vanish at execve when the host started with standard descriptors closed - ptyWrite drops a write once the reaper has staged the exit: there is no io thread left to send it, and mutating the finalized exit's dropped_writes would race the drain - The terminal example accepts input from spawn onward, not only after the first output batch — a shell with an empty prompt and no banner never flips to live, and gating input on it would strand every keystroke * Relocate low pty fds, wind down orphaned ptys, commit-only text, IME preedit, guarded restart - Relocate the child end AND the exec self-pipe write end above the standard descriptors before the fork: login_tty dup2's the child end onto 0/1/2, and a write end left on fd 1/2 would be clobbered, making an exec failure masquerade as a normal exit when the host began with stdio closed - Bind-time services-snapshot failure now winds down already-running ptys (bounded), not just channels, so their exit still delivers instead of stranding with no wake services - on_text delivers COMMITTED text only: an IME preedit (set_composition) or cancel routed through a focused non-text widget no longer types provisional bytes into a terminal - The targetless text path tracks the IME preedit and delivers it on commit — hosts emit an empty commit for unchanged marked text, so the composed bytes come from the buffered preedit; only committed UTF-8 reaches on_text - The terminal example restarts only a genuinely ended or failed session, never during starting/live, so a quiet shell is not duplicated onto its own occupied key * Non-blocking pty parent end, detach at retire, UTF-8-safe preedit truncation - The pty parent end is opened non-blocking and read/write surface WouldBlock: the sole io thread paces both directions through poll and never blocks inside a write when the child stops reading stdin while producing output, so stdout keeps draining and neither side deadlocks (ptyFlushOutbound writes at most one chunk per POLLOUT, leaving the rest staged) - Retirement detaches the io thread instead of joining it: retirement runs at exit delivery, inside the very dispatch a synchronous-marshal wake hook may be waiting on, and the thread's last act is that host wake — joining would recreate the deadlock ChannelWake forbids. By retirement the thread has published io_done and is past all transport/staging/pipe access, so detaching and reclaiming its fds is safe (conforming enqueue-only wakes have already returned; a violating one lingers against the process-lived header) - A truncated targetless IME preedit now cuts on a UTF-8 boundary, so an empty commit never forwards a split code point as committed text * Quiesce every pty wake at teardown, route IME to editable widgets only, chunk input - Teardown now quiesces the wake header of EVERY pty slot, idle included: the header is process-lifetime and reused, so a retired session's slow wake_fn can still be in flight when the services snapshot and platform are freed. A call still executing at the deadline is abandoned, counted, and the platform signalled to outlive it — the abandoned io-thread path accounts for its quiesce failure the same way, no longer silently - Text and IME route to a focused widget only when it is an editable text-entry widget; a focused non-text widget (a button, a list row) lets committed text fall through to the target-less on_text seam instead of dropping it - The targetless IME preedit buffer holds a full phrase-sized composition and the child's exec-failure report retries EINTR, so an interrupted one-byte report is never mistaken for a successful exec - The terminal example chunks committed text into per-write-bound pieces, so a long paste or IME commit types every byte instead of being refused whole * Free the pty header on clean teardown, honor emulator colors, bound the grid text store - A cleanly quiesced pty header is now freed at teardown: unlike a channel header (which an app thread may post to forever), the pty io thread is the sole toucher and is gone once quiesce confirms no in-flight wake, so repeatedly creating and destroying runtimes that used a pty no longer leaks ~64 KiB each; an abandoned header still leaks deliberately - The grid reads the emulator's resolved foreground, background, and cursor: the theme colors are pushed into the emulator's DEFAULTS so ghostty composes OSC 10/11/12 overrides and DECSCNM reverse-video itself, and the renderer honors an application's requested colors instead of forcing the theme - The grid degrades by whole rows when the display-list text store nears its budget (an emoji-dense screen), never blanking cells mid-frame with a swallowed allocation failure - Documented that an .exited pty terminal carries -1 only when the child was reaped outside the toolkit (an embedder installing SA_NOCLDWAIT / ignoring SIGCHLD / running its own reaper) — an already-broken parent/child contract where the real code is unrecoverable * Keep the pty header process-lived, serialize ptsname, honor OSC 4 by mask - Revert the teardown free of PtyShared: quiescing proves in_flight is zero, not that the io thread has RETURNED (it publishes its exit before calling requestHostWake, and a detached mid-life thread's late or violating wake still locks the header's mutex), so freeing risked a use-after-free. The header is now permanently retained like ChannelShared — a bounded per-slot leak that never grows during a runtime's life - ptsname's shared static buffer is resolved and copied under a process-wide spinlock, so concurrent pty spawns from independent runtimes on different threads never open each other's child end - The grid decides an ANSI slot is untouched by the emulator's override MASK, not RGB equality: a program that OSC-4-sets a slot to exactly the default RGB is honored instead of being replaced by the theme color * Stop macOS Option double-input and stage heavy graphemes whole - On macOS, Option is a compose key: Option+F commits the composed character through the text channel, so the key encoder no longer also treats Alt+printable as a chord there (which sent both the composed character and an Alt-escape to the child). Elsewhere Alt stays Meta - The grid's run staging buffer holds a full row plus a heavy grapheme cluster (8 KiB), so a cell with hundreds of combining marks stages whole; the run-break splits long runs across draw commands rather than overflowing, and any residual cut lands between code points (valid UTF-8) * Block for the exec verdict on Linux, refuse empty output records, make grid rows atomic - The exec-status probe blocks for a reliable verdict on Linux, where the report pipe is atomically close-on-exec (pipe2): a slow execve (an interpreter on a sluggish filesystem) is now awaited and correctly reported as spawn_failed rather than assumed successful. Darwin keeps the bounded poll — it has no atomic-CLOEXEC pipe, so blocking could hang on the fork-inheritance window (the documented residual every macOS terminal shares) - Replay refuses a .output pty record with a zero-length blob: the recorder journals output only for a non-empty batch, so a zero-length one is damage that would otherwise replay a synthetic empty event and diverge the fingerprint - The grid measures each row's exact text bytes and skips the row WHOLE when the display-list text store cannot hold them, and breaks a run before a cell whose grapheme would overflow the run scratch — so a heavy-grapheme row is never torn mid-way and a large cluster landing near the buffer's end keeps all its marks - The terminal-response staging buffer holds a large pipelined query burst (16 KiB); an overflowing reply is still dropped whole (never cut) and counted * Zero the signal on a cancelled pty, chunk query replies, grow the IME preedit to fit - A cancelled pty exit reports signal 0: the toolkit's own SIGKILL is what felled it, and the contract is "signal is nonzero only for a .signaled end", so the raw SIGKILL value no longer leaks into a cancelled terminal - Terminal query replies (DSR/DA/XTVERSION) flush through the chunking write path, so a batch that pipelined more than one write's worth of replies delivers every byte instead of being refused whole and leaving the child waiting - The targetless IME preedit buffer grows to fit the composition instead of truncating at a fixed size: each set_composition replaces it with the host's full string, reallocated only when a larger one arrives and freed at runtime deinit, so an unchanged commit of any length forwards the whole composition * Ignore empty pty output, clear stale preedit on OOM, reset on restart, map function keys - feedPtyOutput ignores an empty batch: the live drain only delivers non-empty output, so journaling a zero-length one would write a blob record replay refuses as damage - A failed IME preedit grow clears the buffer instead of leaving the superseded composition, so a later empty commit never inserts stale text the user has replaced - Restarting the terminal hard-resets the emulator (RIS + a fresh parser), so a shell that exited mid-escape or in a non-default mode does not corrupt the next session's key encoding or first output - The key map covers Insert and F1-F12, which carry no committed text and would otherwise be dropped before reaching the child * Bound the exec probe on both platforms, gate impossible exit records, clear selection on restart - The exec-status probe blocks up to a generous bound on every platform rather than forever on Linux: O_CLOEXEC closes an inherited pipe copy on the concurrent forker's exec, not its fork, so a forker slow to exec would delay EOF and an unbounded wait could hang the spawn thread. The bound caps that pathological wait; a timeout means our own child exec'd (an inherited writer merely delayed EOF) and is success. A slow-failing exec under the bound is still awaited and reported; only one that both blocks and fails past it misclassifies (an extreme filesystem pathology) — a self-pipe cannot distinguish that from a delayed EOF without the bound - Replay refuses a pty exit record whose code/signal contradict the delivered contract (signal nonzero iff .signaled; code -1 for every reason but .exited), so a hand-edited .cancelled with code 0 or signal 9 cannot dispatch a contract-violating event - Restarting the terminal leaves selection mode, so a restart while a selection caret is armed does not reject the new shell's typed input * Name the two ends of the pty pair parent and child - The controlling side the toolkit keeps is the parent end; the process side the spawned program adopts as its controlling terminal is the child end. Rename the transport field, the spawn locals, and the poll helper to parent/child, and rephrase every comment to match. - POSIX's own API names (posix_openpt, ptsname, grantpt/unlockpt) keep their C spellings at the call site; only the words the toolkit owns change. * Carry the session key on pty events, normalize fake exits, keep query replies lossless, and shrink the pty leak - The transpiled pty event arm now carries the app's own session key: two sessions routing one event arm were previously indistinguishable, so the arm gains a `key` field the host fills from the wire key (emitter, host bridge, and SDK type updated together). - `feedPtyExit` clamps its tuple to the event contract the live io loop guarantees — a signal only after a signaled end, a code only after an exited one — so a fake or replay can never stage an exit the recorder journals but replay's damage gate then refuses. - The terminal example feeds output in sub-slices and drains the emulator's query answers between them, so a burst of pipelined replies cannot outrun the write-back buffer and a child blocked on a DSR answer never hangs; a residual overflow surfaces on the status line instead of vanishing. - A pty session's ~64 KiB outbound ring and its per-write length ring move into a heap block freed at retire, so only a small header joins the process-lifetime leak (the channel header invariant), not the buffers; a teardown that abandons a stuck io thread still leaks the block with the thread that can reach it. * Make the pty rejection key self-contained and enforce the signaled-exit contract - A staged spawn-rejection Msg is delivered a frame after it is issued, past a frame-arena reset, so it can no longer copy the wire key into that arena: it carries the empty key (the self-contained-Msg rule the bytes field already follows), leaving the durable engine-key routing for live events untouched. - feedPtyExit now enforces the full exit contract as a biconditional — a signaled end REQUIRES a nonzero signal, not just a signal only on a signaled end — so a fake or replay feeding .signaled with signal 0 is refused loudly instead of journaling a record replay's damage gate would later reject. * Give replayed pty output the -1 code, keep the rejection key, and bound the exec probe by deadline - A replay-fed output batch now carries the -1 code sentinel the live drain delivers (the Entry default was 0), so an app that folds the event code into its model no longer diverges between a live run and its replay. - A staged spawn-rejection keeps the app's requested key: it is copied into a process-static ring (durable across the frame reset the staged Msg outlives) and referenced directly, so a duplicate-key or table-full refusal is correlated with the command that caused it instead of arriving keyless. - The exec-status probe bounds itself by a monotonic DEADLINE rather than a per-poll timeout, so signals interrupting poll (EINTR) can no longer each restart the full wait and defer the timeout indefinitely — the synchronous spawn stays bounded under a signal storm. * Make staged rejection keys grow with the batch, pace pty input to the FIFO, and reserve grid widget text - Staged spawn-rejection keys move from a fixed host ring to a growable engine store (stageLoopKey): each key gets its own stable heap buffer reclaimed when the staged stage next empties, so a Cmd.batch staging more rejections than any fixed ring holds can no longer overwrite a key still awaiting delivery and mis-correlate an exit. - The terminal example buffers typed and pasted input and drains it against the pty stdin FIFO's free space (new ptyOutboundFree query), so a paste larger than the 64 KiB FIFO no longer loses its tail when the child is not reading; it paces out as the child reads, with a per-frame flush covering a child that reads without echoing. Query replies stay direct and the flush reserves FIFO headroom for them, so bulk input never starves an answer the child blocks on. - Grid painting reserves display-list text bytes for the header and status widgets that share the per-view text store, so a grapheme-heavy viewport degrades to a few fewer rows instead of pushing the combined frame past the runtime limit and failing the whole update. - Any residual dropped input surfaces on the status line beside the reply-drop count. * Report pty write acceptance so the terminal never loses input or query replies - ptyWrite now returns whether the whole payload was accepted, since it alone knows the byte-FIFO and 256-record admission limits; a byte-capacity query could report room the record ring would still refuse, misleading a caller into treating a rejected write as sent. The misleading ptyOutboundFree query is removed. - The terminal example drains one stream-ordered outbound ring holding typed keys, pastes, AND emulator query replies, retrying any chunk ptyWrite refuses instead of dropping it. A paste larger than the FIFO paces out as the child reads; a reply refused by a full FIFO stays queued rather than being cleared, so a child blocking on a DSR answer is never stranded. A per-frame flush covers a child that reads without echoing, and a full-ring overflow is counted and shown, never silent. * Journal pty write verdicts for replay and free pty buffers through their allocating seam - A ptyWrite admission verdict is executor truth — whether the outbound FIFO and record ring had room depends on how fast the child was reading — so each one now journals (a .pty/.write record) and a replayed write returns the RECORDED verdict instead of recomputing optimistically against a fake with no child: an app that retains refused bytes takes the identical retain/remove path in both runs, pinned by a recorded session whose refused and accepted writes replay fingerprint-identical. The journal format fingerprint moves with the new record kind, refusing pre-verdict journals honestly. - Pty staging rings and outbound blocks now free through the channel_storage_allocator seam that allocated them, so a swapped-in tracking or arena allocator sees its own frees instead of a mismatched destroy against the default backing. * Scope target-less IME preedit per surface and gate command chords out of target-less text - The target-less preedit buffer now tracks its originating surface (window + view label), matching how a focused widget's editor scopes composition to the widget: only the owning surface's commit consumes the buffered bytes and only its events clear them, so a second surface's empty commit can never insert a composition typed into the first. - Target-less text_input now applies the same command-chord gate focused text widgets use (primary/command/control means shortcut, not typing; Alt stays out — Option and AltGr compose text), so Ctrl+C delivers the encoded chord alone and never a stray literal character; pinned at the runtime layer and against the terminal example's pty input. * Deliver key-carried typing target-less, count post-exit write refusals, and complete the terminal key map - Committed text carried on a key_down (hosts whose plain typing rides the key event, with no separate text event) now reaches the target-less on_text seam under the same command-chord gate the focused-widget path applies — ordinary typing was silently lost there, while specials carry no text on any host so nothing doubles with the key fallback. - A ptyWrite refused in the staged-exit window now counts into dropped_writes (the exit reads the count under the mutex at drain delivery, which has not happened yet), closing the one silent-refusal gap; only a write after the exit delivers finds no slot, the documented cancel race. - Every desktop platform now reports delete/home/end/pageup/pagedown/insert and f1-f12 as named keys (previously private-use strings or nothing), shortcuts and menu accelerators accept them, and the terminal example maps chorded punctuation and supplies the pressed character to the emulator's encoder — Ctrl+\ reaches the child as its C0 byte, Ctrl+[ as the encoder's fixterms CSI-u form, F1 as ESC O P. * Reset OSC colors on shell restart and honor widget precedence in target-less text - Session.reset now clears the palette and dynamic color overrides (OSC 4/10/11/12): the emulator's full reset leaves its color state alone, so a shell that tinted the palette and exited would otherwise color the next session; overrides drop while theme defaults stay. - The target-less committed-text fallback now honors the widget-precedence contract's structural-claim step: a key the focused widget answers as a control intent (Space pressing a focused button) no longer ALSO types its literal character through on_text, on either committed-text carrier, while unclaimed characters keep flowing to a terminal that happens to share focus with a button. * Wire the new named keys through every platform's menu accelerators - GTK translates them to accelerator names (Page_Up, F1, ...) so gtk_application_set_accels_for_action actually binds them; AppKit converts named keys to their NSMenuItem key-equivalent characters (function-key unichars, control characters) instead of passing the canonical string through, which also repairs the pre-existing arrow/escape menu bindings. - The Windows keydown path now dispatches menu-item accelerators alongside registered shortcuts — a menu item's key+modifiers emits its menu command exactly as if clicked; AppKit and GTK get this from their menu systems, and on Win32 the message loop is that system. * Settle replay feeds at the journal's end and make pty record accounting exact - Replay now verifies at the journal's end record that every journaled ptyWrite verdict was consumed and no write ran past them (the new .settle replay control): a write-count divergence changes no state when the caller is fire-and-forget, so fingerprint checkpoints alone could pass a diverged run — the settle check fails it loudly in both directions. - Output records journal the canonical scalar shape from BOTH drain sites (-1 code sentinel, no signal, no drops) and the replay damage gate refuses records claiming anything else — previously replay silently delivered defaults that differed from a damaged record's journaled fields. - A fed exit's dropped_writes now adds the refusals the fake itself counted (an oversized write against a scripted pty), so the counted-refusal contract holds without every script re-deriving the tally; under replay the slot count is always zero and the journaled count delivers verbatim. * Keep an IME sequence with the consumer it started over - A composition buffered by the target-less consumer continues target-less even when a text widget takes focus mid-sequence: routing its empty commit to the newly focused editor would resolve a composition that editor never saw and lose the composed text. Ownership ends with the sequence — the next composition belongs to the focused editor as usual. * Admit outbound terminal payloads whole or not at all - The pending-outbound ring's admission is now all-or-nothing: a payload it cannot hold whole is dropped whole and counted, never cut at the ring edge — a torn query reply or encoded key would feed the child a malformed escape sequence, which is worse than a counted loss. Reaching the drop at all means the child ignored 256 KiB of pending input, and the count stays on the status line. * Key the replay write verdicts by pty and grow the queue with the recording - The verdict queue now spills to the heap past its inline window (the pending-stage growth story): every verdict a dispatch recorded feeds before that dispatch replays, so a fixed bound would reject a valid recording whose one update wrote more times than the bound — a giant paste drained in per-write chunks is exactly that. - Each verdict carries the pty key it was recorded against, and a replayed write consumes only a matching-key verdict: the same call count and acceptance results against a DIFFERENT session is still divergent input, which a global boolean sequence would silently pass; a mismatch counts as divergence, strands its verdict, and fails the end-of-journal settle on both signals. * Canonicalize menu key case, teach the Chromium host the named keys, and state the reaping contract - Menu-item keys canonicalize to lowercase at each host's storage boundary, matching the shortcut stores that already did: key names validate case-insensitively, so a mixed-case spelling must still translate to a valid GTK accelerator name and a correct AppKit key equivalent (single characters lowercase there too — uppercase implies Shift, which the modifier mask already expresses). - The macOS Chromium host's key normalizer gains pageup/pagedown/insert and f1-f12, so shortcuts on those keys fire under that host exactly as under AppKit. - The kill fence's ownership premise is now stated at the signal site and in the recipe: the toolkit forks each pty child, is its sole reaper, and never touches SIGCHLD disposition — an embedder must not either, because POSIX offers no portable way to signal a pid once a third party (including a kernel auto-reap) can free it first; inside the contract the reaping fence is exact. * Settle the clock feed too, and retain query replies a full ring refuses - settleReplayFeeds now holds the clock feed to the write-verdict rule: a wallMs read past the journaled values (answered 0 optimistically) or a journaled value never consumed is divergence no checkpoint need see, and either fails the end-of-journal settle loudly. - A query reply the pending ring cannot take right now stays IN the emulator's buffer — uncleared, uncounted — and retries on the next output, resize, or frame nudge, so a child blocked on a DSR answer behind a full ring is never stranded by a discarded reply. Transient payloads (typed text, encoded keys) keep the counted-drop disposal since their bytes cannot outlive the dispatch; only a payload larger than the whole ring is impossible and counts immediately. * Align start-failure write verdicts, grow the reply buffer, and strip the primary alias from encoder chords - A write against a spawn whose transport failed synchronously now journals its refused verdict: the staged executor-truth terminal keeps the key occupied on the live side exactly as the parked slot does under replay, so the two verdict streams stay aligned where they previously diverged (live refused silently, replay consumed a verdict that was never recorded). - The emulator's query-reply buffer is heap-grown to fit (up to the outbound ring's own capacity): replies retained behind a full ring keep accumulating while further output feeds, instead of overflowing a fixed buffer into counted drops that could strand a blocked child; past the ceiling a reply still drops whole and counted, never cut. - The terminal encoder strips the runtime's primary-into-super fold when Ctrl raises it, so a bare Ctrl chord reaches the emulator clean and Ctrl+C delivers ETX — a stray super demoted it to a CSI-u chord no foreground shell treats as an interrupt. The GUI+Ctrl double chord encodes as plain Ctrl, the convention terminals follow. * Hold stdin order across a retained reply - A query reply retained behind a full outbound ring is older than any later keystroke, so transient input now gives the reply its retry first and refuses to jump the queue while it remains stuck (dropped counted, never reordered) — the child's stdin keeps the order a real terminal delivers: the answer it is parsing toward, then the typing. - The output arm retries retained replies right after its flush frees ring room, so the oldest bytes take that room before anything a later dispatch could enqueue. * Refuse and count fake writes after the staged exit, folding drops at delivery - A fake pty now mirrors the live admission path's staged-exit rule: a write after feedPtyExit staged the terminal refuses and counts as dropped, never a silent acceptance into a session that is already over. - The dropped tally folds the slot's count into the delivered exit AT DELIVERY (both the fed-exit queue drain and the start-failure stage), the same read-at-delivery rule the live staged-exit drain follows — so refusals landing between the feed and the drain still reach the delivered count; under replay the slot count is provably zero and the journaled count delivers verbatim. * Land start-failure write refusals in the staged exit's tally - A write refused in the synchronous start-failure window now counts onto the staged terminal entry itself: that terminal's slot was already released, so no slot tally could carry the drop to delivery, and the exit reported zero despite the refusal. The counting walk is the occupies-key predicate with the bump folded in, so the verdict journal and the delivered tally move together — the exit reports every refusal, never silence. * Settle undelivered pty feeds, serialize the spawn's descriptor window, and state the process-group kill limit - settleReplayFeeds now refuses fed pty results still queued at the journal's end: every result was journaled at its live delivery, inside an event that follows it in the stream, so a leftover means the journal was truncated past its consuming event — succeeding would silently omit a recorded delivery. Regenerating staged rejections stay exempt (the live run could end with one staged too). - The pty spawn's whole descriptor-opening window — posix_openpt through fork — now runs under one process-wide lock, and the parent end's close-on-exec fcntl is verified: Darwin ignores O_CLOEXEC on posix_openpt, so a concurrent toolkit fork landing inside another spawn's flag gap would gift its exec'd child a copy of that pty's parent end for the child's whole life. Our own forks can no longer land there; an embedder fork keeps the documented residual window the exec probe's timeout nets, and the probe still polls outside the lock. - The kill contract now states its real reach at every site: SIGKILL lands on the child's process group (the whole foreground job), and a descendant that re-grouped itself escapes — POSIX has no kill-whole-session primitive, the spawn family's documented limit. * Cover every descriptor window with the spawn lock, bound the surrendered reap, and carry an unresolved exec probe to the reap - Every toolkit fork and every Darwin flag gap now shares one lock: the wake pipe's creation and the job-spawn family's process start hold the pty spawn lock, so no toolkit child can inherit another spawn's not-yet-CLOEXEC parent end, pipe writer, or wake pipe across its exec — only embedder forks on threads the toolkit does not own keep the documented residual window. - reapEnding's final wait is bounded: a SIGKILL'd child the kernel holds in uninterruptible I/O cannot be waited out by any signal, so past a further deadline the reap is surrendered and the kill reported as the ending — the exit always reaches the app, and the eventual zombie is the bounded, documented cost that beats an io thread wedged forever. - An exec probe that times out no longer guesses success: the status pipe rides the transport (non-blocking) and is read at reap time, when a failing exec's byte — written before its _exit — is present by construction, so an executable that blocks past the bound and then fails still delivers spawn_failed, never a masqueraded normal exit. * Intern staged rejection keys for the model's life, bound failure-path reaps, and free the spawn lock from job starts - Staged rejection keys are now INTERNED and instance-lived: the TS commit walker shares non-frame pointers into the committed model rather than copying them, so a reducer that stored a rejection's key held memory the old reclaim could free mid-run. Interning bounds the storage by the app's distinct key vocabulary (repeat rejections of one key cost nothing) and the buffers outlive every model that references them. - The pty thread-start and wake-snapshot failure paths use the escalate-then-surrender reap: a child wedged inside an exec on a stalled mount cannot be waited out, and an unbounded waitpid there froze the UI loop instead of delivering spawn_failed. - The job-spawn process start no longer holds the pty spawn lock — it blocks on its own exec-status pipe, so a stalled execve would have wedged the lock and frozen the UI the moment a pty needed it. A job fork landing in a pty flag gap joins the documented bounded residual (inherited copies die at the job child's exec; a delayed exec-pipe EOF resolves through the carried reap-time verdict, never a guess). * Pin compositions to their starting editor, shorten the exec probe, and re-poll surrendered reaps - An IME sequence now routes its continuations to the text entry it STARTED in: a per-view owner is pinned when a set_composition routes and released when its commit, cancel, or a direct insertion closes the sequence — so a focus move mid-composition no longer strands the starting editor's marked text or leaks the commit through the target-less fallback, the mirror of the target-less ownership rule. - The exec probe's deadline drops to half a second: the unresolved verdict carries to the reap where a late failure still reports spawn_failed exactly, so the synchronous wait buys only the instant-failure nicety and no longer holds the loop for seconds; the PATH walk's access() probes remain the documented stalled-mount residual of the spawn-position verdict the replay contract requires. - A surrendered reap now parks its pid on a process-wide list re-polled (WNOHANG) at later spawns and at teardown, so a child that dies after its device or mount recovers is reaped then instead of zombieing for the process's life. * Decide the text claim before app rebuilds, swallow orphaned compositions, and state the Darwin pipe residual - The target-less committed-text claim is decided against the tree the input actually routed through, BEFORE the app dispatches that may rebuild it: a Space that pressed a focused button whose command removes that button still counts as claimed, so one physical keystroke can never double into a command and a literal space. - A widget-owned composition whose editor vanished mid-sequence resolves nowhere: its commit or cancel is swallowed (the newly focused editor never saw the composition, and the target-less consumer never composed it) and the stale pin clears, so a fresh composition reopens cleanly at the focused editor. - The Darwin pipe-then-fcntl window is stated fully at its site: pty forks are locked out, and the residual — a job or embedder fork landing inside it — is bounded on every axis (the copy dies at that child's exec, a wedged exec costs at most the half-second probe with the carried verdict keeping the outcome correct, a held wake pipe merely outlives its session's close until that exec). * Route converted commits to the composing editor, latch orphaned sequences whole, and keep EINTR'd surrendered pids - Every host encodes a converted commit (the composed result differs from the marked text) as cancel-then-text_input, so a cancel now holds the owner pin one event and arms a one-shot grace: the trailing text_input lands in the editor that composed it, never the newly focused one — while a plain cancel's own key_down disarms the grace before ordinary typing could inherit it. - An orphaned sequence swallows EVERY continuation: the pin holds through preedit updates (the sequence is still open, and the focused editor must not inherit them) and releases only at its commit or cancel, with an orphaned cancel arming the swallow grace so the converted commit's trailing text vanishes with the sequence instead of typing into a stranger. - reapSurrendered keeps a pid whose non-blocking poll was interrupted, rather than abandoning a live child to zombie unreaped; only a reap or ECHILD settles the entry. * Give target-less compositions the converted-commit grace too - A target-less composition's cancel now arms the same one-shot grace its widget twin got: the hosts encode a converted commit as cancel-then-text_input, so the trailing text still belongs to the surface that composed it — delivered target-less on that surface, never inserted into whichever text widget took focus mid-sequence. A plain cancel's own key_down disarms the grace before ordinary typing could inherit it. * Retry the late-failure read, orphan-check the commit grace, and die with the view - lateExecFailure retries an interrupted read instead of mistaking EINTR for success: the failure byte may be sitting right there, and reading past the signal is what keeps a delayed exec failure reporting spawn_failed. - The route-to-owner grace re-checks its owner at consumption: the cancel's own app dispatch can rebuild the tree away from the composing editor, and a dead owner converts the grace to a swallow — the converted commit's text resolves nowhere, never in whichever editor holds focus by then. - A target-less composition dies with its view: removing a surface clears any sequence it owned, so a later view reusing the same window and label never mistakes its first composition for a stale continuation that would bypass its own focused editor. * Disarm IME graces on blur and view removal, and re-anchor selection across a resize - A view losing focus disarms its cancel-to-commit grace (and the target-less twin) and releases the owner pin: hosts emit a standalone cancel when a composing view blurs, and after refocus an IM-consumed keystroke's text_input arrives before its key_down echo — a stale grace would route that fresh commit to the old editor or swallow it. - Removing a view clears the target-less grace even when the preedit length is already zero (cancellation zeroes it before arming), so a converted commit's trailing text can never leak target-less into a surface recreated under the same label. - Resizing the terminal re-anchors an armed selection at the clamped caret: reflow moves every cell, so coordinates into the old grid are dropped rather than copied, and Shift+Arrow keeps operating inside the new grid. * Share the blur-side IME hygiene across every focus path, fix the backspace key equivalent, and range-gate exit records - Every focus-mutation entry point — the pointer-driven focus move, the programmatic focusView, and the window-level clearFocusedView blur — now routes through one shared blur helper that disarms the cancel grace and releases its owner pin, closing the lifecycle class structurally instead of per path; audited all view-focus writers to exactly these three. - The macOS "backspace" menu key equivalent maps to 0x7f, the byte the physical Delete/backspace key actually emits (and the key-event normalizer maps back to "backspace") — the nominal BS control 0x08 never fired; every other named key in the table was audited against the normalizer and matches. - Pty exit records are range-gated to what waitpid's status word can produce — codes 0..255 plus the documented -1 externally-reaped sentinel, signals 1..127 — at both the replay damage gate and the feed boundary, refused as damaged rather than replayed into an event no live run can emit; pinned with hand-damaged records on both axes and both directions. * Honor the requested TERM, AltGr text, IME resolutions past claims, flush-first admission, and a clipped grid - The pty environment now replaces an inherited TERM with the spawn's requested value (the spawn declared what terminal the child is attached to; a stale TERM=dumb from the host would misdeclare it), pinned live; and the restart resets the previous session's copy feedback. - AltGr input survives both layers: the text-chord gate exempts Ctrl+Alt together (hosts represent AltGr that way, so a text event carrying both is composed text, not a shortcut), and the terminal's key mapper stops encoding Ctrl+Alt printables as chords for the same reason — the composed character rides the text channel alone. An IME resolution (an owned empty commit, or a converted commit's grace-ridden text) also bypasses the structural-claim gate: it ends a sequence the focused widget never participated in, so a button's Space claim cannot eat it. - Outbound admission flushes before refusing (stale occupancy must not drop a keystroke the drain would have made room for), with a fake full-FIFO test seam standing in for a child that stopped reading; and the grid paints under a clip to its frame with an exact bottom-row guard, so the one stale pre-resize frame degrades to a cropped grid instead of painting over the status bar and past the right edge. * Deadline the reap graces, carry claims across split events, and finish the exit accounting - reapEnding's grace windows are monotonic deadlines: usleep returns early on EINTR, so iteration counting let a signal-heavy embedder collapse the 500 ms hangup grace into an almost-immediate SIGKILL and shrink the surrender window; the surrendered list is also re-polled at every session end, so a recovered child is reaped at the next pty activity of any kind. - The committed-text claim carries across the event split: hosts that deliver a claimed key_down and its committed character as separate events (unlike the key-with-text shape) would recompute the claim against a tree the activation already rebuilt — a one-shot per-view carry keeps one physical keystroke from doubling into a command and a literal character. View blur now clears the composition owner unconditionally too: an active sequence's pin surviving a blur would redirect post-refocus typing to the stale editor. - The example's AltGr exemption narrows to the host that actually represents AltGr as Ctrl+Alt — elsewhere that combination is a genuine chord that must encode — and session end is accounted honestly: cleared outbound bytes count as dropped, retained replies drop with the dead key instead of retrying against it every frame, transport write refusals reach the status tally, and a signaled or cancelled end says so instead of rendering as exited (-1). * Never strand a pre-bind pty exit, and die the claim carry at blur - A failed bind-site snapshot publication now stages a live pty's cancelled terminal from the loop past the wind-down deadline, so the exit delivers instead of waiting on a wake that can never fire - The split-event claim carry clears at blur with the IME grace: a claimed activation that moves focus no longer swallows the refocused surface's first commit - A restarted shell starts its refused-write tally at zero instead of inheriting the dead session's drops * Give the input method its keys, and make the grid preflight measure what paints - A target-less composition now owns its surface's key_downs (candidate navigation, the trailing resolver key on hosts that run the IM filter first), so confirming a candidate with Enter never also sends CR; the buffered preedit and its graces die at the surface's blur - The split-event claim carry is keyed to the armed activation key's own literal, so a different key's committed text arriving next flows instead of feeding a stale latch - The grid's row preflight now counts only bytes painting emits (invisible cells suppress, clusters cap at the run scratch), a session exit counts retained reply bytes as loss, and each fix is pinned in both tiers * Free pty staging only on the io thread's own completion proof - Retirement defers the staging/outbound frees to the thread's io_done publish (same critical section as its staged exit) and leaks them past an abandon, so a detached thread can never observe freed blocks - The outbound flush re-checks open/generation after its unlocked write before touching the block or folding drop counts, closing the one ungated deref - A superseded thread (slot reused after an abandon) goes silent at its next loop head and never publishes reaping/io_done into the successor; pinned with a deadline-miss abandon carrying an in-flight write and a free-counting seam * Suppress IM-consumed composition keys at the GTK source, and bound the grid by what the renderer can hold - Replace the one-shot resolved-key grace with host-side suppression: GTK surfaces no key_down for a key its input method consumed while composing, so a mouse-committed candidate never costs the next genuine Enter or arrow - A styled wide character's spacer tail extends the primary cell's background run, covering both cells for SGR backgrounds and inverse video - Painting stops row-atomically before crossing a distinct-code-point budget (the glyph-atlas proxy), and the run scratch grows to the full text store so any cluster the emulator holds paints whole; each fix pinned * Let compositions own their keys before widget routing, and put real cells in the terminal's semantic surface - A live target-less composition suppresses unchorded key routing ahead of every widget pass (dismissal, focus moves, activation), so a confirming Enter never presses a freshly focused button; chorded shortcuts stay live - hostRequest's inline pre-flight now rejects keys held by live or staged ptys, matching the shared keyed-effect namespace every other issuer enforces - The grid's accessibility label is the viewport text, carrying real cell state into the session fingerprint (equal byte counters with different screens no longer verify), and a failed selection re-pin clears the emulator selection instead of leaving a stale copyable range * Settle every fed family, refuse fed overflow loudly, and keep the semantic screen honest - The end-of-journal settle now reports ANY fed result left undelivered (every family plus journaled env records), and a fed line against a full queue answers EffectQueueFull for the replay pump's drain-and-retry instead of silently converting a recorded delivery into a drop - GTK swallows a suppressed composition key's release too (no orphan key_up), and Windows lets AltGr-composed WM_CHAR text through the chord gate it raises Ctrl+Alt for - The terminal's semantic screen text is heap-exact (never truncated or cut mid-scalar), refreshes when a scroll moves the viewport, clears to unknown on a failed render instead of going stale, and a failed selection serialization surfaces as a failed copy with the selection kept * Close the key-suppression edges and keep selection, scrollback, and copy coherent - GTK dedupes suppressed composition keys under autorepeat and clears them at focus loss; Windows discards the WM_CHAR a dispatched shortcut already consumed (AltGr chords type only when no accelerator matched) - The surrendered-reap table evicts round-robin on overflow, so every wedged child keeps a retry seat instead of all forgetting behind slot zero - Scrollback chords pause while a keyboard selection is armed (viewport-relative caret vs absolute range), and a copy over a vanished emulator range reports failure instead of quietly keeping stale clipboard content * Track suppressed keys by keycode, disarm graces the suppression starved, and confirm before clearing - GTK tracks composition-suppressed keys by physical keycode (a lifted Shift cannot rename the release) and a plain cancel whose resolving key_down is suppressed emits a synthetic duplicate cancel, so the runtime's cancel-to-commit grace disarms instead of eating the next ordinary commit - The Windows shortcut latch holds across the keystroke's whole translated burst (ligature layouts post several WM_CHARs), disarmed by the message sequence at the next key transition - A terminal selection now anchors at the live cursor (never the last painted snapshot) and survives until the clipboard write CONFIRMS - a failed write keeps it standing for the retry the status promises * Paint the terminal where the user can see it - The grid region spacer is a stack, not a panel: the panel's surface chrome (fill, border, shadow, rounded corners) painted OVER the chrome-prefix grid, blanking the whole terminal - Grid text anchors its BASELINE (origin is not the glyph top), so rows land in their cells instead of one line high with row zero swallowed by the clip - The session-state indicator is a quiet muted label instead of a pill that read as a half-painted toggle, and a painted-output oracle now renders the retained frame to pixels headless and asserts the prompt's ink and the caret's cell * Build the terminal example on hosts with only the command-line tools - Disable ghostty's macOS app and xcframework artifacts in the dependency options: only the vt module is consumed, and their configure step resolves the iOS libc, which aborts without a full Xcode install * Fall back to FIONBIO when Darwin's pty parent end rejects F_SETFL - Some macOS releases return ENOTTY from fcntl(F_SETFL, O_NONBLOCK) on the posix_openpt fd while the FIONBIO ioctl succeeds; the shared setNonblock helper tries fcntl first and falls back, so a spawn no longer fails whole on those kernels * Set the pty parent non-blocking only after the replica opens - Some Darwin kernels answer ENOTTY to both fcntl(F_SETFL) and FIONBIO on a pty parent whose replica has never been opened, and accept the identical call once it has; the spawn now orders the non-blocking step after the child end opens * Scope the AltGr chord exemption to Windows and keep an armed selection on its text - Ctrl+Alt is a genuine command chord on Linux and macOS (AltGr rides its own level-3 shift there), so the text gate exempts the pair on Windows alone - a chorded key mid-composition now reaches the app instead of being swallowed - Output that scrolls the live screen rebases the keyboard selection from the emulator's absolute pins: the caret follows the selected text, and a range that leaves the viewport clears selection mode instead of desynchronizing copy from the caret * Arm the cancel-to-commit grace only while a composition owner is pinned - A duplicate cancel (the hosts' synthetic disarm for a consumed cancelling key) arrives after the grace probe already released the owner; re-arming ownerless converted the next ordinary character into a dead-owner swallow, so the arm now requires a live owner * Honor negotiated kitty modes for committed text and key releases, and admit one copy at a time - Committed single-scalar text routes through the emulator's key encoder: byte-identical raw text under legacy modes, CSI-u under a TUI's report-all; multi-scalar IME commits stay raw per the protocol - Key releases reach the encoder through an opt-in on_key phase (key_release_events), emitting kitty release events when negotiated and nothing under legacy; repeats stay presses, the one event type hosts do not distinguish - A copy while the clipboard write is in flight is a no-op, so a duplicate-key rejection can never overwrite the first copy's success; the non-Latin Ctrl-chord gap is recorded as an accepted tail pending base-layout key data in the platform vocabulary * Wheel scrollback, geometric box drawing, and a window that is just the terminal - Trackpad and wheel scrolls over the grid ride a new on_wheel app channel (the pinch channel's sibling) into whole-row scrollback with fractional accumulation; inert while a selection is armed - U+2500-259F render as geometry at exact cell bounds - lines, tees, crosses, doubles, quarter-arc rounded corners, diagonals, blocks, shades, and quadrants - with identical-piece runs merged into single bars, so borders join seamlessly where font glyphs showed seams - The window is a standard titlebar plus the grid: header and status bar removed, size readout gone, keyboard hints moved to the README * One seamless surface: hidden-inset chrome with the terminal running under the traffic lights - The window drops its title and titlebar strip (hidden_inset): the theme background fills edge-to-edge including the titlebar band, the grid's text starts below the reported chrome inset, and only the traffic lights float over the terminal * Shift the video tests' journal byte offsets past the appended pty trailer - The pty record fields append 33 bytes after the video fields in every effect payload, so the hand-patching damage helpers now offset from the pty trailer * Stitch the pty conformance fixtures after the video fixtures - Two fixture-list merge points kept both families' cases in sequence; the video entries close before the pty entries begin |
||
|
|
87fa3f92d0 |
Video playback: AVFoundation through the media surface, replayable end to end (#184)
* Add the video playback tier: platform seam, effects channel, journal v9
- One video channel mirroring audio end to end: loadVideo with the local-then-URL cascade and surface claim, transport verbs (play/pause/stop/seek/volume/mute/loop), key-stamped events, honest failed/rejected degrades, and automation-snapshot mirrors
- Pixels never enter the core: the platform decoder pushes RGBA8 through a copyable VideoFrameSink into the media-surface texture channel the load claimed
- Journal format v9: the .video effect-result kind (code 13) and platform-event tag (code 25) append the video fields after the v8 channel fields and journal every event verbatim for byte-identical replay with no producer attached
* Cover the video tier: lifecycle, frames, teardown, replay identity
- Fake and real executor batteries mirror the audio suite: request capture, event round trips, transport mirrors, rejection classes, cascade order, staged-host degrade, straggler swallowing, and the quit-while-playing stop hook
- Sink coverage proves decoded frames reach the claimed surface, replace/stop release the claim, and a stale sink push lands inert
- A recorded playback replays byte-identical into a decoder-less host with no producer attached, fingerprint and model equal; journal codecs round-trip the video event and effect shapes
* Export the video effect types and decline video on the embed host
- native_sdk.EffectVideo/EffectVideoEventKind/EffectVideoSource ride the SDK roots like their audio twins
- The mobile embed host declines video_playback until a shim registers a real decoder, the audio honesty rule
* Decode video on macOS with AVFoundation; teach on Windows and Linux
- One AVPlayer in the AppKit host: AVPlayerItemVideoOutput frames fitted to the sink's pixel budget, BGRA-to-RGBA vImage swizzle, a 1/60s run-loop frame pump plus the audio tier's 0.5s position clock, loop wraps without a completion, and paused seeks still paint their frame
- The frame sink crosses the C ABI as a callconv(.c) trampoline over the runtime's VideoFrameSink; a released claim answers 1 and the host stops its pump
- Windows and Linux stage the capability honestly: video_playback reports false and the load verbs answer a named teaching plus error.UnsupportedService; the CEF host stubs the video C ABI like audio
* Add Cmd.videoLoad and videoCtl to the TypeScript tier at opcodes 0x17/0x18
- videoLoad claims the named media-surface, resolves the local-then-URL cascade, and bakes autoplay/loop/muted into one flags byte; videoCtl drives play/pause/stop/seek/volume/muted/loop by verb ordinal with an f64 value
- The event arm is the seven-field record matched by NAME with the exact five-member state union both directions, the audio arm convention; refusals teach with the NS1027/NS1029/NS1030 vocabulary
- The host routes events on the TSVI key namespace, parks loads under the fake executor like audio, and covers the wire byte-for-byte in the package, host, and e2e suites
* Declare video from markup: the <video> element, house chrome, reconciler
- Element code 68 with flag attrs controls/autoplay/loop/muted at codes 82-85 (src rides the existing attr name; registry law keeps names unique); a leaf on all three surfaces — validator, interpreter, compiled engine — rejecting children like image
- ui.video composes the playback surface on the framework-owned surface id plus runtime-consumed transport chrome (ghost play/pause, proportional scrub slider, clipped time readouts in the built-in bar's register); presence IS playback: the ui-app reconciler loads on src change, applies loop/muted deltas, and stops when the element leaves the view
- Handler-less playback stays honest under replay: platform video events steer the channel mirrors while journaled effect records remain the only Msg source, so house-chrome sessions replay with live readouts and identical fingerprints
* Scope declarative video ownership to the playback it started
- The reconciler stops or retunes the channel only while the active key is the declaration's own: an update handler that loaded its own playback owns the single player, and a departing <video> element must not kill it
* Add the video-player example: house chrome and custom controls
- Player screen is one declarative ui.video with the house transport chrome; Custom screen composes its own bar (transport, +/-10s, proportional scrub, volume, mute, loop) from the command vocabulary over a bare media surface
- No bundled media: the launch argument or the source field names a local clip or http(s) URL, so a live macOS check is one command away
- Headless tests drive the whole transport with the fake executor's synthetic events, the automation widget path, and the null decoder behind the declarative screen
* Document the video element across the docs surfaces
- Components page, catalog entry, native-ui element table row, and the LSP/vocab doc strings for src/controls/autoplay/loop/muted
- Deterministic preview scenes render the placeholder with the house chrome (regenerated video webps only; the untouched catalog keeps its committed renders)
- Changelog fragment states the new element, the command vocabulary, the staged platforms, and the journal v9 break
* Resolve video attribute docs in the markup LSP hover path
- attributeDoc consults the video scoped table so controls/autoplay/loop/muted hover like every registry attribute; the coverage pin now names the video composite
* Make the video-player example's docs and status line honest
- README run instructions use the real CLI verbs: native dev for build-and-run, or native build plus the zig-out binary with the clip argument (the argument passthrough shape)
- The status line now matches each screen's ownership model: the declarative Player screen shows a static teaching (its transport state lives in the runtime-owned chrome, so an event-fed status there could only lie or stall), while the Custom screen keeps the event-fed loading/dimensions/finished/failed line it owns; tests pin both across load, playback, pause, and completion
- Direct-SDK examples handle the runtime's video event in their exhaustive event switches
* Stage video events non-lossily and deliver fed terminals under replay
- Video events leave the lossy pending ring for their own non-lossy stage (the image/channel discipline): a loop-side .rejected or .failed is its load call's only terminal and a fed event is one recorded delivery, so a burst past the ring's capacity must never evict one — pinned by a 40-rejection burst test
- Fed events capture the handler at feed time and deliver their journaled values verbatim when the channel no longer resolves them: under replay the platform .failed event that follows the record applies the channel reset first, and delivery-time resolution silently dropped the Msg the recording dispatched — pinned by a recorded mid-playback failure replaying fingerprint-identical
- loadVideo's deterministic refusal classes extract to the pure videoLoadRejected, one source of truth a caller-side validator can consult
* Refuse an invalid video_load before it re-routes the bridge entry
- The engine keeps the current playback when it rejects a load, but the bridge re-keyed its single routing entry optimistically first: a refused replacement left the surviving stream's events and transport verbs answering to the refused key and arm
- The bridge now consults the engine's own videoLoadRejected gate before committing the entry and stages the rejection Msg to the refused arm directly (stageLoopMsg, the channel-admission precedent), leaving the entry and the engine untouched
- Pinned: a rejected replacement delivers its rejection while the live stream keeps its events and its wire-key gate
* Drive the house video chrome from keys and rebuild it in every window
- Keyboard activation (Enter/Space) of the transport's play/pause control now drives the video channel exactly like the pointer release: the control advertises Play/Pause to focus and accessibility, so the intent must act instead of being consumed silently — pinned beside the pointer-toggle test
- Runtime-consumed control paths and handler-less video events rebuild through one helper that follows dispatch's discipline: the main canvas against ITS window (a control event from a secondary window used to target that window with the main canvas label and error) and the window slots after it, so a <video controls> declared in a secondary window's tree repaints from the moved mirrors
* Fail a macOS video load whose conversion buffer cannot allocate
- The frame tap's reusable BGRA-to-RGBA conversion buffer failing to allocate used to degrade to a silent zero-frame pump while the load still acknowledged LOADED: playback reported positions forever and could never deliver a pixel
- videoAttachOutputForItem now reports the failure and the status hop answers with the FAILED terminal instead of the acknowledgment - the honest degrade; an audio-only item (no video geometry) keeps its documented honest-absence path
* Journal handler-less video terminals and quarantine stale fed events
- Loop-side video terminals now stage and journal with no Msg handler bound (the image arm's rule): a declarative playback binds no handler, but its synchronous .failed is executor truth — the record is what replays the channel reset, and the staged delivery's wake re-renders the chrome
- A fed event whose staged key no longer names the live playback (replaced before its drain, or a replayed platform .failed already applied the terminal) delivers its staged values verbatim and leaves the live channel alone — applying a replaced stream's terminal would reset the replacement
- A journal-fed video record claiming a millisecond or dimension scalar at or past 2^53 refuses replay as a damaged record at the gate (no recorder writes one, and the TS tier's exact-integer widening would trap on it) — pinned by a hand-patched journal
* Route every video event by the key of the load that produced it
- The bridge's engine key now carries the issuing load's event-arm tag in its low byte (videoKeyForTag): a staged synchronous .failed that delivers AFTER a replacing load re-keyed the single entry still routes the arm of the load it answers, instead of handing the old stream's failure to the replacement's arm
- videoEventMsg routes by the event's own key tag, never the mutable entry's; the wire-key gate on transport verbs is unchanged
- Pinned: a replaced load's straggling terminal routes its own arm while the replacement's stream keeps its own; the request-key pins move to the tagged shape
* Reconcile <video src> from every window's tree and repaint Msg-less failures
- Secondary-window builds now feed the video reconciler: Ui.video promises that declaring the element IS the playback in every window's tree, but slot builds discarded the declaration — the main canvas wins when both declare (one player, one owner), the first declaring slot keeps ownership until it stops declaring or its window closes (reconcile-close included)
- The main build stamps the video mirrors it rendered; drainEffects compares that stamp after a Msg-less drain and re-renders the chrome when they moved — a handler-less declarative playback's synchronous failure no longer leaves controls advertising a playback that is gone
* Paint the poster frame for paused macOS video loads
- A load acknowledged while paused (autoplay = false, the documented poster-frame shape) ran no frame timer and never pumped its first decoded frame: the surface held the placeholder until the user played, paused, or seeked
- LOADED now arms a bounded first-frame hunt: the frame timer polls until the first frame pushes (a paused load then stops the timer; a playing one keeps it), surrendering honestly after ~3s if the output never yields one
* Restart the macOS frame timer when buffered playback actually begins
- The poster hunt could stop the pixel clock while AVPlayer was still waiting to play (a remote autoplay stream yielding its poster mid-buffer, or a hunt surrendering past its bound), and nothing restarted it when the waiting phase ended: audio played and positions ticked with no frames flowing
- The timeControlStatus hop now arms the frame timer whenever playback reports rolling (idempotent beside videoPlay's own arm), for local and remote sources alike
* Deliver a staged video terminal past stop instead of panicking
- A Cmd.batch([videoLoad, videoStop]) on a host whose load fails at once stages the .failed before stop retires the bridge entry; the drain then hit the entry gate's panic even though the terminal is the load call's only answer
- videoEventMsg drops the entry gate: every event reaching it was produced by a bridge-issued load and carries its arm tag in its own key (the engine swallows its post-stop stragglers itself), so routing needs no entry at all - pinned by the load-then-stop batch shape
* Retain every window's video declaration and promote on the owner's close
- The reconciler kept only the owning window's <video src>: closing that window (or its declaration vanishing) stopped playback and left another window's mounted video inactive until an unrelated rebuild
- Slot declarations now retain one entry per window (the table matches the window budget); when the owner closes or stops declaring, the next retained declaration promotes inside the same dispatch - pinned by a two-window close-promotes test with no on_close Msg and no rebuild
* Retry the frame wake for identical pushes still awaiting adoption
- A refused frame-wake request leaves pending clear so a retry can land, but the push-boundary fingerprint short-circuit returned before the wake: a static frame pushed again after a transient request_frame_fn refusal short-circuited forever and the staged bytes were never adopted
- An identical push now falls through to the wake while bytes are still staged; only an adopted frame's repeat stays a pure no-op
* Expose videoPlayback through the bridge alias and the TS feature type
- platformFeatureFromString gains the videoPlayback camel-case alias every other feature carries, so window.zero.platform.supports("videoPlayback") answers the platform truth instead of InvalidPlatformFeature
- NativeSdkPlatformFeature adds video_playback/videoPlayback so TS clients can compile the support query
* Stamp every platform video event with the load that produced it
- The engine mints a token per loadVideo, passes it through the load seam, and every host echoes it in each event: takeVideoMsg swallows an event whose token is not the current load's, so a replaced playback's queued terminal can neither reset the replacement, release its claim, nor route through its handler - pinned by a stale-failed-after-replace regression
- macOS hardening on the same theme: the item end/failure notification blocks re-check note.object against the current item (a removeObserver cannot recall a block already enqueued on the main queue), and the host carries the token across its terminal emits' teardown
- The journaled video platform event carries the token, so replay's mirror steering swallows stale recorded events exactly as live (the re-run loads mint the same deterministic sequence); the null platform echoes tokens like the real hosts and its fake position advance saturates instead of trapping on hostile deltas
* Pair replayed video deliveries with their events; token-gate fed entries; stop abandoned players
- A recorded video Msg dispatched synchronously inside its platform event's dispatch, but replay delivered the fed record at the NEXT drain: an update loading the next clip from its completion handler ran too late and the new clip's .loaded event was swallowed against the old load's token - takeVideoMsg under replay now pops the fed head (the journal's contiguity puts each event's record immediately before it) and dispatches it during the same event, pinned by a chained-load playlist session replaying byte-identical
- Pending video entries carry the load token that staged them, and delivery resolves against the live channel only while it still IS that load: the public key alone cannot tell two loads under one app key apart, and a stale fed terminal resolving against a same-key replacement would have reset it
- failVideoChannel silences the platform player it abandons (best effort): a load that succeeded before a later step refused kept its player decoding while the reset channel forgot it, and the inactive channel skipped it at teardown too
* Keep refused and uninstalled declarations out of the video reconciler
- A declared src the engine's own gates refuse (a malformed URL, say) no longer commits the reconciler's tracked ownership: the running playback keeps its identity - so removing the element later stops IT, instead of hashing the refused source and stranding the playback forever; the refused src is remembered separately and taught once, never re-attempted every rebuild - pinned by a refused-declaration ownership test
- A secondary window's declaration is captured only after its build pass and layout succeed and the tree installs: a build whose layout errors never displays, so its declaration never steers the playback either
* Link CoreVideo wherever appkit_host.m builds standalone
- The frame pump's CVPixelBuffer calls are real CoreVideo symbols: the central build gained the framework, but the generated-app template and the standalone example builds still linked only AVFoundation - a generated macOS project failed to link
- The template and every example build.zig that compiles the AppKit host now link CoreVideo beside AVFoundation
* Gate the poster hunt on a real push and clamp millisecond CMTimes
- videoPumpFrame initialized its push result to the success code, so a NULL base address or failed vImage permutation ended the poster hunt and stopped the frame timer with nothing on the surface; a sentinel now keeps the hunt alive until a push actually returns success
- NativeSdkCMTimeFromMs narrowed the u64 millisecond position straight into the signed CMTimeValue, turning absurd seeks negative; the value now clamps at INT64_MAX so AVFoundation clamps to the duration as documented
* Retire the null video player when a non-looping playback completes
- advanceVideo left the fake player loaded after its completion, so post-completion transport calls succeeded where a live host's torn-down player refuses them (and a replayed play could emit a second completion)
- the completion now unloads before the event returns, matching the live hosts' retire-before-emit order; tests pin the unload and the failed-play degrade path
* Stop the custom playback when an empty source commits
- loadCustom returned early on an empty committed source, leaving the previous video rolling under a status line that said "no source"
- an empty commit now stops the playback and resets the transport mirrors, with a test pinning the stopped channel and the honest status line
* Route replayed video records by the journaled load identity
- Every .video effect record now carries the producing load's token; replay feeds through feedVideoRecord, which resolves the token against the live channel or a retired-load park instead of binding the record to whatever the channel holds at feed time.
- loadVideo and stopVideo park the outgoing load's identity under replay, so a synchronous terminal staged inside the very dispatch that then replaced or stopped its playback still delivers the recorded Msg with the recorded identity - never EffectNotFound, never a reset of a same-key replacement the recording kept playing.
- The replay pairing in takeVideoMsg now token-gates fed entries against the platform event, and two session tests pin the batch shapes: load-then-stop and load-then-replace under one app key.
* Check the Core Video lock result before touching the pixel buffer
- A failed CVPixelBufferLockBaseAddress never maps the buffer, so reading the base address was undefined and the unconditional unlock unbalanced the lock count; the frame now drops with the buffer released.
- The poster hunt stays latched across the dropped frame - only an actual push verdict may end it, the converted-but-unpushed rule.
* Document why rate > 0 is transport intent at the playing derivations
- Per the AVPlayer.h contract, a playback waiting in AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate keeps rate at the requested value ("not currently effective but instead indicates the rate at which playback will start or resume"), so a stalled-but-unpaused stream already reports playing=1 + buffering=1 and the transport control offers Pause.
- AVPlayer resets rate to 0.0 on its own only with waits-to-minimize-stalling disabled - the local-file configuration, where playback would not self-resume and offering Play is the honest affordance.
* Keep the controls-bearing video element zero-intrinsic
- The <video controls> wrap column now adopts the media sizing contract (WidgetLayoutStyle.zero_intrinsic): the transport bar's intrinsic size never leaks into the element, so an unsized element in a hug container measures zero like the bare surface instead of rendering a controls-only strip.
- The wrap clips its content, so chrome cannot paint past a box the layout granted nothing; declared width/height stay definite through the frame and min/max bounds.
- A ui test pins both shapes: zero in a hug container, surface-above-bar inside a declared 320x180 box.
* Bound video scalars at delivery, sweep replay parks, republish flag deltas
- Platform video events clamp position, duration, and dimensions into the exact-integer delivery window (max_effect_video_scalar_exclusive, 2^53) at takeVideoMsg, whatever a host or embedder reports - the engine-side guarantee behind replay's damage gate, so an honest recording can never be refused as damage; a past-window readout now records and replays clamped, pinned by test.
- Replay-side retired video identities release at the first drain-pass boundary after parking (any journaled record for them feeds before that pass's event dispatches), so a long replayed playlist parks and releases one entry per clip instead of accumulating them; pinned at the channel level.
- Same-src declarative loop/muted deltas republish the runtime mirror in the same reconcile, so an automation snapshot taken after the flip reports the new value instead of the one published before the rebuild.
* Journal the video cascade's resolved source for replay
- loadVideo now journals a Msg-less .video_load record (the .clock/.env discipline) carrying which source the recording host's cascade resolved - a missing local file that fell through to the url is filesystem truth the replayed fake load cannot re-probe.
- Replay queues each record and the replayed load consumes it by its deterministic token, so videoSnapshot() and the automation mirror report .stream with the optimistic buffering flag exactly as the recording did, handler-less declarative playbacks included.
- Pinned by a record/replay test on an assets-absent host; the handler-less house-chrome pin now expects exactly the one Msg-less record.
* Keep buffering honest across paused streams and widen the fake's loop wrap
- Buffering means an un-paused stream waiting for bytes: a fresh URL load now starts the flag from the autoplay intent (engine mirror and replayed cascade resolution alike), the macOS host derives it purely from timeControlStatus instead of presetting it, and pauseVideo clears it - a conforming host emits no pause acknowledgment that could.
- The macOS time-control observer stays silent when the transition lands on paused: pause emits nothing by contract and position reports are for playback in motion; waiting and playing transitions still emit.
- The null platform's advanceVideo runs its loop wrap in widened arithmetic so a past-u64 delta lands on the exact residue instead of a saturated one; pinned along with the paused-stream and pause-clears-buffering shapes.
* Spill the replayed cascade-resolution queue past its inline capacity
- Loads per dispatch are unbounded by contract and every .video_load record lands before the dispatch's event, so the replay-side queue now grows geometrically past its inline 64 (freed when it empties and at deinit) instead of refusing the 65th record as a false divergence - the pending stages' non-lossy discipline.
- Pinned by a record/replay burst one past the inline capacity on an assets-absent host.
* Cross-check the journaled key on every replayed video pairing
- Reminted tokens pair records with replayed loads by position; the journaled key now proves the load at that position is the one the recording issued - feedVideoRecord refuses a mismatch as divergence, and a cascade resolution consumed under a different key latches one for the finish check.
- Replay now ends with a consistency check (the .finish replay control): a structurally valid journal whose queued cascade resolutions the replayed timeline never claimed fails as ReplayEffectDivergence instead of reporting success.
- Pinned at the channel level: wrong-key feeds refuse, an unclaimed or misclaimed resolution fails finishReplay, and the honest pairing stays silent.
* Copy the captured video declaration and verify the load bijection under replay
- The main-canvas <video src> capture now copies the source out of the build arena into app-owned storage (the slot captures' rule): a later failed rebuild resets the arena the old capture borrowed, and a window-close reconcile could hash or load overwritten bytes.
- Every non-rejected real load now journals exactly one .video_load record - refused cascades included (failVideoLoad) - so the replayed loads and the journaled records form a bijection: a replayed load with no record at its position, and a record whose position was never issued, both latch divergence for the finish check.
- The journal semantic epoch bumps to 2: identical bytes, changed replay meaning - an earlier recording's failed loads would replay as false divergence, so old journals refuse with the re-record teaching. Pinned: the extra-load shape fails finishReplay and the captured src aliases the app-owned buffer.
* Run the replay finish check even when no records were fed
- A latched video-load divergence can exist with zero fed effect records (the recording journaled nothing; the replayed timeline loaded anyway), so the end-of-journal consistency check now runs unconditionally; hookless apps answer ReplayUnsupported, which is honestly nothing-to-check.
- Pinned: a zero-record recording replayed into a build that declares a video fails as ReplayEffectDivergence instead of reporting success.
* Refuse non-http(s) schemes at the videoLoadUrl seam
- The seam documents streaming-only and promises to reject bad arguments before the platform is asked, but forwarded any scheme - a direct caller could hand file:///... to a host whose media stack opens it (AVPlayer does); the scheme gate now holds at the seam for every caller, matching the engine's own load validation.
- Pinned: file:, ftp:, and unparseable URLs refuse as InvalidVideoOptions without reaching the platform; https passes.
* Retire the null platform's video player before the load probes refuse
- The macOS host stops the previous player before the file-existence probe, so a refused load leaves no playback behind; the null platform now models the same ordering in videoLoad and videoLoadUrl instead of returning early with the replaced player still emitting frames and events under its old token.
- Pinned: a VideoSourceNotFound load leaves nothing loaded, no pending acknowledgment, and no position ticks.
* Fail frame-less video loads and keep the seek mirror on platform truth
- An asset with no video geometry (an audio-only file loaded as video) now fails the load instead of acknowledging a playback that can never paint - sound over a permanently blank surface is the same broken promise as the conversion-buffer failure the attach path already refuses.
- seekVideo applies the platform seek before moving the mirror: a player the host already retired (a completed non-looping playback) refuses the call, and the snapshot and house slider must stay on the frame actually on the glass; pinned in the post-completion transport test.
* Refuse post-completion seeks on the fake video channel too
- A non-looping natural end retires the platform player (retire-before-emit), so a later seek refuses live and the mirror keeps the terminal position; the fake executor now latches the completion and refuses identically, so replayed chrome and snapshots land exactly where the recording left them instead of scrubbing a player that no longer exists.
- Pinned by a record/replay parity test: a scrub after the natural end keeps position at the duration on both sides, model and fingerprints identical.
* Pair replayed video deliveries from any stage position; stop cancels the TS stream
- The replay pairing in takeVideoMsg now scans the pending stage for the event's fed entry instead of checking only the head: a regenerated loop-side rejection staged earlier in the same dispatch keeps its own drain-time order and no longer reverses the recorded Msg order; pinned by a record/replay ordering test.
- VideoMsgFn may return null - the adapter tier's cancel gate - and the TS bridge uses it to honor Cmd.videoStop's wire contract (stop CLOSES the stream, no events for the key after this): a staged synchronous terminal from the very batch that stopped the stream drains through the engine but never reaches the app, and reopening the tag lifts the latch.
- The house videoMsg constructor never returns null, so Zig-native apps keep the one-terminal-per-load delivery whole; the bridge pin now asserts the swallow and the reopen.
* Cancel the key's staged video answers inside the engine at TS stop
- Cmd.videoStop's cancel now runs where the answers live: stopVideoCancel removes every staged-but-undrained entry for the key before the channel goes idle, so nothing for a stopped stream can reach the app - even the synchronous terminal of a load-fail-stop batch, and even when a later load reuses the same event tag before the drain (a bridge-side tag latch could not tell those generations apart).
- A cancelled answer never journals, so replay regenerates and cancels the same entries and the timelines stay identical with no extra machinery; the VideoMsgFn seam returns to plain Msg and the Zig-native stopVideo keeps its one-terminal-per-load delivery.
- The bridge pin now covers the same-tag reopen-before-drain shape.
* Mirror synchronous load refusals under replay; token-scope ownership and cancel
- The .video_load record's video_kind now carries the load's outcome: a refusal reset the live channel before loadVideo returned, so the replayed fake load resets at the same instant (parking its identity for the journaled terminal) and a snapshot an update reads inside the very dispatch matches the recording's; semantic epoch bumps to 3 (same bytes, the field gained meaning). Pinned by a record/replay probe test.
- Declarative video ownership now rides the load token the reconciler captured at its own load, never the derived key alone - the key is a pure function of the source string, and a manual load carrying it must survive declaration removal and flag deltas untouched; pinned.
- stopVideoCancel is token-scoped: the stopped stream is the latest accepted load, so a replaced predecessor sharing the arm's public key keeps its owed terminal - only stop cancels; pinned at the channel level.
* Refuse post-completion play on the fake video channel too
- A non-looping natural end retires the platform player, so play meets an absent player live: one .failed terminal and the channel resets before playVideo returns; the fake executor now models the same refusal (failVideoChannel on plain fake; under replay just the identity park and reset, because the journaled terminal delivers itself), so a snapshot an update reads right after its own play answers the same on every executor.
- Pinned by a record/replay probe test: the mid-dispatch snapshot reads inactive on both sides, the terminal delivers at its recorded wake, and models and fingerprints match.
* Break the video timer retain cycles and gate video-load outcomes at replay
- Both video timers (the 1/60 pixel clock and the 500ms readout) are now weak-host block timers that self-invalidate when the host is gone: a target-selector repeating timer retains its target through the run loop, so a host destroyed mid-playback without an orderly stop could never dealloc - leaving AVPlayer, the timers, and the conversion buffer permanently retained.
- Replay refuses a .video_load record whose video_kind is neither .loaded nor .failed as a damaged journal: the recorder writes exactly those two outcomes, and anything else would steer the replayed fake load into a state the recording never had; pinned by a hand-patched-journal test.
* Commit the video declaration at install; provenance-gate video rejections
- The main-canvas <video src> capture now stages during the build and commits only when the rebuild installs: a build that fails downstream never mounted, and the retained tree on the glass still shows the old declaration - a later reconcile acting on the unmounted capture would stop or replace a playback the presented tree still declares.
- Replay refuses a .video record whose kind and token are recorder-impossible: rejections stamp token 0 (a refused load never minted one) and every delivery carries its minted token, so a .rejected re-stamped onto a delivered record can no longer slip through the regeneration skip and silently omit the recorded Msg; pinned by a hand-patched-journal test.
* Fail the replay finish check on undelivered fed video results
- A fed record is one recorded delivery, and the event that consumed it live follows it in every honest journal - so a fed entry still staged when the journal ends means truncation or hand-editing, and finishReplay now refuses instead of reporting success; regenerated loop-side answers may honestly outlive the last drain on both timelines and stay exempt.
- Pinned: a fed-but-undelivered result fails the finish check and the same result delivered makes it silent again.
* Restart a finished playback from Play instead of failing it
- A non-looping natural end retires the player, so Play answered with one failed event and the seek was guaranteed to spring back - a broken response to a valid finished state. Effects.restartVideo is the resume path: a fresh load of the channel's own remembered source with autoplay, keeping key, surface, handler, and the loop and mute flags, journaling like any load.
- The house transport's toggle restarts when the completion latch is set and its scrub disables (Play is the live affordance); the snapshot and chrome state expose completed so custom players can do the same - the video-player example's Play now restarts its finished clip.
- Pinned: clicking the house toggle after the natural end issues a fresh platform load and the playback runs again.
* Validate replayed video payloads and source shapes; retire the example's dead seeks
- The replay pairing now requires the fed record's payload to equal what the platform event resolves to: the recorder journals every delivery verbatim from that event, so an altered kind or scalar around an intact identity refuses as divergence instead of handing the app a Msg the mirrors contradict; pinned by a hand-patched-journal test.
- A journaled cascade resolution must be one the load's request shape could select (.local needs a path, .stream needs a url); an impossible pairing latches divergence for the finish check, pinned at the channel level.
- The video-player example's seek-family controls (slider, back, forward) disable after a natural completion - a retired player refuses seeks and the thumb would spring back; Play stays live and restarts.
* Document the video output's vended-size contract at the frame tap
- The pixel-buffer width/height attributes are client requirements AVPlayerItemVideoOutput satisfies by scaling every vended frame, not hints: a 3840x2160 H.264 source tapped with fitted 1866x1050 attributes vends 1866x1050 buffers, so a 4K playback never hits the oversized-frame drop - that guard is defense in depth against a hypothetical non-conforming host, and the comment at the attributes site now says so.
* Keep declared ownership across restart; saturate past-window seeks
- The house transport's restart mints a fresh load identity, so the declarative reconciler re-captures its ownership token when the finished playback was its own - removal and same-source flag deltas keep working after a replay-from-end; pinned in the restart test.
- The TS bridge no longer rewinds a finite seek past the exact-integer window to zero: it saturates just below the window, where the engine's duration clamp lands it at the end - an oversized forward seek means the end, and only NaN and negatives (not millisecond offsets at all) seek to 0; pinned at the bridge level.
* Class non-finite seeks as invalid offsets, never oversized forward seeks
- Infinity reached the past-window saturation branch and sought an active video to its end, though the literal validation rejects non-finite offsets: the saturation now requires a FINITE value, so Infinity seeks to 0 like NaN and negatives - not millisecond offsets at all.
- Pinned at the bridge level beside the finite past-window seek.
* Keep the reserved video surface id inside the f64 exact window
- Surface ids ride the TS wire as f64, and a source-less <video> with custom controls is fed by the app's own Cmd.videoLoad naming exactly the reserved id - the old value sat near 2^63, so the bridge's exact-integer validation rejected it and the element could only ever show its placeholder from TypeScript.
- The id moves below 2^53 (mnemonic preserved, bit 63 clear, still a valid producer id reserved by convention); pinned: in the window, outside the derived-texture namespace, and round-trips the f64 wire unchanged.
* Deterministic seek mirror, journaled handler presence, and baked rotation
- The seek mirror now gates on the deterministic completion latch on every executor: a completed playback keeps its terminal position live and replayed alike (the platform is not even asked - the retired player could only refuse), and residual platform verdicts are fire-and-forget so an exotic host's answer can never diverge replay's mirrors from the recording's.
- Every .video record journals whether its delivery dispatched a Msg, and feedVideoRecord requires the replayed handler presence to agree - a record whose Msg silently vanished, or a Msg live never dispatched, refuses as divergence instead of a silent consume.
- Rotated media renders upright: a track with a non-identity preferredTransform gets a properties-of-asset video composition baked into the vended frames (verified against a portrait-flagged H.264 asset: the raw tap vends encoded orientation, the composition vends display orientation); identity-transform assets skip the render pass and track-less streams have no transform to bake.
* Repaint the chrome when the reconcile moves playback; seek on slider key steps
- A src change loading an autoplaying replacement reconciles after the build installs, so the just-installed chrome advertised the OLD transport state while its control acted on the new one - Play on the label, pause in effect. The rebuild now runs one guarded repass when the reconcile moved the mirrors the build rendered; the repass reconciles an unchanged declaration, so the mirrors are a fixed point. Pinned: the replacement's pause glyph shows in the same build.
- Keyboard and assistive steps on the house seek slider arrive as set_value intents with no widget change event behind them; they now map their fraction onto the duration and drive the channel like the pointer scrub instead of being consumed silently for the next tick to snap back. Pinned with a focused arrowright step.
* Repaint secondary-window chrome when its own reconcile moves playback
- The slot rebuild reconciles its captured declaration after the slot tree installs, so a first mount of an autoplaying <video controls> in a secondary window rendered its chrome from the still-inactive snapshot - disabled Play over a playing video - until some later platform event, seconds away on a slow stream. The slot path now runs the main rebuild's guarded repass when the reconcile moved the mirrors the build rendered; the repass reconciles an unchanged declaration, so the mirrors are a fixed point.
- Pinned: the slot's first installed build shows an enabled pause toggle.
* Latch missing video records; scope bridge verbs to their own stream
- A recorded video event arriving under replay with a handler bound and no fed record before it now latches divergence for the finish check: the recorder journals every handled delivery immediately before its event, so absence is truncation or hand-editing - never a Msg to drop silently; pinned at the channel level.
- Bridge video verbs now prove ownership with the load identity their own accepted load minted (Effects.videoMintedToken): a wire key whose stream was since replaced by a load the bridge never issued - a declarative element's - no-ops its transport verbs, and its stop cancels only its own stream's staged answers while the playback on the channel survives untouched; pinned at the bridge level.
* State the replace-is-not-stop rule at the videoLoad contract
- A replaced load still delivers the terminal it owes, routed to its own event arm - the never-silent promise applies to the replaced stream's failure exactly as to any other, and only Cmd.videoStop cancels undelivered answers. The wire contract now says so explicitly where the open-or-replace semantics are defined; the bridge's routing and the arm-separation pin already enforce it.
* Remember volume across a failed load; gate impossible terminal payloads
- Volume is a remembered preference the next load re-applies, so the bridge's ownership gate now lets videoSetVolume through when the channel is IDLE - a failed load's handler routinely sets it before retrying, and with nobody's playback on the channel there is nothing to protect; a foreign live playback still gates it. Pinned both ways.
- Replay refuses a .video record whose payload shape the recorder never writes for its kind: terminals deliver with playing and buffering false and no dimensions, and a completion pins position to the duration - a synchronously failed load's record has no platform event behind it to cross-check, so the gate is where a hand-set width refuses; pinned by a hand-patched-journal test.
|
||
|
|
e67d3bd9c5 |
External-source channels: journaled events from app threads, sockets, and watchers (#165)
* Add the external-source channel effect family to the engine
- fx.openChannel/closeChannel with a generation-stamped thread-safe ChannelHandle.post that stages into a per-channel non-lossy FIFO, wakes the host, and reports back-pressure through per-channel drop counters
- channels share the keyed families' key space (occupied from open until the .closed terminal delivers) and deliver every event through the ordinary drain, journaled at the boundary
- journal format v8: the .channel effect-record kind with inline post bytes; replay feeds recorded events verbatim (no source thread), regenerates admission rejections, and damage-gates impossible records
* Cover the channel family: lifecycle, back-pressure, key space, replay
- direct-channel tests: open/post/deliver/close order, thread posting, duplicate-key and full-table rejects, drop accounting, post-after-close/teardown safety, shared key space with fetch and spawn
- record/replay acceptance: a session recorded with a live posting thread (one honest drop aboard) replays fingerprint-identical offline with no source thread; the duplicate open's rejection regenerates
- damage gates: over-bound post bytes and byte-carrying terminals refuse replay as damaged records
* Give transpiled cores the channel family: Cmd.channelOpen/channelClose
- wire opcodes 0x15/0x16 (additive within cmd_format_version 3) with rt builders, SDK types (ChannelState/ChannelEventArm/ChannelEventKind), and emitter lowering with the image-id literal gates
- ts_core_host routes every event through a five-field by-name arm (key/state/bytes/droppedPending/droppedTotal) on a non-retiring bridge entry; duplicate live keys reject at the post-cycle boundary echoing the key
- posting stays native-side API (Effects.channelHandle) - transpiled cores open, close, and receive; the bridge test drives a real handle post through the drain
* Add the channel-monitor example: a worker-thread source, zero polling
- an app-owned std.Thread samples its own process (uptime, peak RSS) and posts each reading through the channel handle; the UI updates only when events arrive
- Stop closes the channel and the detached worker winds down on the handle's false answer - safe past close and past teardown by the handle's construction
- tests swap the worker for a handle-capturing stub: posted samples land, no fx timer is ever armed, close kills the handle, a refused open reports rejected
* State the channel family and its journal v8 bump in the changelog
- one fragment covering the Zig surface, back-pressure contract, the conscious v8 format break, the TS tier, and the channel-monitor example
* Answer channel posts with a four-way PostResult instead of a bool
- ChannelHandle.post now returns PostResult { accepted, dropped_full, dropped_oversized, closed }: a producer can tell transient back-pressure (skip and continue) from closure (exit the loop) instead of guessing at a false; both drop answers keep the exact drop-counter semantics, and oversized gets its own member because its remedy differs (no retry of the same bytes can ever land)
- channel-monitor's worker now reads the answer honestly: dropped_full skips the sample and keeps sampling (the status line reports the delivered drop counters), closed winds the thread down - a transient 32-entry stall no longer stops monitoring forever while the UI says "monitoring"
- unmerged API, clean break: every post site, the SDK doc comment, the handle-lifetime docs, and the example's tests/README move to the enum
* Park replayed channel opens so a re-run source thread cannot diverge the stream
- under armReplay, openChannel registers the occupancy exactly as live (duplicate opens keep rejecting symmetrically, the shared key space holds) but allocates no staging and returns an INERT handle: every post answers .closed immediately, so app code following the documented open-and-spawn pattern sees its re-spawned worker exit on its first post instead of interleaving live posts with the journal-fed events
- closeChannel tolerates the parked occupancy's missing posting header; the fed .closed terminal retires the parked slot at its recorded position, the same causal instant live delivery frees the key
- new acceptance regression records the open-and-spawn pattern with a real posting thread and replays it offline fingerprint-identical (pre-fix the re-run worker's accepted posts diverged the checkpoints), plus direct coverage that inert-handle posts stage nothing and count no drops and that duplicate opens under replay still reject
* Teach the evals Cmd decoder the channel opcodes
- decode channel_open (0x15: key f64 LE + event_tag u8, exactly the bytes rt.zig's cmdChannelOpen builds - no max_pending rides the wire) and channel_close (0x16: key f64 LE) into new Op arms
- batch test pins both records' lengths so a trailing record still decodes; the unknown-op panic already names the offending byte and offset, so the next opcode gap stays a one-line diagnosis
* Never wake the host for a refused channel post
- A producer continuing through .dropped_full (the documented contract) enqueued one host wake per refusal, growing the main-loop queue without bound while the stage itself stayed bounded.
- The post site now documents the invariant: a wake is issued only when a post makes new work drainable - a full stage's entries already carry their accepted posts' wakes and the drop counters ride the next delivered event, an oversized post stages nothing, and a closed post stays a pure no-op.
- New regression test pins the pending-wake count flat across a 65-refusal storm and pins the accepted path's exactly-one-wake behavior before and after the stage refills.
* Document the channel family across the TS and skill surfaces
- The TypeScript-cores page gains Cmd.channelOpen/channelClose in the command table, a five-field event-arm example with the exact ChannelState union, and the channel stream in the Sub-vs-stream teaching.
- Both shipped skills now teach the family: native-ui gets the fx.openChannel section (verbs, event arm, back-pressure and replay contracts, test surface) and ts-core adds the channel ops to its streaming-ops set.
- A stale OpenChannelOptions comment still said overflow posts return false; it now states the .dropped_full PostResult, and the changelog names the refused-post-never-wakes contract.
* Never hold the channel staging mutex across the host wake
- Split the channel post's host wake out of ChannelShared.mutex into a ChannelWake half behind its own mutex, mirroring the media-surface producer's data/wake split: posts stage under the staging mutex, release, then wake, so drain, close, and teardown never contend with a slow or blocking platform wake hook.
- Arm the binding at openChannel and disarm it under the wake mutex on every close path (closeChannel, terminal retire, teardown) — the abandon fence: after a disarm returns, no post is inside the host call and none can start one.
- Document the lock-order invariant at ChannelShared.mutex (the staging mutex is never held across a host callback; the two locks never nest) and pin it with an injected wake hook that probes the poster's lock state.
* Coalesce channel post wakes behind one latched host wake
- Latch an atomic wake-pending flag in the channel's wake half on the first accepted post and ride it for the rest of the burst, following MediaSurfaceWake exactly: a fill/drain/refill producer now costs one host-queue entry per drain instead of a standing backlog of redundant wakes.
- Clear the latch at the drain pass boundary BEFORE snapshotting the post order (adoptMediaSurfaceFrames' clear-before-sample placement), so a post racing the drain either lands inside the pass's snapshot or observes the cleared latch and wakes afresh — nothing staged is ever left wakeless; the flag is per-channel, riding the generation-fenced binding the lock split introduced, and checked lock-free so a reentrant wake hook coalesces instead of deadlocking.
- Gate the contract in tests: a full-stage burst latches exactly one wake, a post after the pass boundary wakes afresh and delivers next pass, refused posts still never wake, and a wake hook posting back into its own channel completes instead of deadlocking.
* Surface a failed sampler start in the channel-monitor example
- Claim "monitoring" only after the source thread actually starts: the spawn reports failure through the start seam instead of silently returning with a live channel and no producer.
- On failure, close the just-opened channel (the .closed terminal frees the key for a retry) and render "sampler failed to start" in the status line.
- Exercise the failure branch through the injected-source seam the tests already use — std.Thread.spawn cannot be made to fail deterministically — including full recovery on a retry with a healthy source.
* Reserve channel-table capacity for an alloc-failed open until its rejection drains
- Live table admission now counts staged executor-truth channel rejections as occupied capacity, matching the slot replay parks for the same open until the journaled terminal feeds; regenerating refusals (occupied key, full table) deliberately never reserve, the same line the key window draws
- Route channel storage creation through a swappable allocator seam so tests can fail one open's start surgically
- Cover the reservation window, the table-limit record/replay boundary, regenerating no-reserve accounting, and teardown with a reservation pending
* Dispatch bridge-refused rejections in command-stream order across families
- Merge the TS core host's per-family rejection staging (spawn, image, channel) into one kind-tagged stage drained in wire order, so a mixed Cmd.batch's refusals reach update under the performed-in-order contract; a future family joins with one enum member and one switch arm
- Spawn rejections now share the spill discipline: a batch carrying more refused spawns than the effect table holds slots yields one rejection each instead of a panic
- Pin the order at both tiers: bridge tests drive mixed, reversed, and three-family refused batches, and the transpiled e2e fixture records a mixed-rejection session that replays with identical cross-family order
* Preserve mixed-provenance channel rejection order under replay
- A replay-parked open now reserves its pending-order slot at dispatch: the park consumes the pending_seq stamp a live executor-truth refusal would have staged at, and the fed park-retiring .rejected delivers through the pending stage at that stamp (ordered insert, slot retired at delivery) instead of trailing every younger regenerating refusal through the completion queue.
- Accepted opens stamp too - refusal-vs-accepted is only knowable at the first feed - but any non-.rejected feed vacates the stamp unused, so their fed streams keep riding the queue unchanged.
- Cover the mixed sessions end to end: alloc-fail-then-table-full replays in live order, the regenerating-first shape keeps its lead, and a rejection handler that opens a third channel sees the same table state on both sides.
* Publish the channel wake's services binding and sweep staged work at bind
- Posting threads now read the services binding through an atomically published mirror (release store at bind, acquire load under the wake mutex) instead of racing the loop thread's plain-field write; the release/acquire pair is the publication contract that makes the host state fully visible to the first cross-thread wake.
- bindServices sweeps: a post accepted before the binding could neither wake nor latch, so the bind issues one catch-up host wake when anything is staged - without it a one-shot producer that posted early stranded forever.
- Cover both shapes: the pre-bind post delivers off the bind sweep's wake with no further post, and an idle bind wakes nobody.
* Give channel occupancies a channel-owned u64 generation
- Channel handles now stamp from a dedicated monotonic u64 counter instead of the shared wrapping u32 effect counter: the permanent-closed guarantee is absolute, and a u32 wrap after 2^32 occupancies would let a long-lived stale handle match a reused slot and post into another producer's channel (the media-surface producer handle's width, mirrored).
- Fed channel entries carry the u64 generation on their own queue field; the slot families keep the shared u32 counter for their loop-internal gates.
- Pin the width, the counter's independence, and the wrap case itself: a seeded counter reproduces the exact u32 truncation collision and the stale handle still answers closed.
* Add ChannelHandle.live() and state the replay re-run honestly
- live() is the producer-launch check: false for parked replay handles, refused opens, closed or reused occupancies, and torn-down runtimes - advisory only, the post's own answer stays authoritative. The replayed open parks, but the opening update re-executes, so a producer launched unconditionally really starts (connects and blocking setup before its first post included) and is stopped only at that first post; gating the launch on live() keeps replay fully offline.
- The channel-monitor example consults live() before spawning the sampler - the Msg stream and model are identical either way because nothing model-visible branches on it - and its tests pin that a replay-armed start never invokes the source seam.
- Say it honestly everywhere it was claimed: the changelog fragment, the openChannel doc comment, the test-suite header, and both skill surfaces now state that replay never NEEDS the source rather than that no source thread runs, and one journal replays identically under both producer disciplines (acceptance-tested).
* Repair the bind/post wake handshake with a seq_cst store-buffer pairing
- bindServices' services publish, hasPending's mirror loads, the post's pending increment, and requestHostWake's services load all carry seq_cst: release/acquire never orders a store before the same thread's later load of another location, so both sides could read stale values and strand an accepted post with no wake
- Document the total-order argument at each of the four operations: whichever store lands later in the seq_cst order, that side's subsequent load sees the other's store, so the poster wakes or the binder sweeps
- Add a bounded concurrent regression (300 iterations, one post racing one bind) asserting a wake is always observed and the post drains, documented as probabilistic-but-real for this race class
* Deliver bridge refusals through the engine's one seq-ordered rejection stream
- A batch mixing an engine-refused record (cross-family occupied key, staged into the pending FIFOs for the next drain) with a bridge-refused one (host-side stage, dispatched at the cycle boundary) delivered the second rejection before the first, breaking Cmd.batch's performed-in-order contract across layers
- Add Effects.stageLoopMsg: a non-lossy caller-staged Msg stage sharing the pending seq stamp, never journaled and regenerating by contract; the TS bridge now stages its spawn/image/channel refusals there at refusal time and its finishCycle rejection stage retires, so one seq-ordered drain delivers every rejection in command order
- Pin the engine-then-bridge and bridge-then-engine compositions, the three-family mix, the journal provenance (bridge refusals journal nothing, engine refusals journal marked regenerable), and record/replay identity for mixed-authority rejection order
* Run the channel host wake with the wake mutex free
- An embedder wake_fn has no enqueue-only contract: holding wake.mutex through the call deadlocked against drainBoundary (and post's never-blocks contract) whenever a wake synchronously marshaled to the loop thread.
- Posts now mark an in-flight count under the mutex, release it for the host call, and re-acquire to clear; disarm clears the binding under the mutex and waits for in-flight to reach zero, so a returned disarm still means no producer is inside the host call.
- Gate the invariant with a wake hook that takes the drain's own pass boundary (deadlocked before, completes now) and asserts the wake mutex is free during the call.
* Refuse terminal feeds into a live channel occupancy
- feedChannelEvent is the replay/test seam and replay parks its opens with inert handles; feeding a terminal into a LIVE occupancy (open posting header, staged backlog, or armed close marker) raced the producer: the terminal's delivery destroyed the staging FIFO and stranded channel_pending_count, leaving hasPending() true forever.
- Feed answers error.ChannelLiveFeed for that shape instead; live occupancies end through closeChannel, and teardown already reconciles the count to zero when it discards staged entries.
- Tests pin the loud refusal (open and .closing shapes), the reconciled count after teardown-with-staged-entries, and the parked replay feeds staying green.
* State the bridge refusal timing break and align the TS replay claim
- The changelog names the rejection-timing change deliberately: bridge-produced refusals (duplicate-spawn keys, image validation, channel admission) moved from the command cycle's own boundary to the next host drain so every rejection arrives in one seq-ordered stream, and a frame may now render between the cycle and the rejection Msg.
- Cmd.channelOpen's doc drops the last 'no source thread at all' overclaim: replay never NEEDS the source, an unconditional producer is stopped at its first post, and a live()-gated one keeps replay fully offline.
* Reset the channel-monitor drop total on restart
- The start path reset samples and visible rows but not dropped_total, so a fresh run showed the previous run's drops until the first data event.
- The restart test now ends a run with a counted drop and pins a zeroed readout immediately after the fresh start.
* Split channel wake disarm into a non-blocking revoke and a teardown quiesce
- closeChannel and retireChannelSlot now revoke the wake binding without waiting out in-flight host calls: a supported wake hook that synchronously marshals to the loop deadlocked against the old spin-wait when the marshaled dispatch's handler closed the channel
- teardown keeps the blocking quiesce but bounds it (injectable deadline, abandoned-call counter): deinit runs as the loop stops servicing dispatches, so it cannot guarantee an in-flight marshal ever returns, and everything the abandoned call still touches lives in the process-lifetime header
- gate tests: the synchronous-marshal close completes with one .closed terminal, a stale in-flight call outliving close and reopen cannot unlatch the fresh occupancy, and teardown both waits out a slow hook and abandons a stuck one safely
* Release the wake coalescer latch when a takeMsg sweep observes an empty stage
- only drainBoundary cleared the latch, so a caller on the public bare-takeMsg drain stranded the next accepted post: it saw the stale latch and never woke the host
- the sweep clears the latch exactly when a channel's staged queue is observed empty, then re-checks the queue: a post racing the clear either re-latches a fresh wake or its entry is visible to the re-check and delivers — one redundant wake is acceptable, a stranded event is not
- gate tests: a bare-takeMsg drain to empty is followed by a fresh wake on the next accepted post, and an event-driven consumer that only drains on wake movement never strands a concurrent producer's accepted posts
* Condition the channel post never-blocks guarantee on an enqueue-only wake contract
- Document PlatformServices.wake_fn as bounded, non-blocking, and enqueue-only — the shape every first-party host already implements (macOS dispatch_async, GTK g_idle_add, Win32 PostMessageW) and the same contract the media-surface frame request documents — and condition ChannelHandle.post's never-blocks promise on it: the runtime holds no channel lock across the call, so a violating wake hangs only its own posting thread, never the runtime's lock graph.
- Reframe the loop-marshaling wake tests as violator-containment pins rather than supported-usage examples: the pinned behavior (no lock-cycle deadlock through drain, close, or reopen) stays, and the revoke/quiesce rationale now names the marshal shape as out of contract.
- Sweep the never-blocks wording in the TS SDK channel docs, the native-ui skill surface, and the changelog fragment's back-pressure bullet to carry the same conditioning.
* Leak the platform when teardown abandons a stuck channel wake call
- An abandoned in-flight wake_fn call still holds PlatformServices.context past Effects teardown, and the process-lived channel header only protects runtime bookkeeping after the call returns — so the quiesce-abandon path now reports the abandon to the platform through a new note_channel_wake_abandoned_fn services seam, synchronously, while the platform is still alive.
- Every first-party platform latches the report and gates its destruction on it: MacPlatform/LinuxPlatform/WindowsPlatform deinit (the app runner's and the generated runner's one destroy path), the null platform's reference-model deinit, and the mobile ui host's destroy all skip destruction and deliberately leak the host, process-lived, with one loud log — the abandoned-worker idiom, applied to the platform itself. A conforming enqueue-only wake never meets the teardown deadline, so the gate is violator containment only.
- Tests: the quiesce-abandon test now binds a real null platform behind the stuck hook and asserts the destroy path is suppressed (latch set, destruction skipped), and a new non-regression twin pins the healthy half — a conforming wake quiesces fast, nothing is abandoned, and the platform destroys normally.
* Make an abandoned channel wake call's whole dereference chain process-lived
- bindServices publishes an immutable process-allocator snapshot of the services table, so a poster suspended before its services.wake() dereference never reads Runtime-owned memory; a rebind swaps in a fresh snapshot and never writes the old one
- runners heap-allocate the desktop platform wrappers (createWithOptions/destroy) and gate the free on the abandon latch, extending the deinit gate to the wrapper storage the wake context actually points at
- new coverage: a stuck wake whose first context dereference happens only after teardown completes and the owning scopes die (poisoned), plus rebind-generation snapshot coverage
* Resolve a replay park's pending-order stamp exactly once
- A .rejected record targeting a park already vacated no longer reuses the stamp and appends a duplicate terminal to the one-entry-per-open pending ring; it refuses as journal damage (error.ReplayDamagedRecord) with a teaching that names the key and the one-terminal-per-open rule.
- Pins the refusal and the single delivered terminal in effects_channel_tests.
* Gate channel record provenance against recorder truth at replay
- A .data or .closed channel record stamped with .rejected provenance no longer slips into the regeneration skip and silently drops from the Msg stream; the damage gate refuses it, naming both fields.
- Channel records can only carry .exited or .rejected exit reasons (the two journal sites), so any other decoded value refuses as damage too; .rejected kind with .exited provenance stays the executor-truth feed path.
- Pins both mismatch directions, the out-of-range stamp, and the executor-truth positive path in effects_channel_tests.
* Materialize the services snapshot lazily and free it on clean teardown
- bindServices records the loop-thread services pointer only; the process-lived snapshot allocates at whichever comes last of the bind and the first live openChannel, so apps that never open a channel never allocate one, and the seq_cst publish-then-sweep Dekker pair holds at both sites.
- A clean teardown (every wake header quiesced, zero abandons this deinit) frees the snapshot through the channel-storage seam; it stays deliberately process-lived only past an abandon, and the ownership rule is documented at the new wake_snapshot field.
- Re-points the abandon and rebind coverage (an abandoned generation's snapshot stays intact for stale calls; a rebind's fresh snapshot never writes the old one) and adds laziness and clean-free pins in effects_channel_tests.
* Refuse any channel record fed past the open's terminal at replay
- A fed terminal now marks the park .terminated until its delivery retires the slot, so a damaged journal's post-terminal .data/.closed/.rejected records refuse as ReplayDamagedRecord instead of enqueueing events the retire would silently discard from an unverified replay's stream.
- A .rejected fed after the stream proved the open accepted live stays damage and leaves the park intact, so the real terminal still lands.
- Pins data-after-rejection, data-after-closed, and rejection-after-data in effects_channel_tests.
* Refuse an open that cannot publish the services snapshot
- openChannel pre-flights the snapshot before committing the occupancy and rejects as executor truth on allocation failure, so no live channel can exist whose accepted posts strand with producer wakes disarmed.
- The one remaining live-through-failure shape, a channel opened before bindServices whose bind-time publication failed, heals at every drain pass boundary: the retried publication's Dekker sweep un-strands posts accepted in the disarmed window.
- Pins the refusing open (with recovery on the next open) and the boundary heal in effects_channel_tests.
* Resolve a fed park only after its event actually enqueues
- A .closed refused by completion-queue back-pressure no longer marks the park .terminated first, so the replay pump's drain-and-feed-again retry of a valid journal lands instead of refusing as damage.
- The .data vacate moves behind the same commit point; the .rejected path stages through the pending ring, which never back-pressures, and keeps its transition.
- Pins the back-pressured terminal's clean retry in effects_channel_tests.
* Close open channels when the bind cannot publish the services snapshot
- A failed bind-site publication no longer leaves pre-bind channels accepting posts that may never deliver (the pass-boundary retry needed a loop event that idle apps never produce); the bind closes them, flushing backlogs and delivering .closed terminals through its own loop-side wake.
- Together with the open-site refusal this pins one invariant: no live channel ever runs with producer wakes disarmed while services are bound - so the now-unreachable drainBoundary heal is removed.
- Re-points the bind-failure test: pre-bind backlog flushes ahead of the terminal, the producer's next post answers .closed, and a later open republishes and wakes normally.
|
||
|
|
4be334fd40 |
Dynamic images: load at runtime, journal the bytes, replay offline (#146)
* Add fx.loadImage: the runtime image-load effect with the audio source cascade
- New .image effect kind in effects.zig: local-path-then-url resolution with a content-addressed url cache (imageCachePath mirrors audioCachePath under images/), decode + registration through the registered-image seam at drain time, and one terminal Msg carrying loaded dimensions or the same error classes the runtime API raises
- Journal format v6: the .image effect-result record kind with outcome/dimensions and the blob-store content-address fields; replay feeds recorded terminals verbatim and re-registers journaled bytes best-effort
- Fake-executor seams (pendingImageLoad*, feedImageBytes, feedImageResult) plus effects_image_tests.zig covering the request shape, the full decode-register path, every failure class, cancel, and the real executor's local/network/cache-hit cascade against a loopback fixture
* Journal image results through a content-addressed session blob store
- session_blobs.zig: blobs/ beside the journal, one file per distinct payload named by SHA-256 prefix (the audio-cache hashing convention) — dedup by construction, verified on read; DirBlobStore for the app runner, MemoryBlobStore for tests
- The recorder moves an image record's encoded source bytes into the blob store at effect-result time and journals hash + length; replay resolves the blob, verifies address and length, and feeds the recorded bytes — byte-identical, offline, refusing loudly (ReplayMissingBlob) when the store is absent or damaged
- Session tests: record-replay equality with fingerprint checkpoints over two loads sharing one blob, a journaled decode failure, and a regenerating rejection; plus the no-blob-store recording refusal and the journal codec round-trip
* Markup <image>: the runtime-image leaf with a dynamic ImageId binding
- New element code 67 (image, widget_kind image, pictorial a11y class) reusing attr 38's binding grammar broadened to avatar+image; the binding is required on the leaf (an unbound image is dead markup, the icon-without-name policy)
- Both engines mirror the avatar guard: binding-only, integer-only, and negative model values fail the build with the teaching message instead of trapping in the u64 cast; teaching messages renamed to the shared image_binding_* vocabulary
- Pin test updated for the new element fingerprint (print-pins: elements 67, element_names 55); validator/interpreter/compiled/contract tests cover resolve, the 0 sentinel, every misuse, and hand-view parity; LSP docs, SKILL.md tables, and the docs vocab JSON carry the new entry
* Cmd.imageLoad: the TS tier's runtime image vocabulary end to end
- SDK: ImageState/ImageEventArm/ImageEventKind, ImageSource/ImageRoute, the image_load Cmd member and factory keyed by the app's numeric ImageId (any number expression — ids are model data)
- Transpiler: emitImageLoadCmd with the audio source-object grammar, the four-field arm shape check (state matched by member name, width/height/status numbers), and compile-time id/bounds gates; rt.zig gains opcode 0x10 additive within cmd_format_version 2 (the 0x0D-0x0F precedent) and the cmdImageLoad builder
- Host bridge: an id-keyed image table routing the ONE terminal by field name, the spawn discipline for duplicate live ids (bridge-side "rejected" at the post-cycle boundary), and image_cache_dir wiring deriving the content-addressed cache path from the URL like audio; covered by wire-layout, conformance, run-fidelity, and host e2e suites plus the SKILL.md vocabulary
* Document dynamic images: the load-show-replay story
- New docs page (/dynamic-images) covering Cmd.imageLoad TS-first with the Zig toggle, the source cascade and content-addressed cache, the honest result vocabulary and fixed limits, and the byte-identical offline replay guarantee via the session blob store
- Native UI's Images section now leads with the first-class load path and shows the <image> markup leaf; the TypeScript Cmd table gains the imageLoad row
- Changelog fragment states the journal v6 format break plainly (v5 journals refused at the preamble; re-record)
* Regenerate the docs component-preview wasm module
- The live-preview host embeds the runtime and markup engines, which now carry the image effect kind and the <image> element (schema code 67)
* Keep the dynamic-images doc sample inside the transpiled subset
- The core declares the ImageState alias itself (the named-alias rule the arm check enforces) and tracks the last state as a plain enum field
* Export the image effect surface through both roots
- src/root.zig and src/runtime/root.zig now carry EffectImageResult, EffectImageOutcome, imageCachePath, max_effect_image_path_bytes, max_effect_image_bytes, and effect_image_blob_hash_len beside the audio exports they mirror, so an external Zig app can write an Effects.imageMsg Msg arm and reason about the image bounds without module-internal paths.
- A compile-shaped export pin in effects_image_tests.zig (the media-surface pin's convention) holds both roots to the effects module's types, builds the Msg arm through the public constructor, and derives a cache path through the public root.
* Serve session replay decode through the host platform's image codec
- runSessionReplay built its null platform with the image decoder OFF, so production replay dropped every journaled load's pixels (and replayed screenshots lost images) while the branch's tests enabled the decoder by hand; the runner now installs the recording host's own context-free codec (CGImageSource / gdk-pixbuf / WIC) into the headless services via platform.installHeadlessImageCodec, decoding JOURNALED blob-store bytes only — the network stays absent.
- A codec-less null-platform build falls back to the null platform's strict test-PNG decoder, stated honestly in the installer's doc; the comptime platform dispatch keeps the desktop arms out of test-tier analysis so the unit suite links without host shims.
- The offline blob-store replay test now installs its decode seam through the SAME call the runner makes instead of flipping image_decode manually (the exact gap that let this slip), and a file-contains step pins the runner wiring plus all three per-platform installers.
* Reject the seventeenth in-flight image load instead of panicking
- A gallery screen (or one Cmd.batch) with 16 loads already in flight reached the bridge table's freeImageIndex panic, contradicting the documented exactly-one-result contract; a full table now routes pushImageReject like the sibling refusals — the engine's own slot-exhaustion vocabulary, and consistent with audio, whose single channel replaces in place and never crashes.
- The e2e suite fills all 16 entries with dynamic model-owned ids, proves the 17th dispatch answers state "rejected" through the event arm, and feeds one of the 16 live loads to its loaded terminal to show the refusal touched nothing else.
* Make the ImageId bound exclusive at 2^53 in both tiers
- The contract is a positive integer BELOW 2^53, but the emitter allowed literals <= 2 ** 53 and the host allowed dynamic ids <= 9007199254740992.0; 2^53 is the first f64 that aliases a neighbor (2^53 + 1), so both tiers now reject it — the emitter via Number.isSafeInteger with the NS1030 message naming the bound, the host via a strict < with the aliasing rationale in place.
- Boundary tests on both sides: conformance pins 2^53 - 1 building and 2^53 stopping at compile time (NS1030), and the e2e suite drives model-owned dynamic ids through the bridge — 2^53 - 1 parks a live load with the id intact, 2^53 answers state "rejected" without touching it.
* Echo the requested id through the image result arm
- The image event arm is now five fields matched by name (id/state/width/height/status): the engine's EffectImageResult always carried the id, but the arm dropped it, leaving two concurrent loads sharing one arm uncorrelatable in update.
- Bridge rejections echo the refused id too (0 for an id the wire cannot carry exactly — there is no honest integer to echo for one), staged as tag+id pairs at the same post-cycle boundary.
- Every tier that pins the arm shape learns the fifth field: SDK types, emitter conformance, the host's comptime shape check, the wire docs, the e2e fixtures (including a new concurrent-loads test distinguishing completions by the echoed id), and the docs page whose sample now adopts cover: msg.id.
* Stage image-load rejections without a user-reachable cap
- The reject staging buffer was fixed at the 16-entry table size with a panic past it, but the count is the app's to choose: a Cmd.batch of 17 loads against a full table crashed on the seventeenth instead of yielding 17 rejected results.
- Staging still exists because the walk cannot dispatch mid-command (the wire bytes are frame-arena resident and a nested cycle's frame reset would free them), so the stage now spills lazily: the table-sized inline buffer covers everyday cycles allocation-free, and the first overflow takes one engine-allocator block sized by the wire's own arithmetic bound (an image_load record is at least 30 bytes, so cmd.len / 30 caps the count without a second record parser to keep in sync).
- Delivery is unchanged — every reject still dispatches after the issuing cycle's frame reset, nows first, spawn rejects next, image rejects last, in record order — and the new e2e drives a 17-load batch against a full table to 17 boundary rejections with the 16 live loads untouched.
* Cmd.imageCancel: the TS tier's numeric-id image cancel
- The documented "cancelled" outcome was unreachable from TS: Cmd.cancel is string-keyed and never inspects the numeric image table, so a stale load pinned its id against same-id retries until it terminated. Cmd.imageCancel(id) routes to the fx.cancel(id) path the engine already covers — loud like spawn's cancel (the load's own event arm delivers state "cancelled" and the entry retires, freeing the id), a no-op for an id with no live load, mirroring how audioStop is audio's close rather than an overload of the silent string-keyed cancel.
- Wire record image_cancel [op 0x11][id f64 LE], additive within cmd_format_version 2; full tier coverage — SDK factory, emitter with imageLoad's NS1030 id-literal gates, conformance, wire-layout pin, host decode, and an e2e proving cancelled delivery plus a same-id retry parking a fresh load.
- The 0x11 opcode overlap with feat/menu-bar-lifecycle is known cross-branch and handled by the merge-order reconcile.
* Verify blob-store dedup hits instead of trusting the name
- Recording's dedup probe treated ANY existing hash-named file as the bytes, so a damaged blob let future recordings seal successfully while replay refused them. The probe now reads the existing file back chunk-wise against the incoming bytes (equality against the caller's bytes IS the hash check — their address is the file's name, and no blob-sized buffer is staged) and skips the write only on an exact match.
- A mismatch — damage, truncation, an unreadable file — is a repairable state while the true bytes are in hand: the write falls through to the same atomic partial+rename a fresh install uses, with a debug-scoped log line, never a hard failure. Cost: a dedup hit is one bounded read (<= the 1.25 MiB blob budget) instead of one existence probe.
- MemoryBlobStore had the same blind-dedup shape and repairs in place identically; tests pin corrupt-then-rewrite repair on both stores (dir store covering flipped bytes and truncation through the atomic path) alongside the existing one-blob-for-same-bytes dedup pins.
* Feed worker-origin image rejections under session replay
- A rejected image terminal journals from both sides of the executor seam, but replay skipped every one as regenerable loop-side validation - a worker-refused load (a host-less URL that passes the loop's scheme check, an executor that could not start) parked forever in the fake executor and diverged the replay.
- Loop-side validation refusals now mark themselves with the record's exit reason (the .host records' convention) and keep regenerating; unmarked rejections are executor truth and feed like every other worker terminal.
- Covered by a record/replay test that drives a host-less URL through the real executor's worker and asserts the rejected Msg delivers fingerprint-identical offline.
* Give each image cache install its own temp file
- Every install wrote <cache_path>.partial, so two concurrent loads of one URL truncated each other's temp and a rename could publish a half-written file into the cache name; the temp now carries the slot generation (operation-unique), keeping the rename atomic with last-whole-file-wins.
- A failed install deletes its own temp, so the cache directory never accumulates this process's debris; only a hard crash mid-install can leave one, in the OS-purgeable caches directory.
- Pinned by a temp-name uniqueness test on the extracted imageCachePartialPath seam plus a two-loads-one-url executor test asserting both terminals, an intact cache entry, and no .partial debris.
* Stage loop-side image terminals outside the lossy pending ring
- The shared pending ring evicts its oldest entry on overflow and folds the loss into a drop counter, but EffectImageResult carries none and loop-side validation rejections are unbounded per dispatch: 33 refused loadImage calls before a drain silently evicted one terminal, breaking the exactly-one-terminal-per-load contract and stranding the issuing model forever.
- Image terminals now stage in a dedicated non-lossy FIFO: the ring-sized inline buffer covers everyday bursts allocation-free, and a burst past it grows a heap ring geometrically (each staged entry answers exactly one loadImage call, so storage is bounded by the caller's own call count between drains; the spill frees when the stage drains empty). A shared enqueue stamp merges the stage with the ring at drain, so delivery order across both structures is unchanged.
- The new burst test drives 33 sourceless loads plus one rejected spawn through a single dispatch to 34 terminals in enqueue order, exactly one per load - restoring ring routing for image terminals fails it at 32.
* Make image cache install temps writer-unique, not generation-unique
- imageCachePartialPath named temps `<cache_path>.<generation>.partial`, but the slot generation is channel-local: two Effects channels in one process, or two app processes sharing the platform cache directory, can install toward one cache path at the same generation and recreate the truncate/rename race the temp exists to prevent.
- Each install now appends a random 64-bit token drawn from the operation's own executor io (the CSPRNG seam every worker already carries), unique across channels and processes; the generation stays in the name as debris provenance, and the formatter stays pure on every compile target - the wasm32-freestanding docs preview never runs an install (no executor io exists there), verified by a green docs-wasm-preview build.
- The temp-name test now pins the cross-channel case directly: colliding generations with distinct tokens produce distinct names, so reverting the token fails it; the failed-write/rename self-cleanup path is unchanged and the concurrent-load no-debris assertions still hold.
* Refuse fractional expectedBytes at both tiers instead of truncating
- The emitter accepted any finite nonnegative expectedBytes literal, and the host's representability check lacked the whole-number clause its id sibling has: a fractional 1.5 truncated through @intFromFloat to 1, so cache installs verified every download against a size the app never declared and re-fetched on every launch.
- The emitter literal gate is now the id gate's Number.isSafeInteger (>= 0), teaching "not a whole-number byte count below 2^53" in the NS1030 voice; the host adds the floor clause, so fractional and unrepresentable DYNAMIC counts map to 0 = "unknown size" - the honest degrade the comment already promised (verification skipped, never wrong).
- Conformance pins the literal pair (1.5 stops the build, 4096 builds) and the e2e drives model-owned 1.5 and 4096 through the wire to expected_bytes 0 and 4096 with both loads completing; reverting either tier's gate fails its test.
* Propagate a cancel that lands inside the image cache probe
- readImageCache swallowed every error into a cache miss, including error.Canceled; Zig Io cancel delivery is one-shot, so a cancel interrupting the probe was consumed and the load fell through into the network fetch with nothing left to interrupt it - explicit cancel, the timeout, and teardown all degraded to waiting out the fetch, and a timed-out load could deliver late as .loaded.
- The probe now returns error{Canceled}!bool: Canceled propagates and the cascade terminates .cancelled like its sibling probe arms, while every other failure stays an honest miss that refreshes from the network.
- Covered at both tiers: a unit test drives the probe with an Io whose open/read return Canceled (and pins the honest-miss half against std.Io.failing), and an e2e parks a real load inside the probe on a reader-less FIFO, cancels it, and asserts one .cancelled terminal with zero requests reaching the loopback fixture.
* Cmd.imageUnregister: the TS tier's registry release
- A TS gallery had no recourse past the 16-slot registry: Zig cores call fx.unregisterImage(id), but Cmd.imageCancel only ends in-flight loads, so every successfully loaded distinct id occupied its slot permanently and the 17th image answered registry_full forever. Cmd.imageUnregister(id) routes to fx.unregisterImage — the image-family verb naming (imageLoad/imageCancel precedent), synchronous registry surgery like registration itself: no result Msg (releasing a slot cannot fail the way I/O and decode can), a miss is the silent idle no-op imageCancel and audio_ctl keep (the engine's own bool answer treats a miss as an answer, not an error, and the wire has no channel to carry it). It frees only the CURRENT registration: a load in flight is untouched and its terminal still registers the pixels — pinned by e2e as the engine behaves today — so eviction under a live load is imageCancel first, then unregister.
- Wire record image_unregister [op 0x12][id f64 LE], additive within cmd_format_version 2; full tier coverage — SDK factory, emitter with the imageLoad/imageCancel NS1030 id-literal gate, conformance, wire-layout pin, host decode, and e2e proving the gallery eviction verbatim: sixteen loaded images, the next id refused registry_full, one unregister, and the freed slot accepting a 17th distinct image.
- The 0x10-0x12 image block's opcode overlap with feat/menu-bar-lifecycle's 0x10-0x11 window verbs is known cross-branch and handled by the merge-order reconcile; this commit extends this branch's contiguous image block by one.
* Survive two recorders writing the same session blob into one store
- DirBlobStore.write staged every blob through <hash>.partial — one shared file whenever two recorders write the same blob into one blobs/ directory (the designed shared layout), so the writers truncated each other mid-write and the loser's rename failed the whole write, refusing a correct recording. Each write now stages through a writer-unique temp, <hash>.<token>.partial with the token drawn from the store's io CSPRNG (the same entropy seam the effect executor's cache installs use) — unique across stores and processes, with the hash kept in the name as debris provenance.
- A rename that still fails gets the content-addressed grace: re-running the verifying probe against the addressed name decides the outcome — an exact byte match means a concurrent writer won the install with THIS content, so the write succeeded and returns cleanly; a verified mismatch or absence stays BlobIoFailed. The loser's temp is deleted on every failure path. MemoryBlobStore is untouched: its medium is its own entries array, never a shared directory, so the race has no analogue there (documented on the type).
- Tests pin both halves where each is deterministic: the two-writer window cannot be paused mid-call through write(), so the loser's exact rename-failure state is staged at the installPartial seam (reverting the grace fails it with BlobIoFailed), the writer-unique temp name is pinned at the blobPartialPath formatter (a name without the token collides), two DirBlobStore instances over one directory both succeed with one intact blob and zero temp debris, and the existing corruption-repair and dedup coverage still holds.
* Back-pressure replay image feeds instead of dropping bytes and order
- A fed image terminal that found the completion queue full took the pending-ring fallback: releaseFetchSlot freed the journaled bytes and the loop-side delivery jumped ahead of every queued result, so a recording whose drain pass carried more than 64 results replayed with the image pixels gone and the terminal reordered around the recorded lines.
- Under replay the feed now reports EffectQueueFull with the request still parked and its bytes intact, and the replay pump drains through the same .wake dispatch the platform delivers live before feeding once more - everything rides the queue, mirroring the real worker's blocking postImage retry; the byte-free fallback (with its derived .loaded-to-.rejected honesty rewrite) remains for non-replay test feeds, the only other traffic that can reach it.
- The saturation regression test records 64 line results plus a loaded image terminal as one unbroken journal run and asserts replay delivers them in recorded order, re-registers the recorded pixels, and matches every fingerprint checkpoint.
* Retire the image slot before its terminal reaches update
- imageWorkerMain posts the terminal entry first and stores .draining on the next line, so a drain racing ahead of that store could dispatch the result while the slot still read .running - a reload of the same id from inside the handling update (the gallery-refresh idiom) then rejected as a duplicate active key
- the drain's .image arm now stores .draining right after taking the buffer, so the slot is reclaimable by the time any update code reacts to the terminal; the worker-side store stays as the pre-drain transition (cancel targeting and early thread reclaim key off it)
- the new test reconstructs the preemption window (terminal queued, slot rewound to .running - the exact state a preempted worker leaves) and proves the in-handler reload parks and completes instead of rejecting
* Reject children on the image leaf instead of dropping them
- <image ...><text>Caption</text></image> validated through and both engines built the children, but widget layout gives an image no child slots - the caption silently vanished
- all three surfaces now refuse with icon's leaf policy exactly: the validator (native check), the interpreter's build failure, and the compiled engine's compile error, sharing one image_children_message ("image is a leaf - it takes no children")
- negative cases land in the validator table and the interpreter's image-misuse suite with the message asserted symbolically; the compiled engine's rejection is a comptime fail, covered by the documented compile-error strategy (the interpreter enumerates what it rejects)
* Mirror the image leaf's required binding and raw-child checks into both engines
- the validator refused <image /> without its image={binding} (dead markup), but unvalidated or hot-reloaded markup reaching the interpreter or the compiled engine still built the leaf with the default id and silently rendered nothing - both engines now refuse with the validator's image_missing_image_message (icon's missing-name precedent)
- the engines checked image children AFTER context-menu extraction while the validator checks the raw node, so <image on-press="..."><context-menu/></image> built in both engines yet failed validation - the engine checks now read the ORIGINAL node's children, so all three surfaces agree the leaf takes no children at all
- negative cases land in the interpreter's image-misuse suite and the validator table with messages asserted symbolically; the compiled engine's rejections are comptime fails, covered by the documented compile-error strategy (the interpreter enumerates what it rejects)
* Count undelivered draining image slots as occupied in loadImage
- A reload landing between an image worker's terminal post (slot already .draining) and the drain was accepted live but rejected under replay, where the first request is still a parked .running fake until its recorded terminal feeds - the second terminal then had no pending request to match.
- Delivery ends the occupied window: the drain takes the slot's buffer before the terminal Msg reaches update, so a non-null fetch_buffer under .draining is exactly "undelivered" and the reload-from-own-terminal handler idiom still parks as a fresh load.
- Tests: an unrelated dispatch reloading inside the window rejects (and parks again after delivery), plus a record-to-replay session proving both sides reject identically.
* Make the dynamic expectedBytes bound exclusive at 2^53
- The bridge accepted a dynamic expectedBytes of 2^53 while the emitter's literal gate (Number.isSafeInteger) rejects it: 2^53 + 1 aliases 2^53 on the f64 wire, so no one honest count exists - an installed 2^53 becomes a verification size every real download misses, silently re-fetching on launch.
- 2^53 now maps to "unknown size" (0) with the fractionals; 2^53 - 1, the last exactly-carried count, still installs verbatim. The image id checks were already exclusive.
- Boundary e2e: model-owned 2^53 - 1 parks with the count intact, 2^53 (and its 2^53 + 1 alias) parks with expected_bytes 0.
* Teach the eval harness cmdview decoder the image records
- image_load 0x12, image_cancel 0x13, image_unregister 0x14 decode in the harness-lib Cmd iterator, matching ts_core_host.zig's decoder as renumbered past main's window verbs
- Pinned-bytes tests cover the load record's full layout and the two one-field records advancing exactly their own length in a batch
* Regenerate the docs component-preview wasm module after the reconcile rebase
- The wasm build now carries main's window-verb wire records alongside the renumbered image records, so the checked-in artifact drifts and is refreshed per the branch convention
* Refuse replay of .loaded image records that claim a zero-length blob
- A journaled .loaded image record always carries source bytes (the drain journals .loaded only after those exact bytes decoded and registered, and empty bytes cannot decode), so a zero-length blob is journal damage; replay now refuses it with the new ReplayDamagedRecord class instead of skipping blob resolution and delivering a pixel-less loaded.
- State the record-time invariant at the drain's journal site and the replay gate, and add a session test that hand-zeros a recorded journal's image_blob_len (blob store present and intact) and asserts the exact refusal.
* Document image status 0 as the no-exchange signal for cache hits and local paths
- A cache hit delivers .loaded with status 0 on purpose: status is the HTTP status only for url loads that performed an exchange, and fabricating the origin's 200 for a non-network event would claim an exchange that never happened - 0 lets apps tell a cached load from a network one.
- Say so everywhere the field is described: EffectImageResult.status and the journal record in effects.zig, the cache-hit site itself, core.ts's ImageEventArm, rt.zig's wire prose, the ts-core SKILL, and the dynamic-images docs page.
- Extend the offline-cache-hit test: the network load reports the loopback fixture's real 200, the offline re-load of the same id/url reports .loaded with status 0.
* Bump the session-journal format to v7 for the image record layout
- main already ships v6 (the hidden window-frame flag), and this branch appends the .image effect-result kind plus blob-address fields to every effect record - a layout change, so the version gate moves to v7 and a main-written v6 journal refuses as version skew at the preamble instead of misreading as corruption
- the doc-comment history splits v6 (hidden state) from v7 (image records); the changelog fragment and the dynamic-images docs page state the v7 break the same way
- the version-skew test now pins a concrete v6-preamble journal to JournalUnsupportedVersion and its re-record teaching, before any record layout is consulted
* Hold same-pass effect completions to the next wake so journal event boundaries stay causal
- one drain pass now consumes only completions that existed when it began (Effects.DrainBoundary; UiApp.drainEffects and the ts-core host drain use the bounded takeMsgWithin): a load an update handler starts mid-pass that finishes before the pass ends delivers - and journals - under the NEXT wake's event record, so replay's file-order feed always finds the parked request instead of turning the chained fast load into a false ReplayEffectDivergence
- the journal header now states the causal premise its feed-then-dispatch ordering invariant rests on; this is the recorder-side guarantee, chosen over a replay-side retry because the documented contract promises file order IS a valid feed order for every effect family, not just images
- the fake executor gains the instant-load convention (fake_instant_image_bytes) that reproduces the race deterministically, and the chained same-wake session test records, replays fingerprint-identical, and pins the effect/wake interleaving in the journal
* Report registry allocation failure as alloc_failed, never decode_failed
- EffectImageOutcome gains alloc_failed and classifyImageRegisterError maps error.OutOfMemory (the registry slot's lazy pixel-buffer allocation) to it: resource exhaustion at registration is its own honest class, named for the failing stage like io_failed/decode_failed - valid bytes are never reported corrupt
- the fifteen-member contract ripples everywhere it lives: the SDK ImageState alias and ImageEventArm docs, the emitter's IMAGE_STATES build gate, rt.zig's wire docs, the conformance/effects/runfidelity and e2e fixtures, ts-core SKILL.md, the docs outcome table, and the changelog fragment
- a FailingAllocator at the registry seam delivers alloc_failed and the SAME bytes load once memory heals; the fed-classes coverage carries the new member and the TS e2e sees the fifteenth state by name through the host's name-matched mapping
* Make undelivered-terminal occupancy key-wide across effect families
- The effect families share one key space, but only loadImage counted the posted-but-undelivered .draining window as occupied, and only for image slots: a key whose fetch/spawn/file/clipboard terminal was queued but not yet drained was accepted live by any other family (and by its own), while under session replay the same request is still a parked .running fake until its journaled terminal feeds - the accepted duplicate then diverges the Msg stream.
- keyOccupiedUntilDelivery gates every keyed admission (spawn, fetch, file read/write, clipboard, loadImage, and hostRequest's cross-kind collision arm) on findUndeliveredTerminalSlot, whose per-family "terminal still pending" marker is derived from how each drain retires the slot: the fetch_buffer handoff (fetch/file/clipboard/host/image), the collect_buffer handoff (collect spawns), and a new exit_undelivered flag for .lines spawns, whose slots previously retired to idle before their exit drained; the drain clears the flag before the exit Msg reaches update, so the respawn-from-own-exit idiom still parks fresh.
- Tests: live duplicate admissions inside the window reject and delivery frees the key for fetch->fetch, spawn->spawn (plus the respawn idiom), fetch->loadImage, and image->spawn; two record-to-replay sessions prove the cross-family rejections regenerate identically on both sides.
* Hold an image id through its staged start-failure rejection until it drains
- loadImage's executor-start failures (the staged source buffer's allocation, ensureIo, Thread.spawn) stage NON-regenerating rejections - executor truth that journals and FEEDS under session replay, where the same request allocates its own buffer and stays parked until the recorded delivery position. Live, the id was free the moment the rejection was staged, so a reload inside that window was accepted live and rejected under replay.
- Admission (keyOccupiedUntilDelivery, plus hostRequest's collision arm) now also consults the staged pending-image FIFO for non-regenerating entries: those hold their id until the drain delivers them, which covers both start-failure paths and the fake cancel's staged terminal with one rule - occupancy mirrors exactly the records replay feeds. Regenerating validation refusals deliberately do NOT occupy: replay re-runs the same loop-side checks at the same dispatch, so both sides refuse identically with the key never held.
- Tests: a surgical one-shot allocator fails exactly the staged source buffer, then a same-id reload inside the window rejects and delivery frees the id (live), and a record-to-replay session proves the start-failure terminal feeds while the in-window reload's refusal regenerates - model and fingerprint identical on both sides.
* Feed replayed image terminals verbatim past a stale cancel mark
- A fed image terminal is executor truth: check entry.image_fed before the cancelled rewrite so replay's differently-timed fake-slot mark cannot rewrite the journaled outcome
- Repro: a cancel that loses to a slotless staged start-failure rejection live (no-op, .rejected journals) used to resurface as .cancelled at replay, diverging model and fingerprint
- Session test records that window and asserts the fed terminal stays .rejected with identical model and fingerprint
* Publish spawn exit occupancy before the exit is consumable
- Set the lines-mode undelivered-exit marker before postExit: the queue mutex orders the mark before any consume, so the drain-side clear can never be overtaken by a late worker-side set that would strand the slot in .draining and hold the key forever
- Keep the .draining release store after the post: reclaimSlots joins draining workers, and a worker parked in postExit's full-queue retry must stay unjoinable until the loop can drain
- Cover the drain-side clear directly and prove slot handback with more delivered exits than the pool has slots
* Accept exact-fit blob reads in DirBlobStore
- A read that filled the buffer conflated "blob is exactly buffer-sized" with "blob is larger", rejecting valid exact-fit reads as BlobOverBudget before the hash check - callers size the buffer from the journal record's byte length, so the exact fit is the designed case
- Disambiguate with a one-byte probe past the buffer's end: a byte there is genuinely over budget, EOF falls through to the hash check, and a probe failure maps to BlobIoFailed like the main read
- Pin under/exact/over parity between DirBlobStore and MemoryBlobStore, and that an exact-fit read of damaged bytes still reports BlobCorrupt
* Refuse journaled image dimensions the recorder can never produce
- Replay's damage gate now checks decoded dims: .loaded must carry nonzero width/height whose RGBA8 bytes fit max_registered_canvas_image_pixel_bytes (overflow-checked product), every other outcome must carry 0x0
- A hand-edited maxInt width previously fed verbatim into the app's Msg and, on the TS core host, into an @intCast to i64-classed arm fields - a safety panic
- status needs no twin gate (u16 at the journal codec, completion entry, and EffectImageResult, so every downstream cast holds by type); four new session tests hand-damage journals and assert error.ReplayDamagedRecord
* Hold EventKind state unions to their exact members in stock tsc
- ImageEventKind and AudioEventKind now check state BOTH directions: the & constraint held the arm's states to the SDK union, and a tuple-wrapped [ImageState] extends [M["state"]] refuses a narrower union as never instead of letting it type-check
- The docs promise exactly the fifteen ImageState / six AudioState members; a narrower arm silently dropped states the host emits until the transpiler's own shape check caught it
- Four conformance tests pin the rule in tsc itself: narrower unions fail, the exact-member arms keep passing and transpiling
* Journal fire-and-forget image start failures so replay retires the parked fake
- Stage non-regenerating image terminals even with no on_result: only the Msg is handler-gated, so the staged entry still occupies the id (stagedImageOccupiesKey) and journals at drain, and replay's parked fake request retires when the record feeds instead of holding the key and a slot forever.
- Journal before the handler gate in the pending-image, clipboard, and host drain arms: executor-truth terminals record regardless of the route; regenerating validation refusals stay handler-gated because replay re-runs the same refusal at the same dispatch.
- Pin both sides deterministically: a handlerless start failure holds the id until its terminal drains, and a recorded session feeds the handlerless terminal on replay with an identical Msg stream and fingerprint.
* Retire spawn, fetch, and file slots before their terminal handlers run
- Store .draining consumer-side in the exit, response, and file drain arms, mirroring the image arm: workers store it only after posting the terminal, so a drain riding another wake could dispatch the Msg while the slot still read .running and a same-key retry from the handler (the poll/reload/restart idiom) would reject as a duplicate - nondeterministically, and unlike replay, whose fed fakes retire before feeding.
- Keep the exit arm's retire generation-gated beside the undelivered-marker clear, with the happens-before argument documented at the site; the worker-side store stays after the post (cancel targeting, joinability, full-queue retry) and the re-store is idempotent. Clipboard and host need no consumer store: their terminals stage on the loop thread with the store sequenced before the enqueue, and host replaces rather than rejects.
- Pin the idiom per family: fetch and file handler retries park fresh (unit and record/replay session coverage), and the spawn respawn-from-own-exit test runs a second full cycle.
|
||
|
|
6f526a9c1e |
Menu-bar app lifecycle: close-to-hide windows and app show/quit verbs (#155)
* Track alive-but-hidden window state through frame events and the journal - WindowState/WindowInfo gain a hidden flag: the window is alive and open but off the glass (the close_policy .hide shape), distinct from minimized and from closed - window_frame_changed events carry it into the runtime window table, and the session journal round-trips it (format v6) Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Add the close_policy window declaration: .quit stays the default, .hide is the menu-bar shape - Per-window close_policy threads app.zon (top-level and shell windows) through WindowOptions to the hosts, .quit by default so every existing app is unchanged - macOS: windowShouldClose orders a .hide window out instead of closing (both hosts), the Dock reopen re-shows policy-hidden windows, runtime closes bypass the policy; Windows: WM_CLOSE hides via SW_HIDE and the tray stays the re-show affordance - Linux GTK has no status item to bring a hidden window back, so .hide refuses loudly: a comptime teaching for manifest windows and error.UnsupportedWindowClosePolicy at runtime create, gated by the new window_hide_on_close platform feature Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Add the showWindow and quitApp effects verbs on the window-action seam - fx.showWindow(label) un-hides and activates a window (deminiaturize + order front on macOS, SW_RESTORE + foreground on Windows, present on GTK) and fx.quitApp() terminates through the same shutdown event path a last-window close takes - Both follow the existing verbs' shape exactly: mirror counts in windowActionState, fire-and-forget with no journaling of their own, honest no-ops for unknown labels, mirror-only under the fake executor - Null platform models both with pinned seams (show counts, quit requests); a recorded hide/reopen/quit session replays its window states through the journal Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Mirror the window verbs into the TypeScript Cmd vocabulary - Cmd.showWindow(label) and Cmd.quitApp() ride new additive wire records (window_show 0x10, quit_app 0x11; cmd_format_version 3) and decode onto the same fx.showWindow/fx.quitApp verbs the Zig tier calls - The emitter lowers both with the string-literal label discipline effect keys use (window labels are declarations; a dynamic label is taught) - Conformance pins the emitted shapes and the dynamic-label teaching; the effects gate asserts the exact wire bytes; the host-bridge suite pins the mirror counts Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Add the menu-bar example: the tray-player lifecycle in one small app - Zero-config canvas app whose main window declares close_policy "hide"; the status item's Open/Quit rows map to fx.showWindow/fx.quitApp through the ordinary on_command path, with a model-driven title showing transport state while hidden - Tests drive the real tray-selection-to-verb loop and pin the declaration, the mirror counts, and the exactly-once stop hook - A purpose-built example keeps the pattern reference minimal instead of repurposing soundboard's close behavior Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Document the menu-bar lifecycle and make the tray quit example real - The tray page's "app.quit" example now handles the command (runtime.quitApp) and a lifecycle recipe walks the whole pattern: close hides, tray Open/Quit drive showWindow/quitApp, the Dock reopen re-shows - close_policy documented on the windows and app.zon pages, the new verbs on the runtime and TypeScript pages, and the platform matrix states the per-platform truth including Linux's loud refusal - Changelog fragment for the feature (defaults unchanged, no breaks) Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Thread close_policy through the generated runner template - The scaffolded runner's manifestWindow now mirrors the SDK runner: an app.zon window close_policy rides WindowOptions (.quit stays the default), instead of being silently dropped so a "hide" declaration kept quit-on-close - The "hide" declaration is refused at comptime on linux with the same teaching as the SDK runner - nothing could bring the hidden window back - The template test pins the field and the linux refusal, so dropping either fails the writeDefaultApp test again Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Expose alive-but-hidden in the JS bridge's window JSON - writeWindowJsonToWriter emits "hidden" beside open/focused: a close_policy = "hide" window read open:true/focused:false over the bridge, indistinguishable from a visible unfocused one - NativeSdkWindowInfo carries the field, the window bridge test drives a policy-hidden window through window.list, and the windows doc names the bridge JSON among the surfaces where hidden shows up - The webview JSON twin stays untouched: policy-hidden is window state, webviews have none Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Stop listing hidden as persisted WindowState in the windows doc - The type table said hidden persists while the close-policy prose and the implementation say the opposite; the row now marks it deliberately session-transient (never persisted, every launch starts shown) so nobody re-adds it to the store Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Thread shell-startup window state through the generated runner template - The scaffold runner ignored every `.shell.windows` startup declaration that is host state fixed at create time: mirror the SDK runner's manifestShellStartupTitlebar/Resizable/ShowMode/MinSize/ClosePolicy threading (including the Linux comptime refusal of close_policy "hide") into the windows.len == 0 branch, so a scene-first app.zon no longer silently keeps quit-on-close, standard chrome, immediate show, and no min-size floor. - Thread titlebar and min_width/min_height through the top-level manifestWindow too — the same silent-drop class close_policy shipped with once. - Pin the shell-startup threading and the new window fields in the template test, so dropping any of them fails the scaffold contract loudly. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Route focus of a policy-hidden window through the show verb - focusWindow on a window hidden by close_policy .hide left state inconsistent: the hosts' focus paths order a window forward without touching their policy-hidden bookkeeping (macOS reported hidden=true on a visible window; Windows never shows an SW_HIDE'd window at all), so the runtime now drives showWindow first — the seam that clears the hosts' policy-hidden sets, emits consistent state, and rolls back on platform failure — before focusing. - One rule at the runtime seam covers every focus ingress: the app verb and the JS bridge's native-sdk.window.focus both resolve hidden-then-focus the same way. - Tests pin the routing through the null platform's show count on both paths: the runtime seam (hidden clears, focus lands, no second show for a visible window) and the bridge twin (the focus response never reports a focused window that is still hidden). Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Queue the quit verb's stop so app_shutdown emits after the requesting dispatch - fx.quitApp() lands mid dispatch (the tray/menu command's update returned it), and the macOS and GTK hosts emitted SHUTDOWN synchronously from the stop call: the shutdown dispatch nested inside the command's, the session recorder's nested commit + finish() sealed the journal, the outer commit no-oped, and the journal lost the very command (and model mutation) that quit the app — replay diverged. Both hosts (AppKit, macOS CEF, GTK) now queue the identical emitShutdown + stop onto their loop's next turn (dispatch_async onto the main queue / g_idle_add, the same seams the wake paths ride); Windows already posted. The pre-run-loop inline emit stays on macOS so a failed START handler's synchronous shutdown request keeps working, and stop's posted wake event still unwinds a loop whose quit was the last thing that ever happened. - The null platform's quit seam models the queued shape: quitApp records the request and takeQueuedQuit() hands the test the deferred app_shutdown to dispatch as its own loop turn, exactly once — the userCloseWindow pattern. - A recorded-session test drives the whole verb chain (menu command -> update -> fx.quitApp -> queued host echo) with a recorder attached and pins the contract: the journal is not sealed by the quit dispatch alone, contains BOTH the command and app_shutdown in order, and replays fingerprint-identical; a build check step pins the queued stop in all four host sources so reverting to the synchronous emit fails the suite. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Occlude policy-hidden windows in the Windows pacing and spectrum gates - Both occlusion answers in the WebView2 host were IsIconic-keyed only, so a window hidden by close_policy .hide (the menu-bar shape — hidden for days behind its tray icon) kept its full-rate canvas frame loop and its 25 Hz spectrum emissions running for glass nobody can see: pure background CPU burn. gpuSurfaceOccludedPacingActive now answers occluded for a policy-hidden window's surfaces (the ~1 Hz heartbeat, same first-present and input exemptions), and audioAnyWindowReachesGlass stops counting policy-hidden windows as visible, so an app with every window minimized or hidden goes honestly quiet. - Re-show restores full cadence without dropping a beat: SW_SHOW on a same-size window dispatches no WM_SIZE (the minimize path's restore re-arm), so the show verb itself supersedes a parked heartbeat emission at the frame-grid delay after clearing policy_hidden. - This is C++ host logic the Zig suite cannot execute: the contract is pinned by the test-gpu-occluded-frame-heartbeat file-contains step (both policy_hidden gates plus the re-show re-arm), verified by cross-target -fsyntax-only compiles of both WebView2 configurations, and written into the windows and capabilities docs; the pacing comments now name both occlusion facts honestly. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Refuse a .hide main window at Linux platform init instead of silently quitting on close - LinuxPlatform.initWithOptions creates the MAIN window before any runtime exists, so the runtime's create-time .hide gate (window_storage's error.UnsupportedWindowClosePolicy) and the generated runner's comptime refusal never covered a direct-SDK or custom-runner caller: a .hide declaration was silently dropped and the user got quit-on-close. The init now refuses with the same error and the same teaching text the comptime check prints — one message, all seams — through an extern-free gate that is unit-tested on every host, with a file-contains step pinning both its wiring into initWithOptions and the cross-seam message lockstep. - Three-platform pre-created-window audit: macOS threads the declared policy into the host right after create (applyWindowClosePolicy -> windowShouldClose) and Windows does the same (applyWindowClosePolicy -> the WM_CLOSE hide hook), so neither drops it; Linux was the only silent seam and now refuses. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Park a pre-run quit verb and drain it after the boot dispatch returns - A quit requested before [NSApp run] exists (App.start's update, a boot command during the synchronous first canvas frame) rode the failed-START inline emit on the macOS hosts, nesting app_shutdown inside the very dispatch that requested it — the session recorder sealed the journal before the boot turn committed and replay refused the recording. - The seams are now split: native_sdk_appkit_request_stop (the quit verb, AppKit and CEF alike) parks pre-run quits in pendingPreRunStop and runWithCallback drains them at top level after the START dispatch and again after the remaining pre-run emits, while native_sdk_appkit_stop keeps the byte-for-byte inline pre-run emit for the host-side failed-START request. - The recorded-quit session tier gains the quit-from-boot case (the journal carries the start event and app_shutdown in order, and replays fingerprint-identical through the modeled park-then-drain seam), and test-quit-pre-run-pending pins the host wiring so an inline pre-run revert fails the build. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Track, coalesce, and destroy-remove the GTK quit-stop idle source - Every native_sdk_gtk_stop queued a bare untracked g_idle_add: a second stop requested while the first executed (the shutdown handler's error path can make one) left an idle source holding a freed host after the loop quit. - The pending source id now lives on the host: stop coalesces while a turn is queued and skips entirely once did_shutdown is set, the idle clears the id as its first act, and native_sdk_gtk_destroy removes a still-pending turn before the host is freed. - test-gtk-stop-idle-tracked pins the tracking, coalescing, and removal textually — this suite cannot run the GTK loop on macOS; the linux-dev-smoke CI job exercises the real path at runtime. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Clear hidden with open when the app closes a policy-hidden window - closeWindow flipped open and focused optimistically but left hidden alone, so an app-driven close of a policy-hidden window parked {open=false, hidden=true} in the runtime table — visible through the JS bridge, which exposes hidden; a closed window is not hidden, it is gone. - hidden now clears beside open/focused and restores on platform failure through the same rollback; the null platform gains a fail_next_close_window injection seam (error.CloseFailed, consumed on use) so rollback paths are drivable. - The new ui_app_window test walks the whole arc: .hide close hides, a refused platform close rolls every flag back (still open, still hidden), and the successful close reads open=false and hidden=false — dropping the hidden clear fails it. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Clear policy-hidden membership on the CEF app-close exit that skips windowWillClose - The CEF host's app-driven close of a browser-bearing window exits through orderOut + an open=false emit without ever reaching windowWillClose, so the window stayed in policyHiddenWindows: the frame encoder derives hidden from set membership, so app-closing a policy-hidden window emitted {open=false, hidden=true} (overwriting the runtime's optimistic hidden clear), and a later Dock reopen — which re-shows every set member — could resurrect the closed window. - The branch now leaves the set before its emit; after this every close exit removes and only hideWindowWithId (the user close of a live window) adds, so set membership implies a live, policy-hidden window and the reopen handler cannot resurrect. The AppKit host needs no change: all of its close paths run windowWillClose, whose cleanup already precedes the emit. - A file-contains gate pins both hosts' cleanup-before-emit ordering, including the CEF orderOut branch's own cleanup. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Mirror the close's hidden clear in the null platform and gate its reopen on liveness - NullPlatform.closeWindow cleared open/focused but left hidden standing, and userReopenApp re-showed on the hidden flag alone — so the modeled host resurrected an app-closed policy-hidden window (hidden=false, focused=true, a frame event), the same hole the real macOS hosts' set hygiene closes. - The close mirror now clears hidden with open (matching the runtime table's own close flip), and userReopenApp skips windows that are not open — a closed window keeps its slot in the mirror, so hidden alone is not liveness. - The new test drives the whole arc through the runtime: close a policy-hidden window, reopen emits NO event and the runtime table is untouched, then force a stale hidden flag onto the closed slot to prove the liveness gate holds on its own — reverting either fix fails it, and the existing reopen re-shows test still passes for genuinely hidden-open windows. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Move runtime focus with a successful showWindow: the show verb activates - The service contract is show AND activate — every host's show path makes the window key — but the runtime's showWindow flipped only hidden, so listWindows and the JS bridge reported the shown window unfocused while it stood frontmost on the glass. - After the platform accepts the show, focus now moves through the same setFocusedIndex seam focusWindow uses (the dethroned window's key-loss consequence fires); a refused show still rolls hidden back and moves no focus, drivable through the null platform's new fail_next_show_window injection (error.ShowFailed, consumed on use). - Tests pin both halves: show a hidden window and the runtime table reads it focused with every other window unfocused (the bridge's window.list JSON carries the same truth), the failure injection leaves hidden and focus untouched, and dropping the focus update fails both tests. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Teach the evals Cmd decoder the v3 window_show and quit_app records - cmdview.zig self-identified as cmd_format_version 2 and panicked on op bytes past 0x0F, so any graded core returning Cmd.showWindow or Cmd.quitApp (alone or in a batch) crashed the ts-track behavioral harness mid-eval, reading as the graded app's failure. - It now decodes window_show 0x10 ([op][label_len][label]) and quit_app 0x11 (bare op), mirroring the runtime decoder in src/runtime/ts_core_host.zig; a repo sweep found no other Cmd opcode table outside packages/core and src/runtime, both already v3. - The decoder gains its first tests (round-trip of both records against the encoders' pinned bytes, plus batch adjacency), wired into `zig build test` as test-evals-cmdview since eval-time is too late to learn the decoder lags the format. The 0x10/0x11 overlap with feat/dynamic-images remains merge-order-reconciled. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Execute the CEF show and minimize verbs synchronously on the main thread - native_sdk_appkit_show_window queued its work with dispatch_async and returned 1 immediately, while the close verb runs synchronously (direct on main, dispatch_sync hop otherwise): the runtime's post-success focus flip ran while the window was still in policyHiddenWindows, so frame emits in the gap carried {focused:true, hidden:true}, and a show-then-close in one dispatch re-ordered — the queued show landed AFTER the synchronous close and put a retained (ordered-out) closed window back on the glass. showWindowWithId: now owns closeWindowWithId:'s exact on-main/off-main split, with the window lookup inside the block so a window closed before an off-main hop lands is a no-op. - The minimize sibling had the same queued shape and the same close race (a queued miniaturize captured past a synchronous close genies an app-closed window into the Dock); it moves into a miniaturizeWindowWithId: with the same discipline. Minimize mirrors no runtime window state, so it emits no frame event — the ordering race was its only exposure. - The AppKit host's show and minimize were already synchronous direct calls; a file-contains gate pins the CEF block shapes, the shims' call-then-return-1 ordering, and the AppKit symmetry, so reverting any verb to a queued dispatch fails the suite. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Gate the runtime window verbs on slot liveness before the platform call - Runtime.showWindow resolved the id and called the platform: a closed window keeps its table slot (ids and labels release lazily, at the next create), so a retained dead slot was accepted — the null platform reported {open:false, focused:true} and the CEF host, which retains browser-bearing windows past their close, would visibly re-order one onto the glass. Dead slots now answer error.WindowNotFound (the runtime's one answer for not-open slots) before any platform call, gating the resurrection race independent of host timing. - focusWindow had the same hole (close clears hidden with open, so a dead slot skipped the hidden-routing and reached the platform's focus verb directly) and minimizeWindow too (the CEF host would genie the retained closed window into the Dock); both get the identical gate. - The new test closes a window and drives all three verbs: each answers WindowNotFound, the null recorder counts stay 0, and the runtime table and platform mirror stay closed/unfocused/un-hidden — dropping the showWindow gate fails it (verified). Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Expose the full PlatformFeature cohort to the JS bridge and typings - Backfill camelCase aliases in platformFeatureFromString for the seven members past gpuSurfaces: gpuSurfaceScrollDrivers, contextMenus, viewSurfaceAdoption, audioPlayback, audioStreaming, audioSpectrum, windowHideOnClose - Extend NativeSdkPlatformFeature with both spellings of each and sync the builtin-commands feature enumeration - Pin both window_hide_on_close spellings to the null platform seam (true by default, false when flipped) plus the audioSpectrum alias in the bridge test Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Teach check-runtime-types the PlatformFeature union and alias table - Derive every expected union member (snake plus camel form) from the PlatformFeature enum in src/platform/types.zig and fail naming any missing or extra name in NativeSdkPlatformFeature - Parse platformFeatureFromString's manual alias entries and fail on missing, extra, or mistargeted camelCase aliases - Accept a newline after = when locating exported type unions, matching the multiline union layout Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Correct the quit_app_fn contract to the queued shutdown emit - The doc comment mandated a synchronous app_shutdown emit — the exact shape that nests the shutdown inside the requesting dispatch and seals the recorder's journal before the triggering command commits; every shipped host defers, so the written contract now does too. - States the queued shape (emit on the NEXT loop turn, after the requesting dispatch returns), the pre-run park-and-drain case, the exactly-once app.stop guarantee, and names the null platform's quit_pending/takeQueuedQuit seam as the reference model. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Bring every narrative command-set surface to v3 with the window verbs - Four surfaces still said v2 after the window verbs landed: the rt.zig format heading, sdk/core.ts's command-set header, ts_core_host's command-walk doc, and the ts-core skill's command-set line — each now says v3 (the opcode tables and version-history prose beneath them were already v3-complete and unchanged). - sdk/core.ts's header enumeration and the ts-core skill gain the window-verb pair in the set's voice: Cmd.showWindow(label) (un-hide + activate the declared label, the tray Open consequence) and Cmd.quitApp() (the graceful terminate through the last-window-close shutdown path). - The typescript docs page already carried both verbs; version-cohort test names and the v1/v2-additivity history in rt.zig name past versions correctly and stay as they are. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Serialize the JS window-close response from the post-close table - closeWindowFromJson answered from a pre-close snapshot with open and focused hand-cleared, so a policy-hidden window's close told JS {open:false, hidden:true}; the response now reads the table slot after closeWindow, which owns the full set of flags a close clears - The slot index stays valid across the close: closed windows keep their table slot, and slots only compact inside window create when a dead id or label is re-used - The bridge test now closes a policy-hidden palette window and pins "hidden":false in the response Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Gate secondary startup windows on the window_hide_on_close feature - loadStartupWindows creates index > 0 windows through the platform services directly, bypassing the runtime create path's close-policy support check: a .hide declaration on a secondary startup window was silently accepted on hosts that cannot re-show a closed window, so the user's close really closed it - The same loud error.UnsupportedWindowClosePolicy gate now rides that direct create; the host-created main window keeps its own init-time refusal - New test drives the GTK-shaped null platform (window_hide_on_close=false) through a .quit main plus .hide secondary startup pair and pins the refusal at load; the supported host loads both windows Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Require the tray capability for .hide on Windows and downgrade tray-less hides - close_policy "hide" on windows now requires the "tray" capability at every validation seam: the SDK runner and generated-template comptime checks refuse the declaration (SW_HIDE removes the taskbar entry and windows has no dock-reopen path, so the status item is the only re-show affordance; macos stays exempt via the Dock), and the runtime create gates inherit the refusal because the Windows host answers window_hide_on_close only when the manifest declares a tray (new AppInfo.declares_tray, threaded like has_web_content), with a Linux-style init refusal for the host-created main window - Runtime backstop for a tray that validation accepted but creation lost: the WM_CLOSE hide hook consults the host's live tray_active state and downgrades a tray-less .hide close to a real close with a loud stderr line - a visible close beats a running, invisible, unreachable app - Tests: pure-fn coverage for the conditional supports answer and the init refusal, generated-template text pins for the windows teaching and the declares_tray threading, and a textual pin holding the WM_CLOSE tray consult ahead of the SW_HIDE with the downgrade line present (the C++ branch itself is beyond the suite; both WebView2 configurations cross-compile clean to x86_64-windows-gnu) Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Count UTF-8 bytes in the emitter's literal length gates - Cmd.showWindow's label gate (and every sibling string-literal gate: host/request names, cancel/delay/request/spawn/audioPlay/audio-verb/Sub.timer keys, and the asciiBytes literal bound) counted UTF-16 code units while teaching bytes, so a 200-character CJK label passed the gate and arrived as 600 UTF-8 bytes against the wire's 255-byte length prefix; every user-text gate now measures Buffer.byteLength (Cmd.fetch header names/values stay on .length - they are ASCII-enforced right above, where the counts agree) - rt.cmdWindowShow's std.debug.assert compiles out of ReleaseFast, where the length-byte @intCast would then truncate and corrupt the wire record silently - it is now an explicit panic in every build mode, the loud backstop behind the emitter's build-time teaching - Conformance pins both sides of the byte bound (100 CJK chars = 300 bytes teaches; an 84-char/250-byte multibyte label emits and compiles) and the effects harness pins the wire record for a CJK label carrying its 9-BYTE length prefix Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> * Document the Windows tray prerequisite for close_policy hide - The windows, app.zon, and tray pages now state the tray-capability requirement and the runtime downgrade the round-10 guard introduced. Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> --------- Co-authored-by: perminder-klair <3984412+perminder-klair@users.noreply.github.com> |
||
|
|
584dbbbaa9 |
TypeScript authoring: write app cores in TypeScript (#119)
* TypeScript authoring: write app cores in TypeScript - App cores can be authored in TypeScript and compiled ahead of time to arena-backed native code: the complete language minus the ecosystem and purity violations, checked by tsc plus a teaching checker (NS1001-NS1060), emitting readable Zig with 83ns dispatch, no JS engine, and no GC - The full platform surface reaches TS cores: the Cmd and Sub effects vocabulary bridged to the real engine, markup views binding the committed model, record and replay byte-identical to node semantics, stock-IDE support, multi-file cores with @native-sdk/core library modules, and native init scaffolding TypeScript by default with Zig first-class by choice - Two showcase ports prove the bar with zero hand-written Zig: soundboard-ts at pixel parity with its Zig original and system-monitor-ts sampling the real OS, each with end-to-end batteries including replayed sessions with zero host calls - Docs lead TypeScript-first with a segmented language toggle and a markup-first components reference; the eval suite gains dual-track realistic cases measuring both authoring tiers' health and efficiency * Ship packages/core in the npm package and run its .ts modules from any layout - copy-framework.js stages the @native-sdk/core closure (src/, sdk/, rt/, package.json + package-lock.json; test/ and scripts/ stay out), the sync check pins each staged entry plus the dep.path coverage, and package.json "files" covers the mirrored paths - build/ts_run.mjs runs the transpiler tier's .ts modules on every layout: node refuses builtin type stripping under node_modules, so the runner strips those modules with the transpiler's own installed TypeScript and passes repo checkouts through untouched; build/app.zig, native check, and native dev --core all invoke through it - the missing-dependency teaching now names the real dependency root (works verbatim on the npm-installed layout, where npm ci runs in the shipped packages/core against its shipped lockfile) * TS scaffolds ship a CI workflow - the --full ts-core template now writes the Zig full template's workflow (logic tests + Linux automation smoke, no WebKitGTK) with the node tier added to both jobs: setup-node and one npm ci in the fetched SDK's packages/core, the same install native build's teaching names - slim scaffolds keep shipping no workflow (zero-config parity with the slim Zig template), now pinned by the ts slim template test * Wire @native-sdk/core into the release automation - sync-version.js stamps packages/core (manifest + lockfile own-package fields) and the committed TS examples' pins with the CLI release version, check-version-sync.js refuses a half-bumped tree, and the npm version script stages the stamped files; packages/core rides 0.4.4 from here on and scaffold pins follow the bundled manifest automatically - the release publish step gains the packages/core publish gated on its "private" flag: private (until the 0.5.0 cut, by design) skips with a loud flip-requirement comment; dropping the flag is the publish switch, no workflow edit needed - the TS scaffold README notes npm install is optional (the CLI materializes and refreshes the editor package itself), closing the pre-publish gap window honestly * Provide node to the CI jobs that build TypeScript cores - The Native Examples job panicked on the missing transpiler dependency, and the Zig Core and tooling jobs were silently skipping every node-gated ts-core suite; all three now set up node and npm ci packages/core |