Dynamic images: load at runtime, journal the bytes, replay offline#146
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 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
- 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
- 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
- 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
- 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)
- The live-preview host embeds the runtime and markup engines, which now carry the image effect kind and the <image> element (schema code 67)
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- <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)
…th 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)
- 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.
- 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.
- 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
… 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
ctate
force-pushed
the
feat/dynamic-images
branch
from
July 19, 2026 16:37
fbab934 to
7a33ed7
Compare
- 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.
…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.
- 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
…oundaries 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
- 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
- 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.
…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.
- 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
- 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
- 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
- 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
- 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
…rked 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.
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Images were static — registered from
app.zonassets at launch. This is the first full dynamic media pipeline: apps load images at runtime from disk or the network, show them in views, and recorded sessions replay byte-identically with the network absent.Cmd.imageLoadin the TS tier andfx.loadImagein Zig, mirroring the audio vocabulary: app-chosen model-owned ImageId, local-then-cache-then-network source cascade with atomic content-addressed cache install, one terminal result event carrying the full outcome taxonomy (the registry's own error classes plus the fetch taxonomy — fourteen states, never a silent drop).blobs/<sha256-prefix>, hash + length in the record, same bytes twice = one blob). Replay resolves, verifies, and re-registers from blobs alone — proven fingerprint-identical with the fixture server stopped.<image>leaf (registry element 67) binding a dynamic ImageId from the model, with the avatar-style negative-value teaching in both engines and the model contract wired throughnative check.too_large, never truncated); decode and registration run at drain on the loop thread through the existing registered-image storage — no new pools.docs/dynamic-images) with a transpiler-verified TS-first sample, the limits, and the replay guarantee; ~25 new tests across executor, blob store, replay, markup, and TS conformance/e2e.