From 908cd0180bbcb4cbc61ec404a63c7a8c8b62d64e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 15:58:48 -0500 Subject: [PATCH 01/45] 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 --- src/runtime/effects.zig | 878 +++++++++++++++++++++++++++- src/runtime/effects_image_tests.zig | 606 +++++++++++++++++++ src/runtime/session_journal.zig | 60 +- src/runtime/session_replay.zig | 4 + src/runtime/tests.zig | 1 + src/runtime/ui_app.zig | 13 + 6 files changed, 1540 insertions(+), 22 deletions(-) create mode 100644 src/runtime/effects_image_tests.zig diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 246e8e9f..ea48444f 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -64,6 +64,8 @@ const std = @import("std"); const builtin = @import("builtin"); +const canvas = @import("canvas"); +const canvas_limits = @import("canvas_limits.zig"); const platform = @import("../platform/root.zig"); const runtime_clock = @import("clock.zig"); @@ -634,6 +636,120 @@ pub fn audioCachePath(buffer: []u8, cache_dir: []const u8, url: []const u8) ![]c return std.fmt.bufPrint(buffer, "{s}/audio/{s}{s}", .{ cache_dir, hex, extension }); } +/// Derive the conventional cache file path for a URL image source: +/// `/images/.` — `audioCachePath`'s convention +/// (first 16 bytes of the URL's SHA-256 in lowercase hex, the extension +/// kept as a decoder hint) under its own `images/` segment so the two +/// caches never collide and each clears independently. +pub fn imageCachePath(buffer: []u8, cache_dir: []const u8, url: []const u8) ![]const u8 { + if (cache_dir.len == 0 or url.len == 0) return error.InvalidImageOptions; + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(url, &digest, .{}); + const hex = std.fmt.bytesToHex(digest[0..16].*, .lower); + const tail = if (std.mem.lastIndexOfScalar(u8, url, '/')) |slash| url[slash + 1 ..] else url; + var extension: []const u8 = ""; + if (std.mem.lastIndexOfScalar(u8, tail, '.')) |dot| { + const candidate = tail[dot..]; + // A plausible extension only: short, and free of query/fragment + // syntax that would smuggle URL machinery into a file name. + if (candidate.len <= 8 and std.mem.indexOfAny(u8, candidate, "?#&") == null) { + extension = candidate; + } + } + return std.fmt.bufPrint(buffer, "{s}/images/{s}{s}", .{ cache_dir, hex, extension }); +} + +/// Longest local path (and cache path) one `loadImage` source may name, +/// mirroring the file effect's path bound; the URL keeps the fetch +/// bound (`max_effect_url_bytes`). Longer strings deliver exactly one +/// `.rejected` image result Msg. +pub const max_effect_image_path_bytes: usize = max_effect_file_path_bytes; + +/// Maximum ENCODED source bytes one `loadImage` accepts — from a local +/// file, the URL cache, or the network alike. Sized past the decoded +/// pixel bound (`canvas_limits.max_registered_canvas_image_pixel_bytes`) +/// by the same 1/4 margin the decode scratch carries: an encoded stream +/// larger than that cannot decode inside the registered-image budget on +/// any host, so hauling more bytes would only defer the same +/// `.too_large` answer. Unlike fetch bodies there is no truncated +/// delivery — a cut image can never decode, so over-bound sources fail +/// whole with `.too_large`, never arrive clipped. +pub const max_effect_image_bytes: usize = canvas_limits.max_registered_canvas_image_pixel_bytes + + canvas_limits.max_registered_canvas_image_pixel_bytes / 4; + +/// Bytes of an image effect result's content address in the session +/// journal: the first half of the source bytes' SHA-256, the +/// `audioCachePath` hashing convention. The journal record carries hash +/// and length; the bytes themselves live in the session blob store. +pub const effect_image_blob_hash_len: usize = 16; + +/// The terminal outcome of one `loadImage` effect — exactly one Msg per +/// load, `.loaded` or one failure class, never silence. The classes are +/// the union of the source stages and the registered-image API's own +/// errors, so the effect fails with the same vocabulary the direct +/// `registerCanvasImageBytes` path uses. +pub const EffectImageOutcome = enum(u8) { + /// The pixels are registered under the requested id; `width` and + /// `height` report what the platform codec decoded. + loaded, + /// The request never ran: an invalid id (0, or the reserved + /// media-surface namespace), no source at all, an over-bound + /// path/url, a non-http(s) url, all slots busy, or a duplicate + /// active key. + rejected, + /// The local path does not exist and no url was given to fall + /// through to. + not_found, + /// The local read failed for any reason but absence (permissions, a + /// directory, disk errors). A present-but-unreadable file never + /// falls through to the url — retrying a different source would + /// mask the real problem, the audio cascade's rule. + io_failed, + /// DNS, TCP, or the connect phase failed (the fetch taxonomy). + connect_failed, + /// TLS setup failed. + tls_failed, + /// The HTTP exchange broke mid-protocol. + protocol_failed, + /// The whole cascade exceeded the effect timeout. + timed_out, + /// The server answered with a non-2xx status — carried in + /// `EffectImageResult.status`. The body is discarded, never decoded + /// (an error page is not an image). + http_status, + /// `cancel(id)` ended the load before its result was delivered. + cancelled, + /// The source bytes exceed `max_effect_image_bytes`, or the decoded + /// pixels exceed the registered-image slot bound + /// (`error.ImageTooLarge`) — one budget class either way. + too_large, + /// The host has no image codec (`error.UnsupportedService`), or no + /// image registry is bound to this channel. + unsupported, + /// The platform codec could not decode the bytes + /// (`error.ImageDecodeFailed`, impossible decoded dimensions). + decode_failed, + /// Every registered-image slot holds another id + /// (`error.ImageRegistryFull`). + registry_full, +}; + +/// Payload for `on_result` Msg constructors of image loads. All plain +/// data — safe to store in the model (the decoded pixels live in the +/// runtime's registered-image storage, referenced by id from views). +pub const EffectImageResult = struct { + /// The requested ImageId, echoed verbatim (it doubles as the effect + /// key — see `LoadImageOptions.id`). + id: u64, + outcome: EffectImageOutcome = .loaded, + /// Decoded dimensions for `.loaded`; 0 otherwise. + width: usize = 0, + height: usize = 0, + /// The HTTP status for `.http_status` (and for url-sourced + /// `.loaded` results); 0 otherwise. + status: u16 = 0, +}; + /// Base platform timer id for fx timers: slot N arms the platform timer /// `effect_timer_platform_id_base + N`. Lives in the framework-reserved /// id range (`platform.reserved_timer_id_base`) with an `0x00f7_0000` @@ -676,6 +792,13 @@ pub const EffectResultKind = enum(u8) { /// launches with no variables set) re-derive from the launch /// configuration exactly as before. env = 10, + /// One `loadImage` terminal (`EffectImageResult`). The record's + /// `payload` carries the ENCODED source bytes as they leave the + /// drain — the loaded bytes ARE the effect result — and the session + /// recorder moves them into the content-addressed blob store, + /// journaling `image_blob_hash`/`image_blob_len` in their place + /// (journal format v6). + image = 11, }; /// Journaled wall-clock reads buffered for replay (`Effects.wallMs`). @@ -735,6 +858,18 @@ pub const EffectResultRecord = struct { /// the honest non-determinism (a real FFT of real audio) recorded at /// the boundary so replay repaints identical bars. audio_bands: [platform.audio_spectrum_band_count]u8 = @splat(0), + /// `.image` records: the delivered terminal outcome and the decoded + /// dimensions (0 unless `.loaded`); the HTTP status rides the + /// shared `status` field. + image_outcome: EffectImageOutcome = .loaded, + image_width: u64 = 0, + image_height: u64 = 0, + /// `.image` records: the content address of the journaled source + /// bytes in the session blob store, filled by the recorder when it + /// moves `payload` out of line. All-zero when the terminal carried + /// no bytes (source-stage failures). + image_blob_hash: [effect_image_blob_hash_len]u8 = @splat(0), + image_blob_len: u64 = 0, }; /// Type-erased sink the drain reports every delivered result to while a @@ -915,6 +1050,36 @@ fn classifyFetchError(err: anyerror) EffectFetchOutcome { }; } +/// Map a registered-image API error onto the image effect's failure +/// taxonomy — the SAME classes the direct `registerCanvasImageBytes` +/// path raises, flattened for the one terminal Msg. +fn classifyImageRegisterError(err: anyerror) EffectImageOutcome { + return switch (err) { + error.UnsupportedService => .unsupported, + error.ImageTooLarge => .too_large, + error.ImageRegistryFull => .registry_full, + // Invalid ids are refused at issue time, before any I/O; a + // decode that produced impossible dimensions is a decode + // failure like any other undecodable stream. + error.InvalidImageId => .rejected, + else => .decode_failed, + }; +} + +/// Map a network-phase error of the image cascade onto the image +/// taxonomy, riding the fetch classifier so the two effects never +/// disagree about what a DNS failure is called. +fn classifyImageFetchError(err: anyerror) EffectImageOutcome { + return switch (classifyFetchError(err)) { + .connect_failed => .connect_failed, + .tls_failed => .tls_failed, + .timed_out => .timed_out, + .cancelled => .cancelled, + .rejected => .rejected, + .ok, .protocol_failed => .protocol_failed, + }; +} + pub fn Effects(comptime Msg: type) type { return struct { const Self = @This(); @@ -927,6 +1092,7 @@ pub fn Effects(comptime Msg: type) type { pub const TimerMsgFn = *const fn (timer: EffectTimer) Msg; pub const AudioMsgFn = *const fn (event: EffectAudio) Msg; pub const HostMsgFn = *const fn (result: EffectHostResult) Msg; + pub const ImageMsgFn = *const fn (result: EffectImageResult) Msg; /// Comptime Msg constructor for `on_line`, following /// `canvas.Ui(Msg).inputMsg`: `lineMsg(.agent_line)` builds @@ -1023,6 +1189,18 @@ pub fn Effects(comptime Msg: type) type { }.make; } + /// Comptime Msg constructor for `on_result` of image loads: + /// `imageMsg(.cover_loaded)` builds + /// `Msg{ .cover_loaded = result }` — the variant's payload type + /// must be `native_sdk.EffectImageResult`. + pub fn imageMsg(comptime tag: std.meta.Tag(Msg)) ImageMsgFn { + return struct { + fn make(result: EffectImageResult) Msg { + return @unionInit(Msg, @tagName(tag), result); + } + }.make; + } + pub const SpawnOptions = struct { /// Caller-chosen identity, stored in the model. Must not /// collide with another still-running effect. @@ -1266,6 +1444,63 @@ pub fn Effects(comptime Msg: type) type { on_event: ?AudioMsgFn = null, }; + pub const LoadImageOptions = struct { + /// The ImageId the decoded pixels register under — model- + /// owned, chosen by the app, exactly the id `image`/`avatar` + /// widgets reference. It doubles as the effect key: image + /// loads share the `max_effects` slots and the key space + /// with spawns, fetches, and file effects. Id 0 (the + /// no-image sentinel) and the reserved media-surface + /// namespace (`canvas.media_surface_image_id_bit`) are + /// refused with one `.rejected` result, mirroring + /// `registerCanvasImage`. + id: u64, + /// Local file path, tried FIRST — the audio cascade's rule: + /// a present-but-missing file falls through to `url` when + /// one is given; every other local failure is terminal + /// (retrying a different source would mask the real + /// problem). Bounded by `max_effect_image_path_bytes`. + path: []const u8 = "", + /// http(s) source, tried when the local path is absent or + /// missing. A verified cache entry at `cache_path` loads + /// locally (no network); otherwise the bytes are fetched + /// whole and installed into the cache for next time. Empty + /// means local-only. + url: []const u8 = "", + /// Where the URL's bytes are cached, and the cache policy + /// in one field: empty disables caching. Derive it with + /// `imageCachePath` for the content-addressed convention. + /// The fetch writes beside this path and atomically renames + /// into place only after the size verifies, so a partial + /// download never occupies the cache name. + cache_path: []const u8 = "", + /// The source's known byte size (from a manifest), the + /// cache integrity gate: a cache entry whose size disagrees + /// is discarded and re-fetched, and a finished download + /// that disagrees is never installed. Zero means "unknown" + /// — existence alone then qualifies a cache entry. + expected_bytes: u64 = 0, + /// Whole-cascade timeout in milliseconds (local probe, + /// cache read, and network fetch together); expiry delivers + /// the result with outcome `.timed_out`. + timeout_ms: u32 = default_effect_fetch_timeout_ms, + /// Msg constructor the ONE terminal result flows through. + /// Without one the load still runs (and registers on + /// success); the app just hears nothing back. + on_result: ?ImageMsgFn = null, + }; + + /// A recorded image-load request, exposed by the fake executor + /// for test assertions. Slices point into slot storage and stay + /// valid until the result is fed and drained. + pub const ImageLoadRequest = struct { + id: u64, + path: []const u8, + url: []const u8, + cache_path: []const u8, + expected_bytes: u64, + }; + /// A recorded fx timer, exposed by the fake executor for test /// assertions. pub const TimerRequest = struct { @@ -1372,9 +1607,9 @@ pub fn Effects(comptime Msg: type) type { /// thereby retires) it. const SlotState = enum(u8) { idle, running, done, draining }; - const SlotKind = enum(u8) { spawn, fetch, file, clipboard, host }; + const SlotKind = enum(u8) { spawn, fetch, file, clipboard, host, image }; - const EntryKind = enum(u8) { line, exit, response, file, clipboard, host }; + const EntryKind = enum(u8) { line, exit, response, file, clipboard, host, image }; const Entry = struct { kind: EntryKind = .line, @@ -1417,12 +1652,27 @@ pub fn Effects(comptime Msg: type) type { collect_len: u32 = 0, collect_truncated: bool = false, stderr_truncated: bool = false, + /// `.image` entries: the source stage's outcome (`.loaded` + /// means bytes are staged in the slot buffer and the drain + /// decodes + registers them; anything else is the terminal + /// failure class as-is). + image_outcome: EffectImageOutcome = .loaded, + /// `.image` entries fed with a RECORDED terminal (session + /// replay): the drain delivers the journaled outcome and + /// dimensions verbatim — re-registration is best-effort + /// presentation, never the Msg source — so a replay host + /// whose codec differs from the recording host's still + /// replays the identical Msg stream. + image_fed: bool = false, + image_fed_width: u64 = 0, + image_fed_height: u64 = 0, line_fn: ?LineMsgFn = null, exit_fn: ?ExitMsgFn = null, response_fn: ?ResponseMsgFn = null, file_fn: ?FileMsgFn = null, clipboard_fn: ?ClipboardMsgFn = null, host_fn: ?HostMsgFn = null, + image_fn: ?ImageMsgFn = null, /// `.line` entries whose payload exceeds the inline buffer /// (a raised `max_line_bytes` bound): the bytes ride in this /// heap allocation instead of `line_bytes`. Owned by the @@ -1452,6 +1702,9 @@ pub fn Effects(comptime Msg: type) type { /// `takeAudioMsg`. Non-resolving entries (rejections and /// synchronous failures) are fully formed at enqueue. audio: struct { event: EffectAudio, audio_fn: ?AudioMsgFn, resolve: bool }, + /// Loop-thread image terminals (rejections and fake + /// cancels) — always payload-free, fully formed at enqueue. + image: struct { result: EffectImageResult, image_fn: ?ImageMsgFn }, fn addDropped(pending: *PendingMsg, count: u32) void { switch (pending.*) { @@ -1468,6 +1721,9 @@ pub fn Effects(comptime Msg: type) type { // EffectHostResult carries none: its terminals are // one-per-request by construction. .host => {}, + // EffectImageResult carries none: one terminal per + // load by construction. + .image => {}, } } @@ -1480,6 +1736,7 @@ pub fn Effects(comptime Msg: type) type { .timer => 0, .audio => 0, .host => 0, + .image => 0, }; } }; @@ -1679,6 +1936,22 @@ pub fn Effects(comptime Msg: type) type { file_op: EffectFileOp = .read, // ---- clipboard-only fields (kind == .clipboard) ---- clipboard_op: EffectClipboardOp = .write, + // ---- image-only fields (kind == .image) ---- + on_image: ?ImageMsgFn = null, + /// The local source path (the URL rides `url_storage`, a + /// slot being one occupancy at a time — but path and url + /// COEXIST in an image cascade, so the path gets its own + /// storage). + image_path_storage: [max_effect_image_path_bytes]u8 = undefined, + image_path_len: usize = 0, + image_cache_storage: [max_effect_image_path_bytes]u8 = undefined, + image_cache_len: usize = 0, + image_expected_bytes: u64 = 0, + /// Terminal source-stage state, written by the image task + /// before `fetch_done` (the shared completion latch): + /// `.loaded` means encoded bytes are staged in + /// `fetch_buffer` for the drain to decode + register. + image_outcome: EffectImageOutcome = .loaded, // ---- fetch/file fields ---- /// `.stream` frames the response body into line entries; /// `.buffered` delivers it whole on the terminal entry. @@ -1742,6 +2015,14 @@ pub fn Effects(comptime Msg: type) type { fn hostName(slot: *const Slot) []const u8 { return slot.url_storage[0..slot.url_len]; } + + fn imagePath(slot: *const Slot) []const u8 { + return slot.image_path_storage[0..slot.image_path_len]; + } + + fn imageCache(slot: *const Slot) []const u8 { + return slot.image_cache_storage[0..slot.image_cache_len]; + } }; allocator: std.mem.Allocator, @@ -2756,6 +3037,107 @@ pub fn Effects(comptime Msg: type) type { slot.worker_thread = thread; } + /// Load an image at runtime — the `Cmd.imageLoad` executor: the + /// source cascade (local file first, then a verified cache + /// entry, then the network) runs on a worker thread; the bytes + /// it produces decode through the platform codec and register + /// under `options.id` in the runtime's registered-image storage + /// (the `registerCanvasImageBytes` seam — same slot budget, + /// same pixel bound, same error classes); and exactly ONE + /// terminal Msg follows through `on_result`: `.loaded` with the + /// decoded width/height, or one failure class — never a crash, + /// never silence. Views referencing the id repaint with the + /// pixels on their next frame; `update` stays pure. Decode and + /// registration run at drain time on the loop thread (the + /// registry and its decode scratch are loop-thread state), so + /// the journal records the encoded bytes exactly as the effect + /// boundary delivered them and replay re-runs the same + /// decode-and-register offline. Never fails from the caller's + /// view: requests that cannot run deliver one `.rejected` + /// result on the next drain. Image loads share the + /// `max_effects` slots and the key space (keyed by `id`) with + /// spawns, fetches, and file effects. + pub fn loadImage(self: *Self, options: LoadImageOptions) void { + self.reclaimSlots(); + // The same ids `registerCanvasImage` refuses, refused before + // any I/O: 0 is the no-image sentinel, and the high bit is + // the media-surface texture namespace. + const id_invalid = options.id == 0 or (options.id & canvas.media_surface_image_id_bit) != 0; + if (id_invalid or + (options.path.len == 0 and options.url.len == 0) or + options.path.len > max_effect_image_path_bytes or + options.cache_path.len > max_effect_image_path_bytes or + options.url.len > max_effect_url_bytes) + { + return self.rejectImage(options.id, options.on_result); + } + if (options.url.len > 0) { + const uri = std.Uri.parse(options.url) catch return self.rejectImage(options.id, options.on_result); + const scheme_ok = std.ascii.eqlIgnoreCase(uri.scheme, "http") or + std.ascii.eqlIgnoreCase(uri.scheme, "https"); + if (!scheme_ok) return self.rejectImage(options.id, options.on_result); + } + if (self.findActiveSlot(options.id) != null) return self.rejectImage(options.id, options.on_result); + const slot_index = self.findIdleSlot() orelse return self.rejectImage(options.id, options.on_result); + + const slot = &self.slots[slot_index]; + // One spare byte past the source bound detects over-bound + // files and bodies without a stat round trip (the file + // effect's trick). + const buffer = self.allocator.alloc(u8, max_effect_image_bytes + 1) catch { + return self.rejectImage(options.id, options.on_result); + }; + slot.generation = self.next_generation; + self.next_generation +%= 1; + if (self.next_generation == 0) self.next_generation = 1; + slot.key = options.id; + slot.kind = .image; + slot.on_line = null; + slot.on_exit = null; + slot.on_response = null; + slot.on_file = null; + slot.on_image = options.on_result; + slot.timeout_ms = options.timeout_ms; + slot.cancel_requested.store(false, .release); + slot.fetch_done.store(false, .release); + // `cancelled_generation` stays sticky, exactly as in `spawn`. + slot.dropped_pending = 0; + slot.dropped_total = 0; + @memcpy(slot.url_storage[0..options.url.len], options.url); + slot.url_len = options.url.len; + @memcpy(slot.image_path_storage[0..options.path.len], options.path); + slot.image_path_len = options.path.len; + @memcpy(slot.image_cache_storage[0..options.cache_path.len], options.cache_path); + slot.image_cache_len = options.cache_path.len; + slot.image_expected_bytes = options.expected_bytes; + // Pessimistic default: a task interrupted before it records + // a terminal state must never read as a staged success. + slot.image_outcome = .io_failed; + if (slot.line_buffer) |old| { + self.allocator.free(old); + slot.line_buffer = null; + } + if (slot.fetch_buffer) |old| self.allocator.free(old); + slot.fetch_buffer = buffer; + slot.payload_len = 0; + slot.body_len = 0; + slot.fetch_status = 0; + slot.fake = self.executor == .fake; + slot.state.store(.running, .release); + + if (slot.fake) return; + + const io = self.ensureIo() catch { + self.releaseFetchSlot(slot); + return self.rejectImage(options.id, options.on_result); + }; + const thread = std.Thread.spawn(.{}, imageWorkerMain, .{ self, slot_index, slot.generation, io }) catch { + self.releaseFetchSlot(slot); + return self.rejectImage(options.id, options.on_result); + }; + slot.worker_thread = thread; + } + /// Put text on the system clipboard through the platform /// pasteboard — the same seam the runtime's cmd+C copy uses — /// and deliver exactly one terminal Msg with an explicit @@ -3092,6 +3474,15 @@ pub fn Effects(comptime Msg: type) type { self.deliverLoopClipboard(.{ .key = key_copy, .op = op, .outcome = .cancelled }, clipboard_fn); return; } + if (slot.kind == .image) { + // No cascade ran: retire the slot and surface the + // terminal result now. + const image_fn = slot.on_image; + const key_copy = slot.key; + self.releaseFetchSlot(slot); + self.deliverLoopImage(.{ .id = key_copy, .outcome = .cancelled }, image_fn); + return; + } // No process: retire the slot and surface the exit now. const exit_fn = slot.on_exit; const exit: EffectExit = .{ @@ -3637,6 +4028,18 @@ pub fn Effects(comptime Msg: type) type { }); return event_fn(event); }, + .image => |entry| { + const image_fn = entry.image_fn orelse continue; + self.journalNote(.{ + .kind = .image, + .key = entry.result.id, + .status = entry.result.status, + .image_outcome = entry.result.outcome, + .image_width = entry.result.width, + .image_height = entry.result.height, + }); + return image_fn(entry.result); + }, } } if (!self.dequeueInto(&self.drain_scratch)) return null; @@ -3873,10 +4276,99 @@ pub fn Effects(comptime Msg: type) type { }); return host_fn(result); }, + .image => { + // One terminal per image occupancy, mirroring + // `.response`: a mismatched generation means the + // occupant was already retired. + if (entry.generation != slot.generation) continue; + // Take buffer ownership so the slot can be + // reused while `update` (and the journal sink) + // still read the bytes. + if (self.drain_fetch_body) |old| self.allocator.free(old); + self.drain_fetch_body = slot.fetch_buffer; + slot.fetch_buffer = null; + const image_fn = entry.image_fn; + const bytes: []const u8 = if (self.drain_fetch_body) |buffer| + buffer[0..entry.line_len] + else + ""; + var result: EffectImageResult = .{ + .id = entry.key, + .outcome = entry.image_outcome, + .status = entry.status, + }; + // The journal carries the source bytes whenever + // the cascade delivered them — even when the + // decode below fails, the bytes ARE the effect + // result the boundary produced. + var journal_bytes: []const u8 = ""; + if (cancelled) { + result = .{ .id = entry.key, .outcome = .cancelled }; + } else if (entry.image_fed) { + // Session replay: the recorded terminal is + // the Msg, verbatim. Re-registration is + // best-effort presentation — a replay host + // whose codec cannot decode the recorded + // bytes (the null platform decodes only the + // strict PNG subset) still replays the + // identical Msg stream; views just render + // without the pixels, and pixel checkpoints + // report the honest difference. + journal_bytes = bytes; + result.width = std.math.cast(usize, entry.image_fed_width) orelse 0; + result.height = std.math.cast(usize, entry.image_fed_height) orelse 0; + if (result.outcome == .loaded and bytes.len > 0) { + self.registerDrainedImage(entry.key, bytes); + } + } else if (result.outcome == .loaded) { + journal_bytes = bytes; + if (self.images) |binding| { + if (binding.register_bytes_fn(binding.context, entry.key, bytes)) |info| { + result.width = info.width; + result.height = info.height; + } else |err| { + result.outcome = classifyImageRegisterError(err); + } + } else { + // No registry bound to this channel: + // nothing can hold the pixels — the + // same class as a host without a codec. + result.outcome = .unsupported; + } + } + self.journalNote(.{ + .kind = .image, + .key = result.id, + .payload = journal_bytes, + .status = result.status, + .image_outcome = result.outcome, + .image_width = result.width, + .image_height = result.height, + }); + const deliver_fn = image_fn orelse continue; + return deliver_fn(result); + }, } } } + /// Best-effort decode + register of a replayed image record's + /// bytes. Failure is loud (once per process would hide repeats; + /// once per failure is a bounded replay-time diagnostic) but + /// never steers the Msg stream — the journaled terminal does. + fn registerDrainedImage(self: *Self, id: u64, bytes: []const u8) void { + const binding = self.images orelse return; + _ = binding.register_bytes_fn(binding.context, id, bytes) catch |err| { + if (comptime builtin.os.tag != .freestanding) { + std.debug.print( + "session replay: re-registering image id {d} from the journaled bytes failed ({s}); the Msg stream replays the recorded result, views render without the pixels\n", + .{ id, @errorName(err) }, + ); + } + return; + }; + } + // --------------------------------------------------- fake executor /// Number of recorded (still-active) fake spawn requests. @@ -4249,6 +4741,121 @@ pub fn Effects(comptime Msg: type) type { self.wakeHost(); } + /// Number of recorded (still-active) fake image-load requests. + pub fn pendingImageLoadCount(self: *Self) usize { + var count: usize = 0; + for (&self.slots) |*slot| { + if (slot.fake and slot.kind == .image and slot.state.load(.acquire) == .running) count += 1; + } + return count; + } + + /// The `index`-th recorded fake image-load request (slot order). + pub fn pendingImageLoadAt(self: *Self, index: usize) ?ImageLoadRequest { + var seen: usize = 0; + for (&self.slots) |*slot| { + if (!(slot.fake and slot.kind == .image and slot.state.load(.acquire) == .running)) continue; + if (seen == index) { + return .{ + .id = slot.key, + .path = slot.imagePath(), + .url = slot.fetchUrl(), + .cache_path = slot.imageCache(), + .expected_bytes = slot.image_expected_bytes, + }; + } + seen += 1; + } + return null; + } + + /// Feed synthetic ENCODED source bytes to the fake image load + /// with `id`, retiring its slot — the test mirror of a real + /// cascade that produced bytes: the drain decodes and registers + /// them through the bound registry exactly like the real + /// executor, so tests exercise the full decode→register→Msg + /// path. Bytes over `max_effect_image_bytes` deliver `.too_large` + /// without decoding, mirroring the real bound. + pub fn feedImageBytes(self: *Self, id: u64, bytes: []const u8) error{EffectNotFound}!void { + const slot_index = self.findActiveFakeSlot(id, .image) orelse return error.EffectNotFound; + const slot = &self.slots[slot_index]; + const buffer = slot.fetch_buffer orelse return error.EffectNotFound; + var staged_len: usize = 0; + var outcome: EffectImageOutcome = .loaded; + if (bytes.len > max_effect_image_bytes) { + outcome = .too_large; + } else { + @memcpy(buffer[0..bytes.len], bytes); + staged_len = bytes.len; + } + slot.body_len = staged_len; + self.finishFedImage(slot, .{ + .kind = .image, + .slot_index = @intCast(slot_index), + .generation = slot.generation, + .key = slot.key, + .line_len = @intCast(staged_len), + .status = slot.fetch_status, + .image_outcome = outcome, + .image_fn = slot.on_image, + }); + } + + /// Feed a RECORDED image terminal — the session-replay feed: + /// the journaled outcome, dimensions, and status deliver + /// verbatim (the Msg stream must replay byte-identical even on + /// a host whose codec differs), and a `.loaded` record's bytes + /// re-register best-effort so views repaint the recorded + /// pixels. Also the failure-class feed for tests. + pub fn feedImageResult(self: *Self, id: u64, outcome: EffectImageOutcome, width: u64, height: u64, status: u16, bytes: []const u8) error{EffectNotFound}!void { + const slot_index = self.findActiveFakeSlot(id, .image) orelse return error.EffectNotFound; + const slot = &self.slots[slot_index]; + const buffer = slot.fetch_buffer orelse return error.EffectNotFound; + const staged_len = @min(bytes.len, max_effect_image_bytes); + @memcpy(buffer[0..staged_len], bytes[0..staged_len]); + slot.body_len = staged_len; + self.finishFedImage(slot, .{ + .kind = .image, + .slot_index = @intCast(slot_index), + .generation = slot.generation, + .key = slot.key, + .line_len = @intCast(staged_len), + .status = status, + .image_outcome = outcome, + .image_fed = true, + .image_fed_width = width, + .image_fed_height = height, + .image_fn = slot.on_image, + }); + } + + /// Queue a fed image terminal, with the pending-ring fallback + /// every feed carries: if the completion queue is somehow full, + /// the terminal lands byte-free through the ring. A DERIVED + /// `.loaded` (a bytes feed) is rewritten `.rejected` there — + /// nothing decoded, nothing registered; a success it cannot + /// back would lie. A RECORDED terminal stands verbatim (the + /// replayed Msg stream stays identical; only the best-effort + /// re-registration is lost). + fn finishFedImage(self: *Self, slot: *Slot, entry_value: Entry) void { + var entry = entry_value; + slot.state.store(.draining, .release); + if (!self.enqueue(&entry)) { + const image_fn = slot.on_image; + var outcome = entry.image_outcome; + if (outcome == .loaded and !entry.image_fed) outcome = .rejected; + self.releaseFetchSlot(slot); + self.deliverLoopImage(.{ + .id = entry.key, + .outcome = outcome, + .width = std.math.cast(usize, entry.image_fed_width) orelse 0, + .height = std.math.cast(usize, entry.image_fed_height) orelse 0, + .status = entry.status, + }, image_fn); + } + self.wakeHost(); + } + /// Number of parked (still-active) fake host requests. pub fn pendingHostCount(self: *Self) usize { var count: usize = 0; @@ -4434,6 +5041,22 @@ pub fn Effects(comptime Msg: type) type { self.deliverPending(.{ .clipboard = .{ .result = result, .clipboard_fn = clipboard_fn } }); } + fn rejectImage(self: *Self, id: u64, image_fn: ?ImageMsgFn) void { + self.deliverLoopImage(.{ + .id = id, + .outcome = .rejected, + }, image_fn); + } + + /// Queue a terminal image result produced on the loop thread + /// (rejections, fake cancels, feed fallbacks) for the next + /// drain. No bytes ride here — nothing decodes, nothing + /// registers. + fn deliverLoopImage(self: *Self, result: EffectImageResult, image_fn: ?ImageMsgFn) void { + if (image_fn == null) return; + self.deliverPending(.{ .image = .{ .result = result, .image_fn = image_fn } }); + } + fn rejectTimer(self: *Self, options: StartTimerOptions) void { self.deliverLoopTimer(.{ .key = options.key, @@ -5569,6 +6192,257 @@ pub fn Effects(comptime Msg: type) type { return if (err == error.Canceled) .cancelled else .io_failed; } + // ------------------------------------------------------ image worker + + /// Supervises one image load, mirroring `fetchWorkerMain`: the + /// whole source cascade (local probe, cache read, network + /// fetch, cache install) runs as ONE cancelable `Io` task, + /// polled against `cancel`, `shutdown`, and the effect timeout. + /// Unlike bare file effects — which have no deadline at all and + /// need the abandon safety net — every blocking phase here is + /// deadline-bounded: the timeout (or teardown) cancels the task + /// the same way it cancels a fetch exchange, so teardown joins + /// image workers unconditionally, exactly like fetch workers. + fn imageWorkerMain(self: *Self, slot_index: usize, generation: u32, io: std.Io) void { + const slot = &self.slots[slot_index]; + supervise: { + var future = self.startImageExchange(slot, io) catch { + // Same refusal as fetch: an inline cascade would + // evade cancel, the timeout, and teardown's + // unconditional join. + slot.fetch_status = 0; + slot.body_len = 0; + slot.image_outcome = .rejected; + if (self.fetch_start_rejections.fetchAdd(1, .monotonic) == 0) { + std.debug.print( + "effects image: the executor could not start a cancelable load for image id {d}; rejecting the load (an inline cascade would evade cancel, the timeout, and teardown)\n", + .{slot.key}, + ); + } + break :supervise; + }; + const poll_ms: u64 = 5; + var waited_ms: u64 = 0; + var timed_out = false; + while (true) { + if (slot.fetch_done.load(.acquire)) break; + if (self.shutdown.load(.acquire) or slot.cancel_requested.load(.acquire)) { + future.cancel(io); + break; + } + if (waited_ms >= slot.timeout_ms) { + timed_out = true; + future.cancel(io); + break; + } + std.Io.sleep(io, std.Io.Duration.fromMilliseconds(poll_ms), .awake) catch {}; + waited_ms += poll_ms; + } + future.await(io); + if (!slot.fetch_done.load(.acquire)) { + // Interrupted before the task recorded a terminal + // state (never expected — the task always records). + slot.fetch_status = 0; + slot.body_len = 0; + slot.image_outcome = if (timed_out) .timed_out else .cancelled; + } else if (timed_out and slot.image_outcome != .loaded) { + // The interruption was the deadline, not the app: + // every non-staged outcome here is the timeout's + // doing. A cascade that staged its bytes before the + // deadline fired stays a delivered `.loaded`. + slot.image_outcome = .timed_out; + slot.fetch_status = 0; + slot.body_len = 0; + } + } + self.postImage(slot, @intCast(slot_index), generation, io); + slot.state.store(.draining, .release); + self.wakeHost(); + } + + /// Start the blocking cascade as a cancelable task on the + /// executor io — the only way it may run (see + /// `startFetchExchange` for the contract). Shares the fetch + /// injection switch so tests pin the rejection path. + fn startImageExchange(self: *Self, slot: *Slot, io: std.Io) std.Io.ConcurrentError!std.Io.Future(void) { + if (!self.fetch_concurrent_start) return error.ConcurrencyUnavailable; + return std.Io.concurrent(io, imageTask, .{ self, slot, io }); + } + + /// The blocking cascade, run as a cancelable task. Always + /// records a terminal source-stage state in the slot before + /// `fetch_done`. + fn imageTask(self: *Self, slot: *Slot, io: std.Io) void { + defer slot.fetch_done.store(true, .release); + self.runImageLoad(slot, io) catch |err| { + slot.body_len = 0; + slot.image_outcome = classifyImageFetchError(err); + }; + } + + /// The source cascade, the `playAudio` resolution order made + /// byte-producing: local file first (a MISSING file is the one + /// local failure that falls through to the url — everything + /// else is terminal, because retrying a different source would + /// mask the real problem), then a verified cache entry, then + /// the network with a cache install behind it. On success the + /// encoded bytes sit in the slot buffer with + /// `image_outcome == .loaded`; the drain decodes and registers + /// them on the loop thread. + fn runImageLoad(self: *Self, slot: *Slot, io: std.Io) !void { + const buffer = slot.fetch_buffer.?; + const cwd = std.Io.Dir.cwd(); + if (slot.image_path_len > 0) { + if (cwd.openFile(io, slot.imagePath(), .{})) |file_value| { + var file = file_value; + defer file.close(io); + const len = file.readPositionalAll(io, buffer, 0) catch |err| { + slot.image_outcome = if (err == error.Canceled) .cancelled else .io_failed; + slot.body_len = 0; + return; + }; + if (len > max_effect_image_bytes) { + slot.image_outcome = .too_large; + slot.body_len = 0; + return; + } + slot.body_len = len; + slot.image_outcome = .loaded; + return; + } else |err| { + if (err == error.Canceled) { + slot.image_outcome = .cancelled; + slot.body_len = 0; + return; + } + if (err != error.FileNotFound or slot.url_len == 0) { + slot.image_outcome = if (err == error.FileNotFound) .not_found else .io_failed; + slot.body_len = 0; + return; + } + // Missing local file with a url: fall through. + } + } + if (slot.image_cache_len > 0 and self.readImageCache(slot, io, buffer)) return; + try self.fetchImageBytes(slot, io, buffer); + if (slot.image_outcome == .loaded and slot.image_cache_len > 0) { + self.installImageCache(slot, io, buffer[0..slot.body_len]); + } + } + + /// Probe the cache entry for a url source: it qualifies only + /// when readable, within the source bound, and — when the + /// caller declared `expected_bytes` — exactly that size (the + /// integrity gate). Anything else falls through to the network, + /// which refreshes the entry. + fn readImageCache(self: *Self, slot: *Slot, io: std.Io, buffer: []u8) bool { + _ = self; + const cwd = std.Io.Dir.cwd(); + var file = cwd.openFile(io, slot.imageCache(), .{}) catch return false; + defer file.close(io); + const len = file.readPositionalAll(io, buffer, 0) catch return false; + if (len == 0 or len > max_effect_image_bytes) return false; + if (slot.image_expected_bytes != 0 and len != slot.image_expected_bytes) return false; + slot.body_len = len; + slot.image_outcome = .loaded; + return true; + } + + /// GET the url whole into the slot buffer, mirroring + /// `runFetch`'s exchange discipline. Non-2xx statuses terminate + /// as `.http_status` with the body discarded — an error page is + /// not an image — and bodies over the source bound terminate + /// `.too_large` whole (a cut image can never decode, so there + /// is no truncated delivery). + fn fetchImageBytes(self: *Self, slot: *Slot, io: std.Io, buffer: []u8) !void { + const uri = try std.Uri.parse(slot.fetchUrl()); + var client: std.http.Client = .{ .allocator = self.allocator, .io = io }; + defer client.deinit(); + var request = client.request(.GET, uri, .{ + .keep_alive = false, + .redirect_behavior = @enumFromInt(3), + }) catch |err| { + // See `runFetch`: an untyped failure here is a connect + // failure. + if (err == error.Unexpected) return error.ConnectPhaseFailed; + return err; + }; + defer request.deinit(); + try request.sendBodiless(); + var redirect_buffer: [8 * 1024]u8 = undefined; + var response = try request.receiveHead(&redirect_buffer); + slot.fetch_status = @intFromEnum(response.head.status); + if (response.head.status.class() != .success) { + slot.image_outcome = .http_status; + slot.body_len = 0; + return; + } + const decompress_buffer: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try self.allocator.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try self.allocator.alloc(u8, std.compress.flate.max_window_len), + .compress => return error.UnsupportedCompressionMethod, + }; + defer if (decompress_buffer.len > 0) self.allocator.free(decompress_buffer); + var transfer_buffer: [4096]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); + var body_writer = std.Io.Writer.fixed(buffer); + var over_bound = false; + _ = reader.streamRemaining(&body_writer) catch |err| switch (err) { + // The buffer's spare byte filled: the source is over + // the bound, whole-failure by policy. + error.WriteFailed => over_bound = true, + error.ReadFailed => return response.bodyErr() orelse error.ReadFailed, + }; + if (over_bound or body_writer.end > max_effect_image_bytes) { + slot.image_outcome = .too_large; + slot.body_len = 0; + return; + } + slot.body_len = body_writer.end; + slot.image_outcome = .loaded; + } + + /// Best-effort cache install behind a successful fetch: write + /// beside the cache path and atomically rename into place, and + /// only when `expected_bytes` (if declared) verifies — a + /// partial or wrong-size download never occupies the cache + /// name. Failures cost only the next load a re-fetch. + fn installImageCache(self: *Self, slot: *Slot, io: std.Io, bytes: []const u8) void { + _ = self; + if (slot.image_expected_bytes != 0 and bytes.len != slot.image_expected_bytes) return; + const cache_path = slot.imageCache(); + const cwd = std.Io.Dir.cwd(); + if (std.fs.path.dirname(cache_path)) |parent| { + cwd.createDirPath(io, parent) catch return; + } + var partial_buffer: [max_effect_image_path_bytes + 16]u8 = undefined; + const partial_path = std.fmt.bufPrint(&partial_buffer, "{s}.partial", .{cache_path}) catch return; + cwd.writeFile(io, .{ .sub_path = partial_path, .data = bytes }) catch return; + cwd.rename(partial_path, cwd, cache_path, io) catch {}; + } + + /// The terminal image entry must never be dropped: retry until + /// the loop thread drains space, giving up only on shutdown. + fn postImage(self: *Self, slot: *Slot, slot_index: u16, generation: u32, io: std.Io) void { + var entry: Entry = .{ + .kind = .image, + .slot_index = slot_index, + .generation = generation, + .key = slot.key, + .line_len = @intCast(slot.body_len), + .status = slot.fetch_status, + .image_outcome = slot.image_outcome, + .image_fn = slot.on_image, + }; + while (!self.enqueue(&entry)) { + if (self.shutdown.load(.acquire)) return; + self.wakeHost(); + std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1), .awake) catch {}; + } + } + /// The terminal file result must never be dropped: retry until /// the loop thread drains space, giving up only on shutdown. fn postFile(self: *Self, slot: *Slot, slot_index: u16, generation: u32, io: std.Io, outcome: EffectFileOutcome, read_len: usize) void { diff --git a/src/runtime/effects_image_tests.zig b/src/runtime/effects_image_tests.zig new file mode 100644 index 00000000..b481bf0f --- /dev/null +++ b/src/runtime/effects_image_tests.zig @@ -0,0 +1,606 @@ +//! Image-load effect coverage: `fx.loadImage` through the fake executor +//! (request capture, byte feeds that run the REAL decode+register path, +//! the failure taxonomy, cancel) and the real executor's source cascade +//! — local files via `std.testing.tmpDir`, network sources against a +//! loopback `std.http.Server` fixture spawned inside the test (no +//! external network is ever touched), including the content-addressed +//! cache install and the offline cache hit behind it. + +const std = @import("std"); +const canvas = @import("canvas"); +const geometry = @import("geometry"); +const app_manifest = @import("app_manifest"); +const core = @import("core.zig"); +const ui_app_model = @import("ui_app.zig"); +const effects_mod = @import("effects.zig"); +const canvas_limits = @import("canvas_limits.zig"); + +const canvas_label = "image-canvas"; + +const image_views = [_]app_manifest.ShellView{ + .{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const image_windows = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "Images", + .width = 400, + .height = 300, + .views = &image_views, +}}; +const image_scene: app_manifest.ShellConfig = .{ .windows = &image_windows }; + +const ImageModel = struct { + result_count: usize = 0, + last: ?effects_mod.EffectImageResult = null, + rejected_count: usize = 0, + + /// `EffectImageResult` is all plain data — storing it whole is the + /// documented contract (no borrowed slices to copy). + fn record(model: *ImageModel, result: effects_mod.EffectImageResult) void { + model.result_count += 1; + model.last = result; + if (result.outcome == .rejected) model.rejected_count += 1; + } +}; + +const ImageMsg = union(enum) { + start, + stop, + result: effects_mod.EffectImageResult, +}; + +const ImageApp = ui_app_model.UiApp(ImageModel, ImageMsg); +const ImageEffects = ImageApp.Effects; + +const image_id: u64 = 42; + +// Set by each test before dispatching `.start`; globals keep the update +// function closure-free. +var test_id: u64 = image_id; +var test_path: []const u8 = ""; +var test_url: []const u8 = ""; +var test_cache_path: []const u8 = ""; +var test_expected_bytes: u64 = 0; +var test_timeout_ms: u32 = effects_mod.default_effect_fetch_timeout_ms; + +fn imageUpdate(model: *ImageModel, msg: ImageMsg, fx: *ImageEffects) void { + switch (msg) { + .start => fx.loadImage(.{ + .id = test_id, + .path = test_path, + .url = test_url, + .cache_path = test_cache_path, + .expected_bytes = test_expected_bytes, + .timeout_ms = test_timeout_ms, + .on_result = ImageEffects.imageMsg(.result), + }), + .stop => fx.cancel(test_id), + .result => |result| model.record(result), + } +} + +fn imageView(ui: *ImageApp.Ui, model: *const ImageModel) ImageApp.Ui.Node { + return ui.column(.{ .gap = 4, .padding = 8 }, .{ + ui.text(.{}, ui.fmt("{d} results", .{model.result_count})), + ui.button(.{ .on_press = .start }, "Load"), + ui.button(.{ .on_press = .stop }, "Cancel"), + }); +} + +const Harness = struct { + harness: *core.TestHarness(), + app_state: *ImageApp, + app: core.App, + + fn create() !Harness { + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + errdefer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + // The deterministic decode seam: the strict PNG subset + // `canvas.png.writeRgba8` emits decodes without a bundled codec. + harness.null_platform.image_decode = true; + const app_state = try std.testing.allocator.create(ImageApp); + errdefer std.testing.allocator.destroy(app_state); + app_state.* = ImageApp.init(std.heap.page_allocator, .{}, .{ + .name = "effects-image", + .scene = image_scene, + .canvas_label = canvas_label, + .update_fx = imageUpdate, + .view = imageView, + }); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + // Test defaults back to the shared globals. + test_id = image_id; + test_path = ""; + test_url = ""; + test_cache_path = ""; + test_expected_bytes = 0; + test_timeout_ms = effects_mod.default_effect_fetch_timeout_ms; + return .{ .harness = harness, .app_state = app_state, .app = app }; + } + + fn destroy(self: *Harness) void { + self.app_state.deinit(); + std.testing.allocator.destroy(self.app_state); + self.harness.destroy(std.testing.allocator); + } + + /// Consume all pending wake requests and deliver a single `.wake` + /// platform event for the batch. + fn drainWakes(self: *Harness) !void { + var nudged = false; + while (self.harness.null_platform.takeWake()) |_| nudged = true; + if (nudged) try self.harness.runtime.dispatchPlatformEvent(self.app, .wake); + } +}; + +/// A tiny deterministic RGBA fixture encoded as the strict PNG subset +/// the null platform's decode seam parses. +fn encodePngFixture(buffer: []u8, width: usize, height: usize) []const u8 { + var pixels: [64 * 64 * 4]u8 = undefined; + const byte_len = width * height * 4; + var seed: u8 = 17; + for (pixels[0..byte_len]) |*byte| { + byte.* = seed; + seed = seed *% 29 +% 3; + } + var writer = std.Io.Writer.fixed(buffer); + canvas.png.writeRgba8(&writer, width, height, pixels[0..byte_len]) catch unreachable; + return writer.buffered(); +} + +fn waitForResult(h: *Harness, count: usize) !void { + const io = std.testing.io; + var waited_ms: usize = 0; + while (waited_ms < 20_000) : (waited_ms += 10) { + try h.drainWakes(); + if (h.app_state.model.result_count >= count) return; + try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .awake); + } + return error.TestTimedOut; +} + +// ------------------------------------------------------------ fake executor + +test "fake executor records the whole load request shape and feeds bytes through the real decode path" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + test_path = "assets/cover.png"; + test_url = "https://cdn.example.com/cover.png"; + test_cache_path = "cache/images/abc.png"; + test_expected_bytes = 512; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + + try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingImageLoadCount()); + const request = h.app_state.effects.pendingImageLoadAt(0).?; + try std.testing.expectEqual(image_id, request.id); + try std.testing.expectEqualStrings("assets/cover.png", request.path); + try std.testing.expectEqualStrings("https://cdn.example.com/cover.png", request.url); + try std.testing.expectEqualStrings("cache/images/abc.png", request.cache_path); + try std.testing.expectEqual(@as(u64, 512), request.expected_bytes); + + // Feeding encoded bytes exercises decode + register end to end: + // the Msg carries the decoded dimensions and the pixels are live + // in the registry under the requested id. + var encoded_buffer: [2048]u8 = undefined; + const encoded = encodePngFixture(&encoded_buffer, 3, 2); + try h.app_state.effects.feedImageBytes(image_id, encoded); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 1), h.app_state.model.result_count); + const result = h.app_state.model.last.?; + try std.testing.expectEqual(image_id, result.id); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, result.outcome); + try std.testing.expectEqual(@as(usize, 3), result.width); + try std.testing.expectEqual(@as(usize, 2), result.height); + const registered = h.harness.runtime.registeredCanvasImage(image_id).?; + try std.testing.expectEqual(@as(usize, 3), registered.width); + try std.testing.expectEqual(@as(usize, 2), registered.height); + + // The slot retired: the same id loads again. + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingImageLoadCount()); +} + +test "undecodable and oversized feeds fail with the registry's own classes" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + test_path = "assets/cover.png"; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageBytes(image_id, "not an image"); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.decode_failed, h.app_state.model.last.?.outcome); + try std.testing.expect(h.harness.runtime.registeredCanvasImage(image_id) == null); + + // A host without a codec is `.unsupported`, mirroring + // `error.UnsupportedService` on the direct path. + h.harness.null_platform.image_decode = false; + var encoded_buffer: [2048]u8 = undefined; + const encoded = encodePngFixture(&encoded_buffer, 1, 1); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageBytes(image_id, encoded); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.unsupported, h.app_state.model.last.?.outcome); + h.harness.null_platform.image_decode = true; + + // Over the encoded-source bound: `.too_large` without decoding. + const decodes_before = h.harness.null_platform.image_decode_count; + const oversized = try std.testing.allocator.alloc(u8, effects_mod.max_effect_image_bytes + 1); + defer std.testing.allocator.free(oversized); + @memset(oversized, 7); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageBytes(image_id, oversized); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.too_large, h.app_state.model.last.?.outcome); + try std.testing.expectEqual(decodes_before, h.harness.null_platform.image_decode_count); +} + +test "a full registry fails the load with registry_full, never silence" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + var encoded_buffer: [2048]u8 = undefined; + const encoded = encodePngFixture(&encoded_buffer, 1, 1); + var id: u64 = 1000; + while (id < 1000 + canvas_limits.max_registered_canvas_images) : (id += 1) { + _ = try h.harness.runtime.registerCanvasImageBytes(id, encoded); + } + + test_path = "assets/one-too-many.png"; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageBytes(image_id, encoded); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.registry_full, h.app_state.model.last.?.outcome); +} + +test "recorded terminals feed verbatim: the failure classes and a loaded record's re-registration" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + test_path = "assets/cover.png"; + const classes = [_]effects_mod.EffectImageOutcome{ .not_found, .io_failed, .connect_failed, .tls_failed, .protocol_failed, .timed_out, .decode_failed }; + for (classes, 1..) |class, count| { + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageResult(image_id, class, 0, 0, 0, ""); + try h.drainWakes(); + try std.testing.expectEqual(count, h.app_state.model.result_count); + try std.testing.expectEqual(class, h.app_state.model.last.?.outcome); + } + + // An `.http_status` record carries its status through. + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageResult(image_id, .http_status, 0, 0, 404, ""); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.http_status, h.app_state.model.last.?.outcome); + try std.testing.expectEqual(@as(u16, 404), h.app_state.model.last.?.status); + + // A recorded `.loaded` delivers the journaled dimensions verbatim + // AND re-registers the recorded bytes for presentation. + var encoded_buffer: [2048]u8 = undefined; + const encoded = encodePngFixture(&encoded_buffer, 3, 2); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageResult(image_id, .loaded, 3, 2, 200, encoded); + try h.drainWakes(); + const result = h.app_state.model.last.?; + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, result.outcome); + try std.testing.expectEqual(@as(usize, 3), result.width); + try std.testing.expectEqual(@as(usize, 2), result.height); + try std.testing.expect(h.harness.runtime.registeredCanvasImage(image_id) != null); + + // A recorded `.loaded` whose bytes the replay host cannot decode + // still delivers the recorded Msg — the divergence is loud on + // stderr and presentation-only, never a different Msg stream. + test_id = image_id + 1; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.effects.feedImageResult(image_id + 1, .loaded, 8, 8, 0, "jpeg bytes this host cannot decode"); + try h.drainWakes(); + const undecodable = h.app_state.model.last.?; + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, undecodable.outcome); + try std.testing.expectEqual(@as(usize, 8), undecodable.width); + try std.testing.expect(h.harness.runtime.registeredCanvasImage(image_id + 1) == null); +} + +test "load requests that cannot run are rejected loudly, never silently" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + // No source at all. + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 1), h.app_state.model.rejected_count); + + // Id 0 is the no-image sentinel; the media-surface namespace is + // reserved — both mirror `registerCanvasImage`'s refusals. + test_path = "assets/cover.png"; + test_id = 0; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 2), h.app_state.model.rejected_count); + test_id = canvas.media_surface_image_id_bit | 7; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 3), h.app_state.model.rejected_count); + test_id = image_id; + + // A non-http(s) url. + test_path = ""; + test_url = "file:///etc/passwd"; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 4), h.app_state.model.rejected_count); + + // An over-bound path. + var long_path: [effects_mod.max_effect_image_path_bytes + 1]u8 = undefined; + @memset(&long_path, 'a'); + test_url = ""; + test_path = &long_path; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 5), h.app_state.model.rejected_count); + + // A duplicate active key. + test_path = "assets/cover.png"; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 6), h.app_state.model.rejected_count); + try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingImageLoadCount()); +} + +test "cancelling a fake image load delivers one cancelled terminal" { + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.executor = .fake; + + test_path = "assets/cover.png"; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try h.app_state.dispatch(&h.harness.runtime, 1, .stop); + try h.drainWakes(); + try std.testing.expectEqual(@as(usize, 1), h.app_state.model.result_count); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.cancelled, h.app_state.model.last.?.outcome); + try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingImageLoadCount()); + // Terminal means terminal: a straggler feed reports EffectNotFound. + try std.testing.expectError(error.EffectNotFound, h.app_state.effects.feedImageBytes(image_id, "late")); +} + +// ------------------------------------------------------------ real executor + +test "real executor loads a local file through decode and registration" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const io = std.testing.io; + var h = try Harness.create(); + defer h.destroy(); + + var encoded_buffer: [4096]u8 = undefined; + const encoded = encodePngFixture(&encoded_buffer, 4, 3); + try tmp.dir.writeFile(io, .{ .sub_path = "cover.png", .data = encoded }); + + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/cover.png", .{tmp.sub_path[0..]}); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 1); + const result = h.app_state.model.last.?; + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, result.outcome); + try std.testing.expectEqual(@as(usize, 4), result.width); + try std.testing.expectEqual(@as(usize, 3), result.height); + const registered = h.harness.runtime.registeredCanvasImage(image_id).?; + try std.testing.expectEqual(@as(usize, 4), registered.width); + try std.testing.expectEqual(@as(usize, 3), registered.height); +} + +test "real executor reports a missing local file with no url as not_found" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var h = try Harness.create(); + defer h.destroy(); + + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/absent.png", .{tmp.sub_path[0..]}); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 1); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.not_found, h.app_state.model.last.?.outcome); + try std.testing.expect(h.harness.runtime.registeredCanvasImage(image_id) == null); +} + +test "real executor reports connection refused as connect_failed" { + var h = try Harness.create(); + defer h.destroy(); + + // Bind an ephemeral port, then close it: nothing listens there. + var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + const address = try std.Io.net.IpAddress.parseIp4("127.0.0.1", 0); + var listener = try std.Io.net.IpAddress.listen(&address, io, .{}); + const dead_port = listener.socket.address.getPort(); + listener.deinit(io); + + var url_buffer: [128]u8 = undefined; + test_url = std.fmt.bufPrint(&url_buffer, "http://127.0.0.1:{d}/cover.png", .{dead_port}) catch unreachable; + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 1); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.connect_failed, h.app_state.model.last.?.outcome); + try std.testing.expect(h.harness.runtime.registeredCanvasImage(image_id) == null); +} + +// A loopback HTTP fixture serving image routes (the fetch test +// fixture's shape, image-flavored). +const Fixture = struct { + allocator: std.mem.Allocator, + threaded: *std.Io.Threaded, + listener: std.Io.net.Server, + port: u16, + accept_future: std.Io.Future(void), + stopping: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + request_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), + png_storage: [4096]u8 = undefined, + png_len: usize = 0, + + fn start(allocator: std.mem.Allocator) !*Fixture { + const self = try allocator.create(Fixture); + errdefer allocator.destroy(self); + const threaded = try allocator.create(std.Io.Threaded); + errdefer allocator.destroy(threaded); + threaded.* = std.Io.Threaded.init(allocator, .{}); + errdefer threaded.deinit(); + const io = threaded.io(); + const address = try std.Io.net.IpAddress.parseIp4("127.0.0.1", 0); + var listener = try std.Io.net.IpAddress.listen(&address, io, .{ .reuse_address = true }); + errdefer listener.deinit(io); + self.* = .{ + .allocator = allocator, + .threaded = threaded, + .listener = listener, + .port = listener.socket.address.getPort(), + .accept_future = undefined, + }; + self.png_len = encodePngFixture(&self.png_storage, 4, 3).len; + self.accept_future = try std.Io.concurrent(io, serverMain, .{self}); + return self; + } + + fn stop(self: *Fixture) void { + const io = self.threaded.io(); + self.stopping.store(true, .release); + self.accept_future.cancel(io); + self.listener.deinit(io); + self.threaded.deinit(); + const allocator = self.allocator; + allocator.destroy(self.threaded); + allocator.destroy(self); + } + + fn url(self: *const Fixture, buffer: []u8, path: []const u8) []const u8 { + return std.fmt.bufPrint(buffer, "http://127.0.0.1:{d}{s}", .{ self.port, path }) catch unreachable; + } + + fn serverMain(self: *Fixture) void { + const io = self.threaded.io(); + while (!self.stopping.load(.acquire)) { + const stream = self.listener.accept(io) catch return; + self.handleConnection(io, stream) catch {}; + stream.close(io); + } + } + + fn handleConnection(self: *Fixture, io: std.Io, stream: std.Io.net.Stream) !void { + var recv_buffer: [8192]u8 = undefined; + var send_buffer: [8192]u8 = undefined; + var conn_reader = stream.reader(io, &recv_buffer); + var conn_writer = stream.writer(io, &send_buffer); + var server = std.http.Server.init(&conn_reader.interface, &conn_writer.interface); + var request = try server.receiveHead(); + _ = self.request_count.fetchAdd(1, .monotonic); + const target = request.head.target; + if (std.mem.eql(u8, target, "/cover.png")) { + try request.respond(self.png_storage[0..self.png_len], .{ + .keep_alive = false, + .extra_headers = &.{.{ .name = "content-type", .value = "image/png" }}, + }); + } else if (std.mem.eql(u8, target, "/broken.png")) { + try request.respond("these bytes are no image", .{ .keep_alive = false }); + } else { + try request.respond("nope", .{ .status = .not_found, .keep_alive = false }); + } + } +}; + +test "real executor fetches a url source, installs the cache, and hits it offline next time" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const io = std.testing.io; + var h = try Harness.create(); + defer h.destroy(); + const fixture = try Fixture.start(std.testing.allocator); + + var cache_dir_buffer: [256]u8 = undefined; + const cache_dir = try std.fmt.bufPrint(&cache_dir_buffer, ".zig-cache/tmp/{s}/caches", .{tmp.sub_path[0..]}); + var url_buffer: [128]u8 = undefined; + var cache_buffer: [512]u8 = undefined; + test_url = fixture.url(&url_buffer, "/cover.png"); + test_cache_path = try effects_mod.imageCachePath(&cache_buffer, cache_dir, test_url); + test_expected_bytes = @intCast(fixture.png_len); + + // Missing local path falls through to the url (the audio cascade). + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/absent.png", .{tmp.sub_path[0..]}); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 1); + var result = h.app_state.model.last.?; + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, result.outcome); + try std.testing.expectEqual(@as(usize, 4), result.width); + try std.testing.expectEqual(@as(u16, 200), result.status); + try std.testing.expect(fixture.request_count.load(.acquire) >= 1); + + // The verified download was installed under the content address, + // whole, with no .partial debris beside it. + const cached = try std.Io.Dir.cwd().readFileAlloc(io, test_cache_path, std.testing.allocator, .limited(8192)); + defer std.testing.allocator.free(cached); + try std.testing.expectEqualSlices(u8, fixture.png_storage[0..fixture.png_len], cached); + + // Stop the server: the second load must resolve from the cache — + // no network, same result. + fixture.stop(); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 2); + result = h.app_state.model.last.?; + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, result.outcome); + try std.testing.expectEqual(@as(usize, 4), result.width); + try std.testing.expectEqual(@as(usize, 3), result.height); +} + +test "real executor reports non-2xx statuses and undecodable bodies honestly" { + var h = try Harness.create(); + defer h.destroy(); + const fixture = try Fixture.start(std.testing.allocator); + defer fixture.stop(); + + var url_buffer: [128]u8 = undefined; + test_url = fixture.url(&url_buffer, "/missing.png"); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 1); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.http_status, h.app_state.model.last.?.outcome); + try std.testing.expectEqual(@as(u16, 404), h.app_state.model.last.?.status); + + test_url = fixture.url(&url_buffer, "/broken.png"); + try h.app_state.dispatch(&h.harness.runtime, 1, .start); + try waitForResult(&h, 2); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.decode_failed, h.app_state.model.last.?.outcome); +} + +test "imageCachePath keys by url hash under images/ and keeps the extension" { + var buffer: [512]u8 = undefined; + const path = try effects_mod.imageCachePath(&buffer, "/tmp/caches", "https://cdn.example.com/art/cover.png?sig=abc"); + try std.testing.expect(std.mem.startsWith(u8, path, "/tmp/caches/images/")); + // The query-string suffix never smuggles into the file name; the + // dot-extension survives only when plausible. + try std.testing.expect(std.mem.indexOfAny(u8, path, "?#&") == null); + + const clean = try effects_mod.imageCachePath(&buffer, "/tmp/caches", "https://cdn.example.com/art/cover.png"); + try std.testing.expect(std.mem.endsWith(u8, clean, ".png")); + var second_buffer: [512]u8 = undefined; + const again = try effects_mod.imageCachePath(&second_buffer, "/tmp/caches", "https://cdn.example.com/art/cover.png"); + try std.testing.expectEqualStrings(clean, again); + // A different url gets a different address, and the audio cache + // lives in its own segment. + const other = try effects_mod.imageCachePath(&second_buffer, "/tmp/caches", "https://cdn.example.com/art/other.png"); + try std.testing.expect(!std.mem.eql(u8, clean, other)); + try std.testing.expectError(error.InvalidImageOptions, effects_mod.imageCachePath(&buffer, "", "https://x.example/a.png")); +} diff --git a/src/runtime/session_journal.zig b/src/runtime/session_journal.zig index 8436e17e..8c1945f5 100644 --- a/src/runtime/session_journal.zig +++ b/src/runtime/session_journal.zig @@ -81,7 +81,12 @@ pub const magic = "NSDKSJNL"; /// `pinch_begin`/`pinch_change`/`pinch_end` input kinds (codes 12-14); /// v6 added the `hidden` flag to window-frame records (the /// `close_policy = .hide` window state — a layout change every v5 -/// reader would misparse). +/// reader would misparse) and the `.image` effect-result kind +/// (code 11) with its outcome/dimension fields and the blob-store +/// content address (`image_blob_hash`/`image_blob_len`) appended to +/// every effect record — a v5 reader would have called an image +/// record's kind code corrupt and misparsed the longer layouts, so it +/// refuses the skew at the preamble instead. pub const format_version: u32 = 6; // ------------------------------------------------------------- budgets @@ -751,25 +756,27 @@ pub fn decodeEvent(bytes: []const u8, storage: *EventDecodeStorage) JournalError if (try cursor.readBool()) { composition_cursor = std.math.cast(usize, try cursor.readInt(u64)) orelse return error.JournalCorrupt; } - break :blk .{ .gpu_surface_input = .{ - .window_id = window_id, - .label = label, - .kind = kind, - .timestamp_ns = timestamp_ns, - .pointer_id = pointer_id, - .x = x, - .y = y, - .button = button, - .pressure = pressure, - .delta_x = delta_x, - .delta_y = delta_y, - .key = key, - .text = text, - .composition_cursor = composition_cursor, - .modifiers = try readModifiers(&cursor), - // v5: the pinch magnification delta (0 on non-pinch kinds). - .scale = try cursor.readF32(), - } }; + break :blk .{ + .gpu_surface_input = .{ + .window_id = window_id, + .label = label, + .kind = kind, + .timestamp_ns = timestamp_ns, + .pointer_id = pointer_id, + .x = x, + .y = y, + .button = button, + .pressure = pressure, + .delta_x = delta_x, + .delta_y = delta_y, + .key = key, + .text = text, + .composition_cursor = composition_cursor, + .modifiers = try readModifiers(&cursor), + // v5: the pinch magnification delta (0 on non-pinch kinds). + .scale = try cursor.readF32(), + }, + }; }, .gpu_surface_scroll_driver => blk: { const window_id = try cursor.readInt(u64); @@ -850,6 +857,13 @@ pub fn encodeEffect(record: EffectResultRecord, buffer: []u8) JournalError![]con try cursor.writeBool(record.audio_playing); try cursor.writeBool(record.audio_buffering); try cursor.writeBytes(&record.audio_bands); + // v6: image terminals — outcome, decoded dimensions, and the blob + // store content address of the journaled source bytes. + try cursor.writeEnum(record.image_outcome); + try cursor.writeInt(u64, record.image_width); + try cursor.writeInt(u64, record.image_height); + try cursor.writeBytes(&record.image_blob_hash); + try cursor.writeInt(u64, record.image_blob_len); return buffer[0..cursor.len]; } @@ -882,6 +896,12 @@ pub fn decodeEffect(bytes: []const u8) JournalError!EffectResultRecord { .audio_buffering = try cursor.readBool(), }; @memcpy(&record.audio_bands, try cursor.readBytes(record.audio_bands.len)); + // v6: image terminals. + record.image_outcome = try cursor.readEnum(runtime_effects.EffectImageOutcome); + record.image_width = try cursor.readInt(u64); + record.image_height = try cursor.readInt(u64); + @memcpy(&record.image_blob_hash, try cursor.readBytes(record.image_blob_hash.len)); + record.image_blob_len = try cursor.readInt(u64); if (!cursor.done()) return error.JournalCorrupt; return record; } diff --git a/src/runtime/session_replay.zig b/src/runtime/session_replay.zig index cd1cdd99..4abbd3f3 100644 --- a/src/runtime/session_replay.zig +++ b/src/runtime/session_replay.zig @@ -229,6 +229,10 @@ fn effectRegeneratesUnderReplay(record: journal.EffectResultRecord) bool { // Host-request rejections mark themselves with the exit reason // (the `.host` record encoding); host answers must be fed. .host => record.exit_reason == .rejected, + // Image rejections are loop-side validation that refuses again; + // every other terminal — loaded bytes, source and decode + // failures — is an external input and must be fed. + .image => record.image_outcome == .rejected, // Launch-env deliveries are exactly what must NOT regenerate: // the recorded values feed the replayed envMsgs dispatch so the // replay launch's environment is never consulted. diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig index c72122a5..5449108e 100644 --- a/src/runtime/tests.zig +++ b/src/runtime/tests.zig @@ -21,6 +21,7 @@ test { _ = @import("effects_file_tests.zig"); _ = @import("effects_clipboard_tests.zig"); _ = @import("effects_audio_tests.zig"); + _ = @import("effects_image_tests.zig"); _ = @import("effects_host_tests.zig"); _ = @import("ts_core_host_tests.zig"); _ = @import("clock.zig"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index fa839a2d..b19e6b3c 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -1102,6 +1102,19 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // envMsgs dispatch consumes the queue on the // replayed installing frame (zero env reads). .env => try self.effects.pushReplayEnv(record.stderr_tail, record.payload), + // `.image` records deliver the RECORDED terminal + // verbatim (byte-identical Msg stream on any host) + // and re-register the journaled source bytes — + // resolved from the blob store into `payload` by + // the replayer — best-effort for presentation. + .image => try self.effects.feedImageResult( + record.key, + record.image_outcome, + record.image_width, + record.image_height, + record.status, + record.payload, + ), // Spectrum records feed through the band-carrying // helper so replay repaints identical bars; every // other audio kind rides the plain shape. From 62a2590ea90170813c9b7e5dc619bf73d3e65d1c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 16:05:50 -0500 Subject: [PATCH 02/45] Journal image results through a content-addressed session blob store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app_runner/root.zig | 23 ++- src/runtime/root.zig | 5 + src/runtime/session_blobs.zig | 310 +++++++++++++++++++++++++++++++++ src/runtime/session_record.zig | 29 ++- src/runtime/session_replay.zig | 54 +++++- src/runtime/session_tests.zig | 301 ++++++++++++++++++++++++++++++++ src/runtime/tests.zig | 1 + 7 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 src/runtime/session_blobs.zig diff --git a/src/app_runner/root.zig b/src/app_runner/root.zig index 34da1ee0..8e4e947d 100644 --- a/src/app_runner/root.zig +++ b/src/app_runner/root.zig @@ -701,6 +701,22 @@ const SessionRecordContext = struct { } }; +/// The blob directory beside a session journal: `blobs/` in the +/// journal's directory — large effect payloads (an image load's source +/// bytes) live there content-addressed, referenced from journal +/// records by hash + length (see session_blobs.zig). +fn sessionBlobStore(io: std.Io, journal_path: []const u8) ?*native_sdk.runtime.SessionBlobDirStore { + var dir_buffer: [1024]u8 = undefined; + const parent = std.fs.path.dirname(journal_path) orelse "."; + const blob_dir = std.fmt.bufPrint(&dir_buffer, "{s}/blobs", .{parent}) catch return null; + const store = std.heap.page_allocator.create(native_sdk.runtime.SessionBlobDirStore) catch return null; + store.* = native_sdk.runtime.SessionBlobDirStore.init(io, blob_dir) catch { + std.heap.page_allocator.destroy(store); + return null; + }; + return store; +} + /// `NATIVE_SDK_SESSION_RECORD=`: create the journal file and a /// recorder that streams the session into it from the very first /// dispatched event (init determinism needs init-time effect results). @@ -715,6 +731,7 @@ fn setupSessionRecorder(init: std.process.Init, app_info: native_sdk.AppInfo) ?* context.* = .{ .io = init.io, .file = file }; const recorder = std.heap.page_allocator.create(native_sdk.runtime.SessionRecorder) catch return null; recorder.* = native_sdk.runtime.SessionRecorder.init(context.sink()); + if (sessionBlobStore(init.io, path)) |store| recorder.blob_sink = store.sink(); recorder.begin(native_sdk.runtime.sessionHeaderNow( native_sdk.runtime.sessionPlatformName(), app_info.app_name, @@ -794,7 +811,11 @@ fn runSessionReplay(app: native_sdk.App, options: RunOptions, init: std.process. !std.mem.eql(u8, value, "0") else true; - const report = native_sdk.runtime.replaySession(runtime, app, journal_bytes, .{ .verify = verify }) catch |err| { + const blob_store = sessionBlobStore(init.io, journal_path); + const report = native_sdk.runtime.replaySession(runtime, app, journal_bytes, .{ + .verify = verify, + .blobs = if (blob_store) |store| store.source() else null, + }) catch |err| { switch (err) { error.JournalBadMagic, error.JournalUnsupportedVersion, diff --git a/src/runtime/root.zig b/src/runtime/root.zig index d7430fbd..0f732002 100644 --- a/src/runtime/root.zig +++ b/src/runtime/root.zig @@ -120,9 +120,14 @@ pub const TsUiApp = runtime_ts_ui_app.TsUiApp; const runtime_session_journal = @import("session_journal.zig"); const runtime_session_record = @import("session_record.zig"); const runtime_session_replay = @import("session_replay.zig"); +const runtime_session_blobs = @import("session_blobs.zig"); pub const session_journal = runtime_session_journal; +pub const session_blobs = runtime_session_blobs; pub const SessionRecorder = runtime_session_record.SessionRecorder; pub const SessionRecorderSink = runtime_session_record.RecorderSink; +pub const SessionBlobSink = runtime_session_blobs.SessionBlobSink; +pub const SessionBlobSource = runtime_session_blobs.SessionBlobSource; +pub const SessionBlobDirStore = runtime_session_blobs.DirBlobStore; pub const SessionHeader = runtime_session_journal.Header; pub const sessionHeaderNow = runtime_session_record.headerNow; pub const sessionPlatformName = runtime_session_replay.currentPlatformName; diff --git a/src/runtime/session_blobs.zig b/src/runtime/session_blobs.zig new file mode 100644 index 00000000..7b39b6b7 --- /dev/null +++ b/src/runtime/session_blobs.zig @@ -0,0 +1,310 @@ +//! Session blob store: content-addressed payload storage beside the +//! session journal. +//! +//! Some effect results are too big — and too binary — to inline in +//! journal records: an image load's ENCODED source bytes are the whole +//! effect result, and replaying them byte-identically is the point. So +//! the journal record carries only a content address (the first 16 +//! bytes of the payload's SHA-256, the `audioCachePath` hashing +//! convention) plus the length, and the bytes live as one file per +//! distinct payload under `blobs/` next to the journal file: +//! +//! /session.journal +//! /blobs/<32-hex-chars> +//! +//! Content addressing gives deduplication for free — recording the same +//! bytes twice (a cache hit replaying the same image, two loads of one +//! asset) writes one blob — and makes the store verifiable: replay +//! re-hashes what it reads and refuses a store whose bytes do not match +//! their name, the same hostile-input honesty the journal itself keeps. +//! +//! Two type-erased seams keep the recorder and replayer storage- +//! agnostic: `SessionBlobSink` (recording) and `SessionBlobSource` +//! (replay). `DirBlobStore` backs them with a directory over `std.Io` +//! (the app runner's wiring); `MemoryBlobStore` backs them in memory +//! for tests. + +const std = @import("std"); +const runtime_effects = @import("effects.zig"); + +/// Bytes of a blob's content address (SHA-256 prefix); hex-encoded it +/// is the blob's file name. +pub const hash_len: usize = runtime_effects.effect_image_blob_hash_len; + +/// The largest payload one blob may hold — the largest journaled effect +/// payload that goes out of line (an image load's encoded source). +pub const max_blob_bytes: usize = runtime_effects.max_effect_image_bytes; + +pub const BlobHash = [hash_len]u8; + +/// Content address of `bytes`. +pub fn hashBytes(bytes: []const u8) BlobHash { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + return digest[0..hash_len].*; +} + +/// A blob's file name: the address in lowercase hex, no extension (the +/// bytes are an opaque payload; decoders sniff content, not names). +pub fn hexName(hash: BlobHash) [hash_len * 2]u8 { + return std.fmt.bytesToHex(hash, .lower); +} + +pub const BlobError = error{ + /// The store has no blob under this address — the journal names + /// bytes the store never received (a moved journal without its + /// blobs directory, or a damaged store). + BlobMissing, + /// The stored bytes do not hash to their address, or their length + /// disagrees with the journal record: the store was damaged or + /// hand-edited. + BlobCorrupt, + /// The blob claims a payload beyond `max_blob_bytes`. + BlobOverBudget, + /// The store could not be written (recording) or read (replay). + BlobIoFailed, +}; + +/// Where recorded blobs go. `write_fn` must be idempotent per address — +/// content addressing means a repeat write of the same bytes is a no-op +/// by construction, and implementations should skip the copy. +pub const SessionBlobSink = struct { + context: *anyopaque, + write_fn: *const fn (context: *anyopaque, hash: BlobHash, bytes: []const u8) BlobError!void, +}; + +/// Where replayed blobs come from. `read_fn` fills `buffer` and returns +/// the blob's bytes (a prefix of `buffer`); implementations verify the +/// content hash before answering. +pub const SessionBlobSource = struct { + context: *anyopaque, + read_fn: *const fn (context: *anyopaque, hash: BlobHash, buffer: []u8) BlobError![]const u8, +}; + +/// A directory-backed blob store over `std.Io` — the app runner's +/// implementation for both recording and replay. Writes are atomic +/// (beside-then-rename, the cache-install discipline) and deduplicated +/// by an existence probe; reads re-hash and refuse mismatches. +pub const DirBlobStore = struct { + io: std.Io, + dir_storage: [max_dir_bytes]u8 = undefined, + dir_len: usize = 0, + + pub const max_dir_bytes: usize = 1024; + + pub fn init(io: std.Io, dir_path: []const u8) error{BlobDirTooLong}!DirBlobStore { + if (dir_path.len == 0 or dir_path.len > max_dir_bytes) return error.BlobDirTooLong; + var store: DirBlobStore = .{ .io = io }; + @memcpy(store.dir_storage[0..dir_path.len], dir_path); + store.dir_len = dir_path.len; + return store; + } + + pub fn dir(self: *const DirBlobStore) []const u8 { + return self.dir_storage[0..self.dir_len]; + } + + pub fn sink(self: *DirBlobStore) SessionBlobSink { + return .{ .context = self, .write_fn = writeErased }; + } + + pub fn source(self: *DirBlobStore) SessionBlobSource { + return .{ .context = self, .read_fn = readErased }; + } + + pub fn write(self: *DirBlobStore, hash: BlobHash, bytes: []const u8) BlobError!void { + if (bytes.len > max_blob_bytes) return error.BlobOverBudget; + const cwd = std.Io.Dir.cwd(); + var path_buffer: [max_dir_bytes + hash_len * 2 + 16]u8 = undefined; + const name = hexName(hash); + const blob_path = std.fmt.bufPrint(&path_buffer, "{s}/{s}", .{ self.dir(), name }) catch return error.BlobIoFailed; + // Content addressing: an existing blob under this name IS these + // bytes (replay verifies) — the dedup case costs one probe. + if (cwd.openFile(self.io, blob_path, .{})) |file_value| { + var file = file_value; + file.close(self.io); + return; + } else |_| {} + cwd.createDirPath(self.io, self.dir()) catch return error.BlobIoFailed; + var partial_buffer: [max_dir_bytes + hash_len * 2 + 16]u8 = undefined; + const partial_path = std.fmt.bufPrint(&partial_buffer, "{s}/{s}.partial", .{ self.dir(), name }) catch return error.BlobIoFailed; + cwd.writeFile(self.io, .{ .sub_path = partial_path, .data = bytes }) catch return error.BlobIoFailed; + cwd.rename(partial_path, cwd, blob_path, self.io) catch return error.BlobIoFailed; + } + + pub fn read(self: *DirBlobStore, hash: BlobHash, buffer: []u8) BlobError![]const u8 { + const cwd = std.Io.Dir.cwd(); + var path_buffer: [max_dir_bytes + hash_len * 2 + 16]u8 = undefined; + const name = hexName(hash); + const blob_path = std.fmt.bufPrint(&path_buffer, "{s}/{s}", .{ self.dir(), name }) catch return error.BlobIoFailed; + var file = cwd.openFile(self.io, blob_path, .{}) catch return error.BlobMissing; + defer file.close(self.io); + const len = file.readPositionalAll(self.io, buffer, 0) catch return error.BlobIoFailed; + if (len == buffer.len) return error.BlobOverBudget; + const bytes = buffer[0..len]; + if (!std.mem.eql(u8, &hashBytes(bytes), &hash)) return error.BlobCorrupt; + return bytes; + } + + fn writeErased(context: *anyopaque, hash: BlobHash, bytes: []const u8) BlobError!void { + const self: *DirBlobStore = @ptrCast(@alignCast(context)); + return self.write(hash, bytes); + } + + fn readErased(context: *anyopaque, hash: BlobHash, buffer: []u8) BlobError![]const u8 { + const self: *DirBlobStore = @ptrCast(@alignCast(context)); + return self.read(hash, buffer); + } +}; + +/// An in-memory blob store for tests: allocator-backed, bounded, and +/// honest about capacity. Write dedups by address; read verifies the +/// hash like the directory store, so a test store misbehaves exactly +/// as loudly as the real one. +pub const MemoryBlobStore = struct { + allocator: std.mem.Allocator, + entries: [max_entries]Entry = undefined, + count: usize = 0, + /// How many writes found their address already present — the dedup + /// evidence tests assert on. + dedup_hits: usize = 0, + + pub const max_entries: usize = 32; + + const Entry = struct { + hash: BlobHash, + bytes: []u8, + }; + + pub fn init(allocator: std.mem.Allocator) MemoryBlobStore { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *MemoryBlobStore) void { + for (self.entries[0..self.count]) |entry| self.allocator.free(entry.bytes); + self.count = 0; + } + + pub fn sink(self: *MemoryBlobStore) SessionBlobSink { + return .{ .context = self, .write_fn = writeErased }; + } + + pub fn source(self: *MemoryBlobStore) SessionBlobSource { + return .{ .context = self, .read_fn = readErased }; + } + + pub fn write(self: *MemoryBlobStore, hash: BlobHash, bytes: []const u8) BlobError!void { + if (bytes.len > max_blob_bytes) return error.BlobOverBudget; + if (self.find(hash) != null) { + self.dedup_hits += 1; + return; + } + if (self.count == max_entries) return error.BlobIoFailed; + const copy = self.allocator.dupe(u8, bytes) catch return error.BlobIoFailed; + self.entries[self.count] = .{ .hash = hash, .bytes = copy }; + self.count += 1; + } + + pub fn read(self: *MemoryBlobStore, hash: BlobHash, buffer: []u8) BlobError![]const u8 { + const entry = self.find(hash) orelse return error.BlobMissing; + if (entry.bytes.len > buffer.len) return error.BlobOverBudget; + @memcpy(buffer[0..entry.bytes.len], entry.bytes); + const bytes = buffer[0..entry.bytes.len]; + if (!std.mem.eql(u8, &hashBytes(bytes), &hash)) return error.BlobCorrupt; + return bytes; + } + + fn find(self: *MemoryBlobStore, hash: BlobHash) ?*Entry { + for (self.entries[0..self.count]) |*entry| { + if (std.mem.eql(u8, &entry.hash, &hash)) return entry; + } + return null; + } + + fn writeErased(context: *anyopaque, hash: BlobHash, bytes: []const u8) BlobError!void { + const self: *MemoryBlobStore = @ptrCast(@alignCast(context)); + return self.write(hash, bytes); + } + + fn readErased(context: *anyopaque, hash: BlobHash, buffer: []u8) BlobError![]const u8 { + const self: *MemoryBlobStore = @ptrCast(@alignCast(context)); + return self.read(hash, buffer); + } +}; + +// -------------------------------------------------------------- tests + +const testing = std.testing; + +test "content addresses are stable and hex names filesystem-safe" { + const hash = hashBytes("the same bytes"); + try testing.expectEqualSlices(u8, &hash, &hashBytes("the same bytes")); + try testing.expect(!std.mem.eql(u8, &hash, &hashBytes("different bytes"))); + const name = hexName(hash); + for (name) |char| { + try testing.expect((char >= '0' and char <= '9') or (char >= 'a' and char <= 'f')); + } +} + +test "memory store round-trips, dedups identical bytes, and refuses damage" { + var store = MemoryBlobStore.init(testing.allocator); + defer store.deinit(); + + const bytes = "png bytes, say"; + const hash = hashBytes(bytes); + try store.write(hash, bytes); + try store.write(hash, bytes); + try testing.expectEqual(@as(usize, 1), store.count); + try testing.expectEqual(@as(usize, 1), store.dedup_hits); + + var buffer: [64]u8 = undefined; + const read_back = try store.read(hash, &buffer); + try testing.expectEqualStrings(bytes, read_back); + + try testing.expectError(error.BlobMissing, store.read(hashBytes("never written"), &buffer)); + // A tampered entry no longer hashes to its address. + store.entries[0].bytes[0] ^= 0x40; + try testing.expectError(error.BlobCorrupt, store.read(hash, &buffer)); +} + +test "dir store writes atomically, dedups by existence, and verifies on read" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const io = testing.io; + + var dir_buffer: [256]u8 = undefined; + const dir_path = try std.fmt.bufPrint(&dir_buffer, ".zig-cache/tmp/{s}/blobs", .{tmp.sub_path[0..]}); + var store = try DirBlobStore.init(io, dir_path); + + const bytes = "encoded image bytes"; + const hash = hashBytes(bytes); + try store.write(hash, bytes); + try store.write(hash, bytes); + + var read_buffer: [4096]u8 = undefined; + const read_back = try store.read(hash, &read_buffer); + try testing.expectEqualStrings(bytes, read_back); + + // Exactly one blob file, no .partial debris. + var blob_dir = try tmp.dir.openDir(io, "blobs", .{ .iterate = true }); + defer blob_dir.close(io); + var iterator = blob_dir.iterate(); + var file_count: usize = 0; + while (try iterator.next(io)) |entry| { + try testing.expect(!std.mem.endsWith(u8, entry.name, ".partial")); + file_count += 1; + } + try testing.expectEqual(@as(usize, 1), file_count); + + try testing.expectError(error.BlobMissing, store.read(hashBytes("absent"), &read_buffer)); + + // Damaged bytes are refused, never returned as the payload. + const name = hexName(hash); + var damaged: [64]u8 = undefined; + @memcpy(damaged[0..bytes.len], bytes); + damaged[0] ^= 0x40; + var path_buffer: [300]u8 = undefined; + const rel = try std.fmt.bufPrint(&path_buffer, "blobs/{s}", .{name}); + try tmp.dir.writeFile(io, .{ .sub_path = rel, .data = damaged[0..bytes.len] }); + try testing.expectError(error.BlobCorrupt, store.read(hash, &read_buffer)); +} diff --git a/src/runtime/session_record.zig b/src/runtime/session_record.zig index 4e53a96e..5326216f 100644 --- a/src/runtime/session_record.zig +++ b/src/runtime/session_record.zig @@ -25,6 +25,7 @@ const platform = @import("../platform/root.zig"); const runtime_clock = @import("clock.zig"); const runtime_effects = @import("effects.zig"); const journal = @import("session_journal.zig"); +const session_blobs = @import("session_blobs.zig"); pub const Header = journal.Header; @@ -37,6 +38,13 @@ pub const RecorderSink = struct { pub const SessionRecorder = struct { sink: RecorderSink, + /// Where large effect payloads go out of line (`blobs/` beside the + /// journal, content-addressed — see session_blobs.zig). Bound by + /// the owner alongside the sink; null means no blob storage, and + /// the first effect result that NEEDS one (an image load's source + /// bytes) fails the recording loudly rather than journaling a + /// record replay could never resolve. + blob_sink: ?session_blobs.SessionBlobSink = null, began: bool = false, finished: bool = false, failed: bool = false, @@ -185,10 +193,27 @@ pub const SessionRecorder = struct { } /// Record one drained effect result (the `Effects.bindJournal` - /// callback target). + /// callback target). Image results carry their ENCODED source + /// bytes in `payload`; those move into the content-addressed blob + /// store at this moment — effect-result time — and the journal + /// record keeps only the address and length, so records stay small + /// and identical payloads share one blob. pub fn recordEffect(self: *SessionRecorder, record: runtime_effects.EffectResultRecord) void { if (!self.began or self.failed or self.finished) return; - const payload = journal.encodeEffect(record, &self.effect_buffer) catch { + var journaled = record; + if (record.kind == .image and record.payload.len > 0) { + const blob_sink = self.blob_sink orelse { + return self.fail("an image effect result needs the session blob store, and none is bound - wire SessionRecorder.blob_sink (the app runner creates blobs/ beside the journal)"); + }; + const hash = session_blobs.hashBytes(record.payload); + blob_sink.write_fn(blob_sink.context, hash, record.payload) catch |err| { + return self.fail(@errorName(err)); + }; + journaled.image_blob_hash = hash; + journaled.image_blob_len = record.payload.len; + journaled.payload = ""; + } + const payload = journal.encodeEffect(journaled, &self.effect_buffer) catch { return self.fail("an effect result exceeded max_session_record_bytes"); }; self.writeRecord(.effect, payload); diff --git a/src/runtime/session_replay.zig b/src/runtime/session_replay.zig index 4abbd3f3..b28bbbc5 100644 --- a/src/runtime/session_replay.zig +++ b/src/runtime/session_replay.zig @@ -31,6 +31,7 @@ const canvas = @import("canvas"); const automation_protocol = @import("../automation/protocol.zig"); const core = @import("core.zig"); const journal = @import("session_journal.zig"); +const session_blobs = @import("session_blobs.zig"); pub const ReplayError = error{ /// The journal was recorded on a different platform; v1 replay is @@ -46,6 +47,11 @@ pub const ReplayError = error{ /// The app registered no replay hook (`App.replay_fn`), but the /// journal carries effect results that need one. ReplayUnsupportedApp, + /// The journal references a session blob (an image record's source + /// bytes) but no blob source was provided, the blob is missing, or + /// its bytes fail their content hash — the journal directory was + /// moved without its `blobs/`, or the store was damaged. + ReplayMissingBlob, }; /// Bounded mismatch detail (first N are kept; the count keeps counting). @@ -68,6 +74,11 @@ pub const ReplayOptions = struct { /// Refuse a journal recorded on another platform (the v1 bar). /// Tests recording under the null platform disable this. require_same_platform: bool = true, + /// Where journal records' out-of-line payloads resolve from (the + /// `blobs/` directory beside the journal — see session_blobs.zig). + /// Only consulted when a record references a blob; a journal + /// without image records replays fine with none. + blobs: ?session_blobs.SessionBlobSource = null, }; pub const ReplayReport = struct { @@ -152,11 +163,31 @@ pub fn replaySession( try runtime.dispatchPlatformEvent(app, event); report.events_replayed += 1; }, - .effect => |effect| { + .effect => |effect_record| { + var effect = effect_record; if (effectRegeneratesUnderReplay(effect)) { report.effects_skipped += 1; continue; } + // Resolve out-of-line payloads: an image record's + // source bytes come from the blob store, verified + // against the journaled address and length, and feed + // as `payload` — the recorded bytes, byte-identical, + // no network. The scratch lives until the feed below + // returns (the fed bytes are copied into the stub + // executor's slot buffer). + var blob_scratch: ?[]u8 = null; + defer if (blob_scratch) |scratch| std.heap.page_allocator.free(scratch); + if (effect.kind == .image and effect.image_blob_len > 0) { + const bytes = resolveBlob(effect, options.blobs, &blob_scratch) catch |err| { + std.debug.print( + "replay refused after event {d}: image record for id {d} references blob {s} ({d} bytes) that could not be resolved ({s}) - replay needs the journal's blobs/ directory beside it\n", + .{ report.events_replayed, effect.key, session_blobs.hexName(effect.image_blob_hash), effect.image_blob_len, @errorName(err) }, + ); + return error.ReplayMissingBlob; + }; + effect.payload = bytes; + } app.replayControl(.{ .feed = effect }) catch |err| switch (err) { error.EffectNotFound => { std.debug.print( @@ -209,6 +240,27 @@ pub fn replaySession( return report; } +/// Read one journal-referenced blob into fresh scratch (handed to the +/// caller through `scratch_out` for freeing) and verify it against the +/// record: present, exact length, and hashing to its address — the +/// same hostile-input honesty the journal reader keeps. +fn resolveBlob( + record: journal.EffectResultRecord, + blobs: ?session_blobs.SessionBlobSource, + scratch_out: *?[]u8, +) anyerror![]const u8 { + const blob_source = blobs orelse return error.BlobMissing; + if (record.image_blob_len > session_blobs.max_blob_bytes) return error.BlobOverBudget; + const blob_len: usize = @intCast(record.image_blob_len); + // One spare byte proves the stored blob is not LONGER than the + // record claims (the source reads at most the buffer). + const scratch = try std.heap.page_allocator.alloc(u8, blob_len + 1); + scratch_out.* = scratch; + const bytes = try blob_source.read_fn(blob_source.context, record.image_blob_hash, scratch); + if (bytes.len != blob_len) return error.BlobCorrupt; + return bytes; +} + /// Journaled results that regenerate deterministically from the /// replayed updates themselves — feeding them would double-deliver: /// rejections (the same over-capacity/duplicate-key validation refuses diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index f116f481..4ed89b63 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -1330,3 +1330,304 @@ test "a quit from the app's boot dispatch journals the start turn before the shu try std.testing.expectEqual(recorded_fingerprint, harness.runtime.sessionStateFingerprint()); } } + +// --------------------------------------------- image loads and the blob store + +const session_blobs = @import("session_blobs.zig"); + +const image_canvas_label = "image-session-canvas"; + +const ImageSessionModel = struct { + results: u32 = 0, + loaded: u32 = 0, + failed: u32 = 0, + rejected: u32 = 0, + last_width: usize = 0, + last_height: usize = 0, + last_outcome_name: [24]u8 = @splat(' '), + last_outcome_len: usize = 0, + + fn outcomeName(self: *const ImageSessionModel) []const u8 { + return self.last_outcome_name[0..self.last_outcome_len]; + } +}; + +const ImageSessionMsg = union(enum) { + load_cover, + load_cover_again, + load_broken, + load_invalid, + image: effects_mod.EffectImageResult, +}; + +const ImageSessionApp = ui_app_mod.UiApp(ImageSessionModel, ImageSessionMsg); + +fn imageSessionUpdate(model: *ImageSessionModel, msg: ImageSessionMsg, fx: *ImageSessionApp.Effects) void { + switch (msg) { + .load_cover => fx.loadImage(.{ .id = 21, .path = "art/cover.png", .on_result = ImageSessionApp.Effects.imageMsg(.image) }), + // A second id over the SAME bytes: the journal's blob store + // must hold ONE blob for both records. + .load_cover_again => fx.loadImage(.{ .id = 22, .path = "art/cover.png", .on_result = ImageSessionApp.Effects.imageMsg(.image) }), + .load_broken => fx.loadImage(.{ .id = 23, .path = "art/broken.png", .on_result = ImageSessionApp.Effects.imageMsg(.image) }), + // Id 0 is refused loop-side: a `.rejected` record that must + // REGENERATE under replay rather than feed. + .load_invalid => fx.loadImage(.{ .id = 0, .path = "art/cover.png", .on_result = ImageSessionApp.Effects.imageMsg(.image) }), + .image => |result| { + model.results += 1; + switch (result.outcome) { + .loaded => model.loaded += 1, + .rejected => model.rejected += 1, + else => model.failed += 1, + } + model.last_width = result.width; + model.last_height = result.height; + const name = @tagName(result.outcome); + const len = @min(name.len, model.last_outcome_name.len); + @memcpy(model.last_outcome_name[0..len], name[0..len]); + model.last_outcome_len = len; + }, + } +} + +fn imageSessionView(ui: *ImageSessionApp.Ui, model: *const ImageSessionModel) ImageSessionApp.Ui.Node { + // The semantic tree carries the image-derived model state, so the + // fingerprint checkpoints PIN the Msg stream: a replay that + // delivered different outcomes or dimensions mismatches here. + return ui.column(.{ .gap = 4, .padding = 8 }, .{ + ui.text(.{}, ui.fmt("{d} results, {d} loaded, {d} failed, {d} rejected", .{ model.results, model.loaded, model.failed, model.rejected })), + ui.text(.{}, ui.fmt("last {s} {d}x{d}", .{ model.outcomeName(), model.last_width, model.last_height })), + }); +} + +fn imageSessionCommand(name: []const u8) ?ImageSessionMsg { + if (std.mem.eql(u8, name, "image.cover")) return .load_cover; + if (std.mem.eql(u8, name, "image.again")) return .load_cover_again; + if (std.mem.eql(u8, name, "image.broken")) return .load_broken; + if (std.mem.eql(u8, name, "image.invalid")) return .load_invalid; + return null; +} + +const image_session_views = [_]app_manifest.ShellView{ + .{ .label = image_canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const image_session_windows = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "Image Session", + .width = 400, + .height = 300, + .views = &image_session_views, +}}; +const image_session_scene: app_manifest.ShellConfig = .{ .windows = &image_session_windows }; + +fn imageSessionOptions() ImageSessionApp.Options { + return .{ + .name = "image-session-demo", + .scene = image_session_scene, + .canvas_label = image_canvas_label, + .update_fx = imageSessionUpdate, + .view = imageSessionView, + .on_command = imageSessionCommand, + }; +} + +fn imageSessionPng(buffer: []u8) []const u8 { + var pixels: [6 * 5 * 4]u8 = undefined; + var seed: u8 = 23; + for (&pixels) |*byte| { + byte.* = seed; + seed = seed *% 31 +% 7; + } + var writer = std.Io.Writer.fixed(buffer); + canvas.png.writeRgba8(&writer, 6, 5, &pixels) catch unreachable; + return writer.buffered(); +} + +const RecordedImageSession = struct { + model: ImageSessionModel, + fingerprint: u64, +}; + +/// Record the image reference session: two loads of the same bytes +/// (one blob, two records), one decode failure (its bytes journal +/// too), and one loop-side rejection (regenerates at replay). +fn recordImageSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, store: *session_blobs.MemoryBlobStore) !RecordedImageSession { + const recorder = try std.heap.page_allocator.create(session_record.SessionRecorder); + defer std.heap.page_allocator.destroy(recorder); + recorder.* = session_record.SessionRecorder.init(buffer.sink()); + recorder.blob_sink = store.sink(); + recorder.begin(.{ .platform_name = "test", .app_name = "image-session-demo", .window_width = 400, .window_height = 300 }); + + const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(gpa); + harness.null_platform.gpu_surfaces = true; + harness.null_platform.image_decode = true; + harness.runtime.options.session_recorder = recorder; + + const app_state = try gpa.create(ImageSessionApp); + defer gpa.destroy(app_state); + app_state.* = ImageSessionApp.init(std.heap.page_allocator, .{}, imageSessionOptions()); + defer app_state.deinit(); + app_state.effects.executor = .fake; + const app = app_state.app(); + + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = image_canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 2, + .frame_index = 1, + .timestamp_ns = 1_000_000, + } }); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + var png_buffer: [4096]u8 = undefined; + const png = imageSessionPng(&png_buffer); + + try harness.runtime.dispatchPlatformEvent(app, .{ .menu_command = .{ .name = "image.cover", .window_id = 1 } }); + try app_state.effects.feedImageBytes(21, png); + try harness.runtime.dispatchPlatformEvent(app, .wake); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + try harness.runtime.dispatchPlatformEvent(app, .{ .menu_command = .{ .name = "image.again", .window_id = 1 } }); + try app_state.effects.feedImageBytes(22, png); + try harness.runtime.dispatchPlatformEvent(app, .wake); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + try harness.runtime.dispatchPlatformEvent(app, .{ .menu_command = .{ .name = "image.broken", .window_id = 1 } }); + try app_state.effects.feedImageBytes(23, "these bytes are no image"); + try harness.runtime.dispatchPlatformEvent(app, .wake); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + try harness.runtime.dispatchPlatformEvent(app, .{ .menu_command = .{ .name = "image.invalid", .window_id = 1 } }); + try harness.runtime.dispatchPlatformEvent(app, .wake); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + + // Both decoded loads are live in the recording runtime. + try std.testing.expect(harness.runtime.registeredCanvasImage(21) != null); + try std.testing.expect(harness.runtime.registeredCanvasImage(22) != null); + + recorder.finish(); + try std.testing.expect(!recorder.failed); + return .{ + .model = app_state.model, + .fingerprint = harness.runtime.sessionStateFingerprint(), + }; +} + +test "image loads record into the blob store (deduplicated) and replay byte-identical, offline" { + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + var store = session_blobs.MemoryBlobStore.init(gpa); + defer store.deinit(); + + const recorded = try recordImageSession(gpa, buffer, &store); + try std.testing.expectEqual(@as(u32, 4), recorded.model.results); + try std.testing.expectEqual(@as(u32, 2), recorded.model.loaded); + try std.testing.expectEqual(@as(u32, 1), recorded.model.failed); + try std.testing.expectEqual(@as(u32, 1), recorded.model.rejected); + + // Same bytes twice = ONE blob; the broken bytes are a second. + try std.testing.expectEqual(@as(usize, 2), store.count); + try std.testing.expectEqual(@as(usize, 1), store.dedup_hits); + + // Replay into a fresh app: the journal plus the blob store are the + // WHOLE world — no files are read (the fake paths never existed), + // no network is touched, and every fingerprint checkpoint matches. + const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(gpa); + harness.null_platform.gpu_surfaces = true; + harness.null_platform.image_decode = true; + const app_state = try gpa.create(ImageSessionApp); + defer gpa.destroy(app_state); + app_state.* = ImageSessionApp.init(std.heap.page_allocator, .{}, imageSessionOptions()); + defer app_state.deinit(); + + const report = try session_replay.replaySession(&harness.runtime, app_state.app(), buffer.journalBytes(), .{ + .verify = true, + .require_same_platform = false, + .blobs = store.source(), + }); + try std.testing.expect(report.ok()); + try std.testing.expect(report.checkpoints_verified > 0); + // Two loaded terminals and the decode failure feed; the rejection + // regenerates from the same loop-side validation. + try std.testing.expectEqual(@as(u64, 3), report.effects_fed); + try std.testing.expectEqual(@as(u64, 1), report.effects_skipped); + try std.testing.expectEqualDeep(recorded.model, app_state.model); + try std.testing.expectEqual(recorded.fingerprint, harness.runtime.sessionStateFingerprint()); + + // The replayed runtime re-registered the recorded bytes: the + // pixels are drawable offline, straight from the blob store. + const replay_registered = harness.runtime.registeredCanvasImage(21).?; + try std.testing.expectEqual(@as(usize, 6), replay_registered.width); + try std.testing.expectEqual(@as(usize, 5), replay_registered.height); + try std.testing.expect(harness.runtime.registeredCanvasImage(22) != null); + try std.testing.expect(harness.runtime.registeredCanvasImage(23) == null); +} + +test "a journal referencing blobs refuses to replay without its blob store" { + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + var store = session_blobs.MemoryBlobStore.init(gpa); + defer store.deinit(); + _ = try recordImageSession(gpa, buffer, &store); + + const harness = try core.TestHarness().create(gpa, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(gpa); + harness.null_platform.gpu_surfaces = true; + harness.null_platform.image_decode = true; + const app_state = try gpa.create(ImageSessionApp); + defer gpa.destroy(app_state); + app_state.* = ImageSessionApp.init(std.heap.page_allocator, .{}, imageSessionOptions()); + defer app_state.deinit(); + + const result = session_replay.replaySession(&harness.runtime, app_state.app(), buffer.journalBytes(), .{ + .verify = false, + .require_same_platform = false, + }); + try std.testing.expectError(error.ReplayMissingBlob, result); +} + +test "recording an image result without a blob store fails the journal loudly" { + const gpa = std.testing.allocator; + const buffer = try std.heap.page_allocator.create(JournalBuffer); + defer std.heap.page_allocator.destroy(buffer); + buffer.len = 0; + + const recorder = try std.heap.page_allocator.create(session_record.SessionRecorder); + defer std.heap.page_allocator.destroy(recorder); + recorder.* = session_record.SessionRecorder.init(buffer.sink()); + recorder.begin(.{ .platform_name = "test", .app_name = "image-session-demo" }); + recorder.recordEffect(.{ .kind = .image, .key = 21, .payload = "encoded bytes", .image_outcome = .loaded }); + try std.testing.expect(recorder.failed); + _ = gpa; +} + +test "image effect records round-trip the blob address through the journal codec" { + var buffer: [4096]u8 = undefined; + var hash: [effects_mod.effect_image_blob_hash_len]u8 = undefined; + for (&hash, 0..) |*byte, index| byte.* = @intCast(index * 3 + 1); + const encoded = try journal.encodeEffect(.{ + .kind = .image, + .key = 21, + .status = 200, + .image_outcome = .loaded, + .image_width = 640, + .image_height = 480, + .image_blob_hash = hash, + .image_blob_len = 12_345, + }, &buffer); + const decoded = try journal.decodeEffect(encoded); + try std.testing.expectEqual(effects_mod.EffectResultKind.image, decoded.kind); + try std.testing.expectEqual(@as(u64, 21), decoded.key); + try std.testing.expectEqual(@as(u16, 200), decoded.status); + try std.testing.expectEqual(effects_mod.EffectImageOutcome.loaded, decoded.image_outcome); + try std.testing.expectEqual(@as(u64, 640), decoded.image_width); + try std.testing.expectEqual(@as(u64, 480), decoded.image_height); + try std.testing.expectEqualSlices(u8, &hash, &decoded.image_blob_hash); + try std.testing.expectEqual(@as(u64, 12_345), decoded.image_blob_len); +} diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig index 5449108e..8ba6be44 100644 --- a/src/runtime/tests.zig +++ b/src/runtime/tests.zig @@ -29,6 +29,7 @@ test { _ = @import("markdown_app_tests.zig"); _ = @import("platform_bridge_tests.zig"); _ = @import("session_journal.zig"); + _ = @import("session_blobs.zig"); _ = @import("session_record.zig"); _ = @import("session_tests.zig"); } From 9b9d6db81bf6db3e126b348ec18f8ade5d3b135c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 16:19:10 -0500 Subject: [PATCH 03/45] Markup : 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 --- docs/src/lib/component-vocab.json | 6 +- skill-data/native-ui/SKILL.md | 3 +- src/primitives/canvas/ui_markup.zig | 22 ++- src/primitives/canvas/ui_markup_compiled.zig | 16 +-- .../canvas/ui_markup_compiled_tests.zig | 55 ++++++++ src/primitives/canvas/ui_markup_contract.zig | 2 +- .../canvas/ui_markup_contract_tests.zig | 26 ++++ src/primitives/canvas/ui_markup_tests.zig | 29 ++-- src/primitives/canvas/ui_markup_view.zig | 23 ++-- .../canvas/ui_markup_view_tests.zig | 129 ++++++++++++++++-- src/primitives/canvas/ui_schema.zig | 13 ++ src/primitives/canvas/ui_schema_tests.zig | 9 +- tools/native-sdk/markup_docs.zig | 3 +- tools/native-sdk/markup_lsp.zig | 4 +- 14 files changed, 287 insertions(+), 53 deletions(-) diff --git a/docs/src/lib/component-vocab.json b/docs/src/lib/component-vocab.json index 0fa2daa4..4dbda082 100644 --- a/docs/src/lib/component-vocab.json +++ b/docs/src/lib/component-vocab.json @@ -263,6 +263,10 @@ { "name": "media-surface", "doc": "The media surface leaf: composites a texture produced OUTSIDE the widget tree (a video decoder, a camera pipeline, an external renderer) into the layout like any widget — clipped, z-ordered, transformed. surface is one {binding} to the model-owned u64 surface id a Zig-tier producer targets (runtime.acquireMediaSurfaceProducer pushes RGBA8 frames, latest-wins, paced by the presented-frame clock). Until the first frame arrives it shows a deterministic id-derived placeholder — which is also all that goldens, screenshots, and session replay ever show: texture contents are presentation chrome. Display-only (presses fall through); size it like an image (width/height or grow); label it for screen readers." + }, + { + "name": "image", + "doc": "The image leaf: draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id Cmd.imageLoad (TS) or fx.loadImage/fx.registerImageBytes (Zig) registered pixels under. image is one required {binding}; ids are model data, never markup literals, and 0 draws nothing (store the id in the model only when the load reports loaded). Display-only (presses fall through); size it with width/height or grow (no intrinsic size); label it for screen readers." } ], "structure": [ @@ -631,7 +635,7 @@ "avatar": [ { "name": "image", - "doc": "avatar: one {binding} to a u64 ImageId the app registered at runtime (fx.registerImageBytes); 0 renders the initials fallback." + "doc": "avatar and image: one {binding} to a u64 ImageId the app registered at runtime (Cmd.imageLoad, fx.loadImage, fx.registerImageBytes); 0 draws nothing (an avatar falls back to its initials). Required on the image leaf." } ], "media-surface": [ diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 5a628e81..002b5efa 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -181,6 +181,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `skeleton`, `spinner` | loading leaves | size `skeleton` with `width`/`height` | | `icon` | vector icon leaf | `name` picks the icon: a bare literal is a curated built-in stroke icon (compile-checked; 49 names: search, plus, x, x-circle, check, check-circle, chevron-up/down/left/right, arrow-up/down/right, menu, panel-left, panel-right, settings, terminal, wrench, trash, edit, copy, external-link, play, pause, skip-back/forward, shuffle, repeat, music, volume, info, alert, download, save, folder, folder-open, file-text, sun, moon, eye, clock, git-pull-request, git-merge, git-branch, circle-dot, archive, refresh-cw, send); `app:` reaches an icon the app registered at boot with `canvas.icons.registerAppIcons` (declare the table as `pub const app_icons` on the app root so `native check` verifies the name against the model contract), and one `{binding}` defers the choice to model data - an unknown resolved name draws the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap; tint with `foreground`, size with `width`/`height` | | `media-surface` | media surface leaf | composites a texture produced OUTSIDE the widget tree (video decoder, camera, an external renderer like mpv) into the layout like any widget — clipped, z-ordered, rounded. `surface="{binding}"` (required) binds the model-owned u64 surface id a Zig-tier producer targets (`runtime.acquireMediaSurfaceProducer` pushes RGBA8 frames, latest-wins, paced by the presented-frame clock; 0 = unbound, draws nothing; usable ids are nonzero values below the reserved bit 63). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content). Texture contents are presentation chrome: goldens, reference screenshots, and session replay show the deterministic id-derived placeholder, never producer frames | +| `image` | runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id `Cmd.imageLoad` (TS) or `fx.loadImage`/`fx.registerImageBytes` (Zig) registered pixels under. `image="{binding}"` (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content) | | `markdown` | rendered markdown subtree | leaf; `source` is one `{binding}` — see "Markdown in markup" | | `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` | | `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron | @@ -188,7 +189,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit target or an element with `on-press`/`on-hold`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" | | `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) | -Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `image` (needs `ImageId` pixel references, runtime-registered — see the Images section), `icon_button` (`