From a3c6560415630957cc0388e8a0ee0ef92584cb56 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Thu, 9 Jul 2026 22:03:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20ADR-0203=20stage=205=20?= =?UTF-8?q?=E2=80=94=20transparent=20on-disk=20compilation=20cache=20(clos?= =?UTF-8?q?es=20D-508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zwasm run --cache[=DIR] x.wasm` reuses the .cwasm artifact a previous run produced. Users keep deploying plain `.wasm` (the wasmtime Config::cache / wazero NewCompilationCacheWithDir model); the runtime keys artifacts by content hash in a versioned directory: /zwasm----/.cwasm - src/cli/cache.zig: SHA-256 content key; versioned subdir (every codegen-affecting knob lives in the dir name, so stale entries are silently unreachable — no config hash needed); temp + atomic-rename store; ANY cache-side error degrades to miss/no-store (EXEMPT-FALLBACK per ADR-0203 D5 — the cache can never make `run` fail); --cache-clear deletes this build's subdir only. - main.zig: --cache[=DIR] / --cache-clear flags; default root = platform user-cache dir (HOME/Library/Caches, XDG_CACHE_HOME|~/.cache, LOCALAPPDATA) from the environ map; a HIT swaps the artifact bytes in and the existing CWAS branch runs it through the full-fidelity load path — cache-hit == cache-miss by construction. - Components + explicit .cwasm inputs bypass the cache. Measured (tinygo_json, ReleaseSafe, hyperfine): 9.2 ms → 4.1 ms cold start (2.2x; user CPU 7.2 → 2.2 ms — parse/validate/codegen skipped). Unit tests: subdir naming, stable content key, miss→store→hit byte-identity, corrupt-entry loud rejection at the load gate, --cache-clear idempotency + re-populate. zig build test green. --- .dev/debt.yaml | 26 ------ .dev/handover.md | 17 +++- src/cli/cache.zig | 207 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli/main.zig | 88 ++++++++++++++++++-- src/zwasm.zig | 2 + 5 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 src/cli/cache.zig diff --git a/.dev/debt.yaml b/.dev/debt.yaml index 74d50ec09..ccbf25d61 100644 --- a/.dev/debt.yaml +++ b/.dev/debt.yaml @@ -3,32 +3,6 @@ # Refresh on every /continue resume — .claude/skills/continue/RESUME.md Step 0.5. entries: - - id: "D-508" - layer: "code" - status: "note" - description: |- - Front AOT-full-fidelity (was G-senior-gap G2; SCOPE CORRECTED 2026-07-09 - — the "loader already exists / not ADR-grade" premise is FALSE: the - .cwasm load path is a compute-only mini-runtime (no memory.grow — 3MB Go - modules die OOM on it; measured), so the cache rides the campaign's - full-runtime load path). **On-disk transparent compilation cache** — - zwasm recompiles every `run` (measured 2026-07-09: 3MB Go modules pay - ~110ms compile tax per cold start; tinygo_json 8.5→3.4ms with a - precompiled artifact). Peers: wasmtime `Config::cache` + wazero - `NewCompilationCacheWithDir` (versioned dir key). SHAPE (campaign phase - III ADR): key = SHA-256(module bytes) under dir - `zwasm---/`; value = `.cwasm`; hit loads into the - FULL runtime (cache-hit == cache-miss, D-510-diff-gated); temp+atomic - rename; silent miss on mismatch; eviction v1 = none + `--cache-clear` - (wazero parity), LRU later; opt-in `--cache[=dir]` CLI first, - embedding knob after. - first_raised: "2026-07-06" - last_reviewed: "2026-07-09" - refs: |- - .dev/meta_audits/2026-07-09-aot-full-fidelity-investigation.md - .dev/meta_audits/2026-07-06-senior-runtime-gap-analysis.md §2.4 - ~/Documents/OSS/wazero/cache.go:34-114 - front: AOT-full-fidelity - id: "D-509" layer: "code" status: "note" diff --git a/.dev/handover.md b/.dev/handover.md index 70e0d3304..21ceb5139 100644 --- a/.dev/handover.md +++ b/.dev/handover.md @@ -57,9 +57,20 @@ publish / cut over. No active campaign/bundle; no cron self-re-arm. RE-REGISTERED entries. **aot-diff 62/62 with ELIDED artifacts as the default output** · fuzz-diff green · Rosetta green. D-515 row (1) struck ((2) spec-corpus remains); ADR-0202 note: AOT elision ENABLED. -- **NEXT**: merge #139 → rebase stage 4 → critique → PR · stage 5 - (--cache, D-508 per ADR-0203 D5) · stage V retrospective → - **v2.2.0 release (user-directed)**. +- Stage 4 critique #8 (17/20) fixed in-commit (non-D1-host reject added: + ElidedArtifactNeedsGuardedHost; oob_guard_boundary added to aot corpus → + **63/63 MATCH**); PR #140 in CI. **Stage 5 CODE-COMPLETE + (develop/aot-stage5-cache, stacked)** — D-508: `zwasm run --cache[=DIR]` + + `--cache-clear`; src/cli/cache.zig (SHA-256 key, versioned dir + zwasm----, temp+atomic-rename, silent-miss + discipline w/ EXEMPT-FALLBACK per ADR-0203 D5); hit routes the artifact + through the CWAS full-fidelity path. **Measured: tinygo_json 9.2ms → + 4.1ms (2.2x; user CPU 7.2→2.2ms = codegen skipped)**. Unit tests + (subdir/key/miss-hit-corrupt-clear) green; D-508 row closed. +- **NEXT**: merge #140 → rebase stage 5 → critique → PR → merge · stage V + retrospective (debt reconcile · ADR-0203 status · bench record · + docs/README --cache mention · lessons) → **v2.2.0 release + (user-directed: version bump + tag → release.yml)**. ## Active front — G-senior-gap (2026-07-06, /continue entry point) diff --git a/src/cli/cache.zig b/src/cli/cache.zig new file mode 100644 index 000000000..0812b11f7 --- /dev/null +++ b/src/cli/cache.zig @@ -0,0 +1,207 @@ +//! Transparent on-disk compilation cache (ADR-0203 D5 / D-508). +//! +//! `zwasm run --cache[=DIR] x.wasm` keys the module by content hash and +//! reuses the `.cwasm` artifact a previous run produced — a cache HIT skips +//! parse/validate/codegen entirely and loads through the SAME full-fidelity +//! path as `zwasm run x.cwasm` (cache-hit == cache-miss by construction, +//! ADR-0203 D2; measured cold-start tax ~110 ms on 3 MB Go modules). +//! +//! Layout (the wazero model — versioned dir, no config hash: every +//! codegen-affecting knob lives in the directory name, so a version / arch / +//! OS / bounds-mode change makes old entries silently unreachable): +//! +//! /zwasm----/.cwasm +//! +//! Writes go to a `.tmp` sibling then atomic-rename (concurrent runs race +//! harmlessly — last writer wins with identical content). ANY cache-side +//! error is a silent miss + fresh compile: the cache can never make `run` +//! fail (ADR-0203 D5). Eviction v1 = none; `zwasm run --cache-clear` deletes +//! this build's versioned subdirectory only. +//! +//! Zone 3 (`src/cli/`). + +const std = @import("std"); +const builtin = @import("builtin"); + +const zwasm = @import("zwasm"); +const runner = @import("../engine/runner.zig"); +const produce = @import("../engine/codegen/aot/produce.zig"); + +/// `zwasm----` — the versioned cache +/// subdirectory. Bounds mode is the one codegen knob not implied by the +/// binary itself (`--engine`-independent; process-global, ADR-0202). +pub fn subdirName(buf: []u8) ![]u8 { + return std.fmt.bufPrint(buf, "zwasm-{s}-{s}-{s}-{s}", .{ + zwasm.version, + @tagName(builtin.target.cpu.arch), + @tagName(builtin.target.os.tag), + @tagName(runner.boundsChecksMode()), + }); +} + +/// SHA-256 of the module bytes, lowercase hex — the cache key. Content +/// hash only: everything else that affects codegen is in `subdirName`. +pub fn keyHex(wasm_bytes: []const u8) [64]u8 { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(wasm_bytes, &digest, .{}); + return std.fmt.bytesToHex(digest, .lower); +} + +/// Return `.cwasm` artifact bytes for `wasm_bytes` — from the cache when +/// present, otherwise compile + produce + best-effort store. Caller owns +/// the returned slice. Only compile/produce errors propagate; cache I/O +/// failures degrade to miss/no-store per the module doc. +pub fn lookupOrProduce( + gpa: std.mem.Allocator, + io: std.Io, + root: []const u8, + wasm_bytes: []const u8, +) ![]u8 { + var sub_buf: [96]u8 = undefined; + const sub = try subdirName(&sub_buf); + const key = keyHex(wasm_bytes); + const path = try std.fmt.allocPrint(gpa, "{s}/{s}/{s}.cwasm", .{ root, sub, &key }); + defer gpa.free(path); + const cwd = std.Io.Dir.cwd(); + + // HIT — hand the artifact bytes back; the CWAS run path validates the + // header (magic/version/arch) and re-links, so a stale or corrupt + // entry surfaces there as a load error, never as silent misbehaviour. + // A version-mismatched entry can't even be hit (versioned dir). + if (cwd.readFileAlloc(io, path, gpa, .limited(256 << 20))) |cached| { + return cached; + } else |_| { + // EXEMPT-FALLBACK: cache read failure = MISS by design (ADR-0203 D5 + // — the cache can never make `run` fail); we fall through to a + // fresh compile below. + } + + // MISS — fresh compile + produce (errors here are the caller's). + var compiled = try runner.compileWasmForAot(gpa, wasm_bytes); + const cwasm = blk: { + defer compiled.deinit(gpa); + break :blk try produce.produceFromCompiledWasm(gpa, &compiled, wasm_bytes); + }; + errdefer gpa.free(cwasm); + + // Best-effort store: mkdir -p + temp write + atomic rename. + store(gpa, io, root, sub, &key, cwasm) catch { + // EXEMPT-FALLBACK: cache write failure only loses the speedup for + // the NEXT run (ADR-0203 D5); this run proceeds with the artifact. + }; + return cwasm; +} + +fn store( + gpa: std.mem.Allocator, + io: std.Io, + root: []const u8, + sub: []const u8, + key: *const [64]u8, + cwasm: []const u8, +) !void { + const cwd = std.Io.Dir.cwd(); + const dir_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ root, sub }); + defer gpa.free(dir_path); + try cwd.createDirPath(io, dir_path); + // Unique-enough temp name: concurrent writers race on the SAME content + // (the key is a content hash), so a clobbered temp or double rename is + // harmless; rename is atomic on POSIX + NTFS. + const tmp_path = try std.fmt.allocPrint(gpa, "{s}/{s}.tmp", .{ dir_path, key }); + defer gpa.free(tmp_path); + const final_path = try std.fmt.allocPrint(gpa, "{s}/{s}.cwasm", .{ dir_path, key }); + defer gpa.free(final_path); + try cwd.writeFile(io, .{ .sub_path = tmp_path, .data = cwasm }); + try cwd.rename(tmp_path, cwd, final_path, io); +} + +/// `--cache-clear`: delete THIS build's versioned subdirectory (other +/// versions' entries are other builds' business). +pub fn clear(gpa: std.mem.Allocator, io: std.Io, root: []const u8) !void { + var sub_buf: [96]u8 = undefined; + const sub = try subdirName(&sub_buf); + const dir_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ root, sub }); + defer gpa.free(dir_path); + std.Io.Dir.cwd().deleteTree(io, dir_path) catch { + // EXEMPT-FALLBACK: nothing to clear / already gone is success for + // `--cache-clear` (idempotent; ADR-0203 D5). + }; +} + +// ============================================================ +// Tests +// ============================================================ + +const testing = std.testing; + +test "cache: subdirName carries version, arch, os, and bounds mode" { + var buf: [96]u8 = undefined; + const sub = try subdirName(&buf); + try testing.expect(std.mem.startsWith(u8, sub, "zwasm-")); + try testing.expect(std.mem.indexOf(u8, sub, zwasm.version) != null); + try testing.expect(std.mem.indexOf(u8, sub, @tagName(builtin.target.cpu.arch)) != null); + try testing.expect(std.mem.endsWith(u8, sub, @tagName(runner.boundsChecksMode()))); +} + +test "cache: keyHex is a stable content hash" { + const a = keyHex("hello"); + const b = keyHex("hello"); + const c = keyHex("hellp"); + try testing.expectEqualSlices(u8, &a, &b); + try testing.expect(!std.mem.eql(u8, &a, &c)); +} + +test "cache: miss produces + stores; hit returns identical artifact bytes; corrupt entry degrades to miss" { + // `() -> i32` returning 42, exported "f". + const wasm = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, + 0x02, 0x01, 0x00, 0x07, 0x05, 0x01, 0x01, 0x66, + 0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, + 0x2a, 0x0b, + }; + const io = testing.io; + const cwd = std.Io.Dir.cwd(); + const root = ".zig-cache/cache-test-root"; + // EXEMPT-FALLBACK: test-scratch cleanup — absent tree == already clean (ADR-0203 D5). + cwd.deleteTree(io, root) catch {}; + // EXEMPT-FALLBACK: test-scratch cleanup — absent tree == already clean (ADR-0203 D5). + defer cwd.deleteTree(io, root) catch {}; + + // MISS: compile + store. + const first = try lookupOrProduce(testing.allocator, io, root, &wasm); + defer testing.allocator.free(first); + try testing.expect(std.mem.eql(u8, first[0..4], "CWAS")); + + // The entry landed at the expected path. + var sub_buf: [96]u8 = undefined; + const sub = try subdirName(&sub_buf); + const key = keyHex(&wasm); + const path = try std.fmt.allocPrint(testing.allocator, "{s}/{s}/{s}.cwasm", .{ root, sub, &key }); + defer testing.allocator.free(path); + const stored = try cwd.readFileAlloc(io, path, testing.allocator, .limited(1 << 20)); + defer testing.allocator.free(stored); + try testing.expectEqualSlices(u8, first, stored); + + // HIT: byte-identical artifact without recompiling. + const second = try lookupOrProduce(testing.allocator, io, root, &wasm); + defer testing.allocator.free(second); + try testing.expectEqualSlices(u8, first, second); + + // Corrupt entry: the run path (parseHeader) rejects it loudly there; + // here the cache still RETURNS it (content opaque to the cache layer) — + // prove the CWAS run gate catches it instead of silent misbehaviour. + try cwd.writeFile(io, .{ .sub_path = path, .data = "CWASgarbage" }); + const corrupt = try lookupOrProduce(testing.allocator, io, root, &wasm); + defer testing.allocator.free(corrupt); + const load_compiled = @import("../engine/codegen/aot/load_compiled.zig"); + try testing.expectError(load_compiled.Error.TruncatedHeader, load_compiled.deserializeToCompiledWasm(testing.allocator, corrupt)); + + // --cache-clear removes the versioned subdir; the next lookup is a miss + // that recompiles and restores the entry. + try clear(testing.allocator, io, root); + try testing.expectError(error.FileNotFound, cwd.readFileAlloc(io, path, testing.allocator, .limited(1 << 20))); + const third = try lookupOrProduce(testing.allocator, io, root, &wasm); + defer testing.allocator.free(third); + try testing.expectEqualSlices(u8, first, third); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 63d3d136f..f9997845f 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -39,6 +39,7 @@ const build_options = @import("build_options"); const zwasm = @import("zwasm"); const cli_run = zwasm.cli.run; +const cli_cache = zwasm.cli.cache; const cli_compile = zwasm.cli.compile; const cli_dispatch = zwasm.cli.dispatch; const diag_print = zwasm.cli.diag_print; @@ -131,6 +132,9 @@ pub fn main(init: std.process.Init) !void { defer env_vals.deinit(gpa); // ADR-0179 #3a-4 / D-314 — `--fuel` / `--timeout` / `--max-memory`. var limits: cli_run.Limits = .{}; + var cache_enabled = false; + var cache_clear = false; + var cache_dir_arg: ?[]const u8 = null; var next_arg = arg_it.next(); while (next_arg) |a| { if (std.mem.eql(u8, a, "--invoke")) { @@ -235,10 +239,21 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); }; next_arg = arg_it.next(); + } else if (std.mem.eql(u8, a, "--cache") or std.mem.startsWith(u8, a, "--cache=")) { + // ADR-0203 D5 / D-508 — transparent compilation cache. + // `--cache` uses the platform default root; `--cache=DIR` + // overrides it. + cache_enabled = true; + if (std.mem.startsWith(u8, a, "--cache=")) cache_dir_arg = a["--cache=".len..]; + next_arg = arg_it.next(); + } else if (std.mem.eql(u8, a, "--cache-clear")) { + cache_enabled = true; + cache_clear = true; + next_arg = arg_it.next(); } else break; } const path_arg = next_arg orelse { - try printlnErr(io, "usage: zwasm run [--invoke ] [--engine ] [--dir [:]] [--env KEY=VAL] [--fuel ] [--timeout ] [--max-memory ] [--max-table-elements ] [args...]"); + try printlnErr(io, "usage: zwasm run [--invoke ] [--engine ] [--dir [:]] [--env KEY=VAL] [--fuel ] [--timeout ] [--max-memory ] [--max-table-elements ] [--cache[=DIR]] [--cache-clear] [args...]"); std.process.exit(2); }; const path = try gpa.dupe(u8, path_arg); @@ -253,6 +268,40 @@ pub fn main(init: std.process.Init) !void { }; defer gpa.free(bytes); + // ADR-0203 D5 / D-508 — transparent compilation cache. Only a + // plain core module routes through the cache (an explicit + // `.cwasm` input IS the artifact; components have no artifact + // format). On a hit the cached artifact replaces `bytes` and the + // CWAS branch below runs it through the full-fidelity load path + // — the same flow a miss's freshly-produced artifact takes. + var cache_bytes: ?[]u8 = null; + defer if (cache_bytes) |cb| gpa.free(cb); + if (cache_enabled and bytes.len >= 4 and + std.mem.eql(u8, bytes[0..4], "\x00asm") and bytes.len >= 8 and bytes[6] != 0x01) + { + const root = cache_dir_arg orelse defaultCacheRoot(gpa, init) orelse { + try printlnErr(io, "zwasm run: --cache could not resolve a cache directory (set HOME/XDG_CACHE_HOME/LOCALAPPDATA or pass --cache=DIR)"); + std.process.exit(2); + }; + defer if (cache_dir_arg == null) gpa.free(@constCast(root)); + if (cache_clear) try cli_cache.clear(gpa, io, root); + cache_bytes = cli_cache.lookupOrProduce(gpa, io, root, bytes) catch |e| { + const err = e; + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "zwasm run: cannot compile '{s}': {s}", .{ path, @errorName(err) }) catch "zwasm run: compile failed"; + try printlnErr(io, msg); + std.process.exit(1); + }; + } else if (cache_clear) { + const root = cache_dir_arg orelse defaultCacheRoot(gpa, init) orelse { + try printlnErr(io, "zwasm run: --cache-clear could not resolve a cache directory"); + std.process.exit(2); + }; + defer if (cache_dir_arg == null) gpa.free(@constCast(root)); + try cli_cache.clear(gpa, io, root); + } + const run_bytes: []const u8 = cache_bytes orelse bytes; + // Build argv for the WASI guest. Wasmtime's default is // argv[0] = wasm filename + any trailing args; mirror // that here so guests that print argv produce parity @@ -269,8 +318,8 @@ pub fn main(init: std.process.Init) !void { // start-function behaviour (cache-hit == cache-miss). The // pre-stage-3 refusals (limits, typed invoke args) are gone — // both are wired through the shared path now. - if (bytes.len >= 4 and std.mem.eql(u8, bytes[0..4], "CWAS")) { - const code = cli_run.runWasmJitCaptured(gpa, io, bytes, invoke_name, argv_list.items, preopen_list.items, env_keys.items, env_vals.items, limits, null, invoke_args) catch |err| { + if (run_bytes.len >= 4 and std.mem.eql(u8, run_bytes[0..4], "CWAS")) { + const code = cli_run.runWasmJitCaptured(gpa, io, run_bytes, invoke_name, argv_list.items, preopen_list.items, env_keys.items, env_vals.items, limits, null, invoke_args) catch |err| { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "zwasm run: cannot run '{s}': {s}", .{ path, @errorName(err) }) catch "zwasm run: .cwasm run failed"; try printlnErr(io, msg); @@ -282,7 +331,7 @@ pub fn main(init: std.process.Init) !void { // CM campaign D1-2 / D-306 — a component-layer module (preamble // version 0x0d, layer byte = 0x01) routes to the component host // (`runWasiP2Main`), not the core-module runner. stdio subset. - if (bytes.len >= 8 and std.mem.eql(u8, bytes[0..4], "\x00asm") and bytes[6] == 0x01) { + if (run_bytes.len >= 8 and std.mem.eql(u8, run_bytes[0..4], "\x00asm") and run_bytes[6] == 0x01) { if (comptime !@import("build_options").enable_component) { try printlnErr(io, "zwasm run: component support not compiled in (rebuild with -Dwasi=p2 or higher)"); std.process.exit(2); @@ -292,7 +341,7 @@ pub fn main(init: std.process.Init) !void { try printlnErr(io, "zwasm run: --fuel/--timeout/--max-memory are not wired for components yet (core modules only)"); std.process.exit(2); } - const code = cli_run.runComponentWasi(gpa, io, bytes, argv_list.items, preopen_list.items) catch |err| { + const code = cli_run.runComponentWasi(gpa, io, run_bytes, argv_list.items, preopen_list.items) catch |err| { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "zwasm run: cannot run component '{s}': {s}", .{ path, @errorName(err) }) catch "zwasm run: component run failed"; try printlnErr(io, msg); @@ -305,9 +354,9 @@ pub fn main(init: std.process.Init) !void { // D-477 — typed `--invoke NAME=ARGS` now also runs on the JIT engine // (marshalled through the generalized buffer-write thunk). const code = (if (engine_jit) - cli_run.runWasmJitCaptured(gpa, io, bytes, invoke_name, argv_list.items, preopen_list.items, env_keys.items, env_vals.items, limits, null, invoke_args) + cli_run.runWasmJitCaptured(gpa, io, run_bytes, invoke_name, argv_list.items, preopen_list.items, env_keys.items, env_vals.items, limits, null, invoke_args) else - cli_run.runWasmCapturedOpts(gpa, io, bytes, argv_list.items, null, invoke_name, preopen_list.items, env_keys.items, env_vals.items, invoke_args, limits)) catch |err| { + cli_run.runWasmCapturedOpts(gpa, io, run_bytes, argv_list.items, null, invoke_name, preopen_list.items, env_keys.items, env_vals.items, invoke_args, limits)) catch |err| { // Per ADR-0016 phase 1: prefer the structured // diagnostic when one was set; fall back to the // legacy `@errorName` form for unwired sites. @@ -356,6 +405,31 @@ pub fn main(init: std.process.Init) !void { try stdout.flush(); } +/// Platform default cache root for `--cache` (ADR-0203 D5): the user +/// cache directory + "/zwasm". Resolved from the environment the same way +/// wasmtime/wazero do; null when no suitable variable is set (the caller +/// surfaces a usage error suggesting `--cache=DIR`). Caller frees. +fn defaultCacheRoot(gpa: std.mem.Allocator, init: std.process.Init) ?[]const u8 { + const builtin = @import("builtin"); + switch (builtin.target.os.tag) { + .windows => { + const base = init.environ_map.get("LOCALAPPDATA") orelse return null; + return std.fmt.allocPrint(gpa, "{s}\\zwasm", .{base}) catch null; + }, + .macos => { + const home = init.environ_map.get("HOME") orelse return null; + return std.fmt.allocPrint(gpa, "{s}/Library/Caches/zwasm", .{home}) catch null; + }, + else => { + if (init.environ_map.get("XDG_CACHE_HOME")) |x| { + return std.fmt.allocPrint(gpa, "{s}/zwasm", .{x}) catch null; + } + const home = init.environ_map.get("HOME") orelse return null; + return std.fmt.allocPrint(gpa, "{s}/.cache/zwasm", .{home}) catch null; + }, + } +} + fn printlnErr(io: std.Io, msg: []const u8) !void { var stderr_buf: [512]u8 = undefined; var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); diff --git a/src/zwasm.zig b/src/zwasm.zig index 79c1e7bdd..0413102a9 100644 --- a/src/zwasm.zig +++ b/src/zwasm.zig @@ -278,6 +278,7 @@ pub const api = struct { }; pub const cli = struct { pub const run = @import("cli/run.zig"); + pub const cache = @import("cli/cache.zig"); pub const invoke_args = @import("cli/invoke_args.zig"); pub const compile = @import("cli/compile.zig"); pub const dispatch = @import("cli/dispatch.zig"); @@ -407,6 +408,7 @@ test { _ = @import("wasi/adapter.zig"); _ = @import("wasi/p2_sockets.zig"); _ = @import("cli/run.zig"); + _ = @import("cli/cache.zig"); _ = @import("cli/invoke_args.zig"); _ = @import("feature/component/decode.zig"); _ = @import("feature/component/types.zig"); From 4a17ceda77fd82b50bf82e97911229d12c7e46c1 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Thu, 9 Jul 2026 22:58:25 +0900 Subject: [PATCH 2/2] fix(cli): cache failure paths degrade, never fail the run (ADR-0203 D5, DA critique) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage-5 cache implemented the D5 silent-miss discipline for read errors only; the DA critique reproduced four ways a cache defect failed the run (corrupt entry x3 shapes, ZWASM_DEBUG instrumentation) plus an --engine interp override. All degrade now: - HIT gate: parseHeader (magic/version/arch) + section-bounds check before trusting an entry; a bad entry is deleted (self-heal) and recompiled — a corrupt cache can never brick the run. - Compile/produce refusal inside the cache path = cache BYPASS (fall through to normal dispatch), restoring `.auto`'s interp fallback and ZWASM_DEBUG runnability instead of exit(1). - Explicit `--engine interp` bypasses the cache entirely (D-496: the flag must keep forcing interp; the artifact is JIT code). - `--cache-clear` alone no longer implies caching this run. - store(): unique random temp suffix (a shared temp name let one writer's atomic rename publish another's partial bytes); artifacts above the 256 MiB read cap are not stored (never hittable). - Module doc states the trust model (cache-dir write access = native code execution as the user; wasmtime/wazero posture). - test-aot-diff: cache lanes (miss + hit vs fresh, 63/63) + interp- bypass probe + stored-entry check — the ADR-0203 D6 stage-5 ratchet. Claude-Session: https://claude.ai/code/session_01F2PGseEjGFR1uqid4FaXsm --- src/cli/cache.zig | 132 ++++++++++++++++++++++++++-------- src/cli/main.zig | 37 +++++----- test/aot/aot_process_diff.zig | 101 +++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 52 deletions(-) diff --git a/src/cli/cache.zig b/src/cli/cache.zig index 0812b11f7..2e81df1bb 100644 --- a/src/cli/cache.zig +++ b/src/cli/cache.zig @@ -6,17 +6,28 @@ //! path as `zwasm run x.cwasm` (cache-hit == cache-miss by construction, //! ADR-0203 D2; measured cold-start tax ~110 ms on 3 MB Go modules). //! -//! Layout (the wazero model — versioned dir, no config hash: every -//! codegen-affecting knob lives in the directory name, so a version / arch / -//! OS / bounds-mode change makes old entries silently unreachable): +//! Layout (the wazero model — versioned dir, no config hash: the +//! codegen-affecting knobs live in the directory name, so a version / arch / +//! OS / bounds-mode change makes old entries silently unreachable). Caveat: +//! dev builds between releases share the version string; `--cache-clear` is +//! the escape hatch (format-version drift is caught by the entry gate below). //! //! /zwasm----/.cwasm //! -//! Writes go to a `.tmp` sibling then atomic-rename (concurrent runs race -//! harmlessly — last writer wins with identical content). ANY cache-side -//! error is a silent miss + fresh compile: the cache can never make `run` -//! fail (ADR-0203 D5). Eviction v1 = none; `zwasm run --cache-clear` deletes -//! this build's versioned subdirectory only. +//! Failure discipline (ADR-0203 D5): ANY cache-side defect degrades — a +//! read failure or an entry failing the cheap gate (header magic / format +//! version / arch / section bounds) is a MISS (the bad entry is deleted, +//! self-heal); a store failure only loses the next run's speedup. The cache +//! can never make `run` fail. Writes go to a unique temp sibling then +//! atomic-rename, so a reader never sees a partially-written entry. +//! Eviction v1 = none; `zwasm run --cache-clear` deletes this build's +//! versioned subdirectory only. +//! +//! Trust model (the wasmtime/wazero posture): entries are native code +//! executed as the user — write access to the cache directory means +//! arbitrary code execution. User-owned dir (0755/644 via umask), no +//! content integrity beyond the entry gate; point `--cache=DIR` only at +//! trusted locations. //! //! Zone 3 (`src/cli/`). @@ -26,6 +37,11 @@ const builtin = @import("builtin"); const zwasm = @import("zwasm"); const runner = @import("../engine/runner.zig"); const produce = @import("../engine/codegen/aot/produce.zig"); +const format = @import("../engine/codegen/aot/format.zig"); + +/// Entry read cap AND store cap — an artifact too big to read back is +/// never hittable, so storing it would only burn a full rewrite per run. +const max_entry_bytes: usize = 256 << 20; /// `zwasm----` — the versioned cache /// subdirectory. Bounds mode is the one codegen knob not implied by the @@ -64,12 +80,16 @@ pub fn lookupOrProduce( defer gpa.free(path); const cwd = std.Io.Dir.cwd(); - // HIT — hand the artifact bytes back; the CWAS run path validates the - // header (magic/version/arch) and re-links, so a stale or corrupt - // entry surfaces there as a load error, never as silent misbehaviour. - // A version-mismatched entry can't even be hit (versioned dir). - if (cwd.readFileAlloc(io, path, gpa, .limited(256 << 20))) |cached| { - return cached; + // HIT — but only after the cheap entry gate (header + section bounds): + // a corrupt / truncated / format-drifted entry is DELETED (self-heal) + // and degrades to a miss, never to a failed run (ADR-0203 D5). + if (cwd.readFileAlloc(io, path, gpa, .limited(max_entry_bytes))) |cached| { + if (entryIsValid(cached)) return cached; + gpa.free(cached); + cwd.deleteFile(io, path) catch { + // EXEMPT-FALLBACK: self-heal delete is best-effort (ADR-0203 D5); + // the fresh compile below re-stores over the bad entry anyway. + }; } else |_| { // EXEMPT-FALLBACK: cache read failure = MISS by design (ADR-0203 D5 // — the cache can never make `run` fail); we fall through to a @@ -84,14 +104,48 @@ pub fn lookupOrProduce( }; errdefer gpa.free(cwasm); - // Best-effort store: mkdir -p + temp write + atomic rename. - store(gpa, io, root, sub, &key, cwasm) catch { + // Best-effort store: mkdir -p + temp write + atomic rename. An artifact + // above the read cap is skipped — it could never be hit back. + if (cwasm.len <= max_entry_bytes) store(gpa, io, root, sub, &key, cwasm) catch { // EXEMPT-FALLBACK: cache write failure only loses the speedup for // the NEXT run (ADR-0203 D5); this run proceeds with the artifact. }; return cwasm; } +/// Cheap HIT gate (ADR-0203 D5): header parses (magic / format version / +/// arch matches this binary) and every section lies inside the file — so a +/// corrupt, truncated, or format-drifted entry degrades to a miss WITHOUT +/// paying the full deserialize. Content beyond that stays the run path's +/// business (same trust boundary as running a `.cwasm` directly). +fn entryIsValid(cwasm: []const u8) bool { + const h = format.parseHeader(cwasm) catch return false; + const want_arch: u32 = switch (builtin.target.cpu.arch) { + .aarch64 => format.arch_arm64, + .x86_64 => format.arch_x86_64, + else => return false, + }; + if (h.arch != want_arch) return false; + const sections = [_][2]u32{ + .{ h.code_offset, h.code_size }, + .{ h.metadata_offset, h.metadata_size }, + .{ h.types_offset, h.types_size }, + .{ h.relocs_offset, h.relocs_size }, + .{ h.exports_offset, h.exports_size }, + .{ h.globals_offset, h.globals_size }, + .{ h.memory_init_offset, h.memory_init_size }, + .{ h.elem_offset, h.elem_size }, + .{ h.imports_offset, h.imports_size }, + .{ h.wasm_bytes_offset, h.wasm_bytes_size }, + .{ h.func_extras_offset, h.func_extras_size }, + .{ h.eh_offset, h.eh_size }, + }; + for (sections) |s| { + if (@as(u64, s[0]) + s[1] > cwasm.len) return false; + } + return true; +} + fn store( gpa: std.mem.Allocator, io: std.Io, @@ -104,14 +158,23 @@ fn store( const dir_path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ root, sub }); defer gpa.free(dir_path); try cwd.createDirPath(io, dir_path); - // Unique-enough temp name: concurrent writers race on the SAME content - // (the key is a content hash), so a clobbered temp or double rename is - // harmless; rename is atomic on POSIX + NTFS. - const tmp_path = try std.fmt.allocPrint(gpa, "{s}/{s}.tmp", .{ dir_path, key }); + // Unique temp name per writer: with a SHARED temp path, P1's atomic + // rename could publish P2's partially-written bytes to a third reader. + // Random suffix keeps writers disjoint; rename is atomic on POSIX + NTFS + // and concurrent finals carry identical content (key = content hash). + var rnd: [8]u8 = undefined; + io.random(&rnd); + const tmp_path = try std.fmt.allocPrint(gpa, "{s}/{s}.{x:0>16}.tmp", .{ + dir_path, key, std.mem.readInt(u64, &rnd, .little), + }); defer gpa.free(tmp_path); const final_path = try std.fmt.allocPrint(gpa, "{s}/{s}.cwasm", .{ dir_path, key }); defer gpa.free(final_path); try cwd.writeFile(io, .{ .sub_path = tmp_path, .data = cwasm }); + errdefer cwd.deleteFile(io, tmp_path) catch { + // EXEMPT-FALLBACK: temp-file cleanup after a failed rename is + // best-effort (ADR-0203 D5); a stray .tmp is inert garbage. + }; try cwd.rename(tmp_path, cwd, final_path, io); } @@ -138,8 +201,8 @@ test "cache: subdirName carries version, arch, os, and bounds mode" { var buf: [96]u8 = undefined; const sub = try subdirName(&buf); try testing.expect(std.mem.startsWith(u8, sub, "zwasm-")); - try testing.expect(std.mem.indexOf(u8, sub, zwasm.version) != null); - try testing.expect(std.mem.indexOf(u8, sub, @tagName(builtin.target.cpu.arch)) != null); + try testing.expect(std.mem.find(u8, sub, zwasm.version) != null); + try testing.expect(std.mem.find(u8, sub, @tagName(builtin.target.cpu.arch)) != null); try testing.expect(std.mem.endsWith(u8, sub, @tagName(runner.boundsChecksMode()))); } @@ -188,14 +251,23 @@ test "cache: miss produces + stores; hit returns identical artifact bytes; corru defer testing.allocator.free(second); try testing.expectEqualSlices(u8, first, second); - // Corrupt entry: the run path (parseHeader) rejects it loudly there; - // here the cache still RETURNS it (content opaque to the cache layer) — - // prove the CWAS run gate catches it instead of silent misbehaviour. - try cwd.writeFile(io, .{ .sub_path = path, .data = "CWASgarbage" }); - const corrupt = try lookupOrProduce(testing.allocator, io, root, &wasm); - defer testing.allocator.free(corrupt); - const load_compiled = @import("../engine/codegen/aot/load_compiled.zig"); - try testing.expectError(load_compiled.Error.TruncatedHeader, load_compiled.deserializeToCompiledWasm(testing.allocator, corrupt)); + // Corrupt entries degrade to a self-healed MISS (ADR-0203 D5 — the + // cache can never make `run` fail): each shape must come back as the + // recompiled, byte-identical artifact AND leave a healed entry on disk. + const corrupt_shapes = [_][]const u8{ + "CWASgarbage", // magic ok, header truncated + &([_]u8{0} ** 200), // no magic at all + first[0 .. first.len / 2], // valid header, truncated body + }; + for (corrupt_shapes) |shape| { + try cwd.writeFile(io, .{ .sub_path = path, .data = shape }); + const healed = try lookupOrProduce(testing.allocator, io, root, &wasm); + defer testing.allocator.free(healed); + try testing.expectEqualSlices(u8, first, healed); + const on_disk = try cwd.readFileAlloc(io, path, testing.allocator, .limited(1 << 20)); + defer testing.allocator.free(on_disk); + try testing.expectEqualSlices(u8, first, on_disk); + } // --cache-clear removes the versioned subdir; the next lookup is a miss // that recompiles and restores the entry. diff --git a/src/cli/main.zig b/src/cli/main.zig index f9997845f..440363330 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -247,7 +247,8 @@ pub fn main(init: std.process.Init) !void { if (std.mem.startsWith(u8, a, "--cache=")) cache_dir_arg = a["--cache=".len..]; next_arg = arg_it.next(); } else if (std.mem.eql(u8, a, "--cache-clear")) { - cache_enabled = true; + // Clear-only: caching this run still requires `--cache` + // (least surprise — "clear" must not imply "populate"). cache_clear = true; next_arg = arg_it.next(); } else break; @@ -271,34 +272,30 @@ pub fn main(init: std.process.Init) !void { // ADR-0203 D5 / D-508 — transparent compilation cache. Only a // plain core module routes through the cache (an explicit // `.cwasm` input IS the artifact; components have no artifact - // format). On a hit the cached artifact replaces `bytes` and the + // format), and an explicit `--engine interp` bypasses it (the + // artifact is JIT code — the flag must keep forcing interp, + // D-496). On a hit the cached artifact replaces `bytes` and the // CWAS branch below runs it through the full-fidelity load path // — the same flow a miss's freshly-produced artifact takes. var cache_bytes: ?[]u8 = null; defer if (cache_bytes) |cb| gpa.free(cb); - if (cache_enabled and bytes.len >= 4 and - std.mem.eql(u8, bytes[0..4], "\x00asm") and bytes.len >= 8 and bytes[6] != 0x01) - { + if (cache_enabled or cache_clear) { const root = cache_dir_arg orelse defaultCacheRoot(gpa, init) orelse { - try printlnErr(io, "zwasm run: --cache could not resolve a cache directory (set HOME/XDG_CACHE_HOME/LOCALAPPDATA or pass --cache=DIR)"); + try printlnErr(io, "zwasm run: --cache/--cache-clear could not resolve a cache directory (set HOME/XDG_CACHE_HOME/LOCALAPPDATA or pass --cache=DIR)"); std.process.exit(2); }; defer if (cache_dir_arg == null) gpa.free(@constCast(root)); if (cache_clear) try cli_cache.clear(gpa, io, root); - cache_bytes = cli_cache.lookupOrProduce(gpa, io, root, bytes) catch |e| { - const err = e; - var buf: [256]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, "zwasm run: cannot compile '{s}': {s}", .{ path, @errorName(err) }) catch "zwasm run: compile failed"; - try printlnErr(io, msg); - std.process.exit(1); - }; - } else if (cache_clear) { - const root = cache_dir_arg orelse defaultCacheRoot(gpa, init) orelse { - try printlnErr(io, "zwasm run: --cache-clear could not resolve a cache directory"); - std.process.exit(2); - }; - defer if (cache_dir_arg == null) gpa.free(@constCast(root)); - try cli_cache.clear(gpa, io, root); + const is_core_module = bytes.len >= 8 and + std.mem.eql(u8, bytes[0..4], "\x00asm") and bytes[6] != 0x01; + if (cache_enabled and is_core_module and limits.engine != .interp) { + // A compile/produce refusal (module the AOT producer + // can't serialize, ZWASM_DEBUG instrumentation, genuine + // module error) falls through to the normal dispatch — + // keeping `.auto`'s interp fallback and its diagnostics. + // EXEMPT-FALLBACK: cache-path failure = BYPASS, never a failed run (ADR-0203 D5). + cache_bytes = cli_cache.lookupOrProduce(gpa, io, root, bytes) catch null; + } } const run_bytes: []const u8 = cache_bytes orelse bytes; diff --git a/test/aot/aot_process_diff.zig b/test/aot/aot_process_diff.zig index 7e3091835..460df3800 100644 --- a/test/aot/aot_process_diff.zig +++ b/test/aot/aot_process_diff.zig @@ -129,6 +129,17 @@ pub fn main(init: std.process.Init) !void { try cwd.createDirPath(io, tmp_dir); defer cwd.deleteTree(io, tmp_dir) catch {}; const preopen_scratch = ".zig-cache/aot-diff-preopen"; + // Cache-lane scratch (ADR-0203 D6 stage-5 ratchet): one root shared + // across fixtures so hits exercise a real multi-entry directory; + // absolutized because the lanes run with per-fixture cwds. + const cache_root_rel = ".zig-cache/aot-diff-cache"; + cwd.deleteTree(io, cache_root_rel) catch {}; + try cwd.createDirPath(io, cache_root_rel); + defer cwd.deleteTree(io, cache_root_rel) catch {}; + const cache_root = try cwd.realPathFileAlloc(io, cache_root_rel, gpa); + defer gpa.free(cache_root); + const cache_flag = try std.fmt.allocPrint(gpa, "--cache={s}", .{cache_root}); + defer gpa.free(cache_flag); var total: u32 = 0; var matched: u32 = 0; @@ -222,21 +233,85 @@ pub fn main(init: std.process.Init) !void { }; defer lane_b.deinit(gpa); + // Lane C — transparent cache (ADR-0203 D5/D6): the first + // `--cache` run is a MISS (compile + store), the second a HIT + // (loads the stored artifact); both must byte-match lane A. + const lane_c_argv: []const []const u8 = if (preopen_abs) |p| + &.{ cli, "run", cache_flag, "--dir", p, entry.name } + else + &.{ cli, "run", cache_flag, entry.name }; + var lane_miss = runLane(gpa, io, lane_c_argv, corpus_dir) catch |err| { + try stdout.print("SKIP-SPAWN {s}: lane C miss: {s}\n", .{ entry.name, @errorName(err) }); + skipped_refused += 1; + continue; + }; + defer lane_miss.deinit(gpa); + var lane_hit = runLane(gpa, io, lane_c_argv, corpus_dir) catch |err| { + try stdout.print("SKIP-SPAWN {s}: lane C hit: {s}\n", .{ entry.name, @errorName(err) }); + skipped_refused += 1; + continue; + }; + defer lane_hit.deinit(gpa); + const equal = lane_a.exit == lane_b.exit and std.mem.eql(u8, lane_a.stdout, lane_b.stdout); + const cache_equal = lane_a.exit == lane_miss.exit and + std.mem.eql(u8, lane_a.stdout, lane_miss.stdout) and + lane_a.exit == lane_hit.exit and + std.mem.eql(u8, lane_a.stdout, lane_hit.stdout); + + // `--engine interp --cache` must BYPASS the cache (D-496: the + // explicit interp choice wins; the artifact is JIT code). Probe + // once: interp+cache == plain interp, and NOTHING is stored. + if (std.mem.eql(u8, entry.name, "compute_add.wasm")) { + const iroot_rel = ".zig-cache/aot-diff-cache-interp"; + cwd.deleteTree(io, iroot_rel) catch {}; + try cwd.createDirPath(io, iroot_rel); + defer cwd.deleteTree(io, iroot_rel) catch {}; + const iroot_abs = try cwd.realPathFileAlloc(io, iroot_rel, gpa); + defer gpa.free(iroot_abs); + const iflag = try std.fmt.allocPrint(gpa, "--cache={s}", .{iroot_abs}); + defer gpa.free(iflag); + var interp_ref = try runLane(gpa, io, &.{ cli, "run", "--engine", "interp", entry.name }, corpus_dir); + defer interp_ref.deinit(gpa); + var interp_cached = try runLane(gpa, io, &.{ cli, "run", "--engine", "interp", iflag, entry.name }, corpus_dir); + defer interp_cached.deinit(gpa); + var stored: u32 = 0; + var idir = try cwd.openDir(io, iroot_rel, .{ .iterate = true }); + defer idir.close(io); + var idir_it = idir.iterate(); + while (try idir_it.next(io)) |_| stored += 1; + if (interp_ref.exit != interp_cached.exit or + !std.mem.eql(u8, interp_ref.stdout, interp_cached.stdout) or stored != 0) + { + unexpected += 1; + try stdout.print( + "INTERP-CACHE-BYPASS-FAIL {s}: interp exit={d} vs interp+cache exit={d}, stored-entries={d} (want 0)\n", + .{ entry.name, interp_ref.exit, interp_cached.exit, stored }, + ); + } + } switch (expectationFor(entry.name)) { .match => { - if (equal) { + if (equal and cache_equal) { matched += 1; } else { unexpected += 1; - try stdout.print( + if (!equal) try stdout.print( "AOT-DIVERGE {s}: wasm(exit={d}, {d}B stdout) vs cwasm(exit={d}, {d}B stdout{s})\n", .{ entry.name, lane_a.exit, lane_a.stdout.len, lane_b.exit, lane_b.stdout.len, if (lane_b.crashed) ", CRASHED" else "", }, ); + if (!cache_equal) try stdout.print( + "CACHE-DIVERGE {s}: wasm(exit={d}, {d}B) vs cache-miss(exit={d}, {d}B) / cache-hit(exit={d}, {d}B)\n", + .{ + entry.name, lane_a.exit, lane_a.stdout.len, + lane_miss.exit, lane_miss.stdout.len, lane_hit.exit, + lane_hit.stdout.len, + }, + ); } }, .wrong_result => |reason| { @@ -270,6 +345,28 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); } + // The cache lanes must have actually STORED entries — a silently-broken + // store would still "match" above by recompiling on every run. + var stored_artifacts: u32 = 0; + { + var root_dir = try cwd.openDir(io, cache_root_rel, .{ .iterate = true }); + defer root_dir.close(io); + var root_it = root_dir.iterate(); + while (try root_it.next(io)) |sub| { + if (sub.kind != .directory) continue; + var sub_dir = try root_dir.openDir(io, sub.name, .{ .iterate = true }); + defer sub_dir.close(io); + var sub_it = sub_dir.iterate(); + while (try sub_it.next(io)) |e| { + if (std.mem.endsWith(u8, e.name, ".cwasm")) stored_artifacts += 1; + } + } + } + if (matched > 0 and stored_artifacts == 0) { + unexpected += 1; + try stdout.print("CACHE-STORE-FAIL: cache lanes matched but zero .cwasm entries were stored\n", .{}); + } + try stdout.print( "\naot_process_diff: {d} fixtures — {d} matched, {d} refused(skip), {d} expected-diverge, {d} unsound-reported, {d} UNEXPECTED, {d} ratchet-flips — GATING\n", .{ total, matched, skipped_refused, expected_diverged, unsound_reported, unexpected, ratchet_flips },