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..2e81df1bb --- /dev/null +++ b/src/cli/cache.zig @@ -0,0 +1,279 @@ +//! 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: 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 +//! +//! 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/`). + +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"); +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 +/// 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 — 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 + // 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. 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, + 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 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); +} + +/// `--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.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()))); +} + +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 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. + 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..440363330 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,22 @@ 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")) { + // 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; } 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 +269,36 @@ 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), 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 or cache_clear) { + const root = cache_dir_arg orelse defaultCacheRoot(gpa, init) orelse { + 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); + 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; + // 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 +315,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 +328,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 +338,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 +351,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 +402,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"); 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 },