diff --git a/build.zig b/build.zig index dfa95ca0..c9928bd8 100644 --- a/build.zig +++ b/build.zig @@ -234,6 +234,20 @@ pub fn build(b: *std.Build) void { corpus_opts.addOption([]const u8, "name", name_filter); mod.addOptions("corpus_paths", corpus_opts); + // `zig build [test] -Dcomptime-flat` defaults comptime evaluation to the + // flat-memory VM (`src/ir/comptime_vm.zig`), with the legacy tagged interpreter + // as the per-eval fallback — the "swap behind a build flag" step of + // `current/PLAN-COMPILER-VM.md`. Default OFF (legacy). The `SX_COMPTIME_FLAT` + // env var enables it too (either turns it on); read in `emit_llvm.zig::init`. + const comptime_flat = b.option( + bool, + "comptime-flat", + "Default comptime evaluation to the flat-memory VM (legacy interp as fallback)", + ) orelse false; + const build_opts = b.addOptions(); + build_opts.addOption(bool, "comptime_flat", comptime_flat); + mod.addOptions("build_opts", build_opts); + const mod_tests = b.addTest(.{ .root_module = mod, }); diff --git a/current/CHECKPOINT-COMPILER-API.md b/current/CHECKPOINT-COMPILER-API.md index 58d451d3..c3ce4399 100644 --- a/current/CHECKPOINT-COMPILER-API.md +++ b/current/CHECKPOINT-COMPILER-API.md @@ -26,8 +26,22 @@ with ONE welded mechanism. Branch: `reify` (off `master`). Update after every st > breaks cross-compilation — host vs target layout — and loses the sandbox. A > flat-memory VM keeps both while getting native bytes + speed.) > -> **Next action:** execute Phase 0 of `PLAN-COMPILER-VM.md` (strip the weld machinery), -> then Phase 1 (flat-memory value model). Build/verify: `zig build && zig build test`. +> **Next action (2026-06-18):** Phase 1.final op-porting is essentially COMPLETE — the VM +> handles **36** real corpus const-inits (0 → 16 → 27 → 31 → 36), with only **2** fallbacks +> left, both principled (`intern` = the welded compiler-API fn, Phase 3; inline-asm global +> `1654`, never comptime-evaluable). Parity **688/688** (gate ON and OFF). The VM now covers +> scalars/control-flow/aggregates/strings/optionals/enums, calls+recursion, the implicit +> context + full allocator protocol, globals, and failables + return traces. BOTH comptime +> call sites (const-init + `#run` side-effects) route through the VM with legacy fallback. +> **The forward work is Phase 2 (bytecode) and Phase 3 (compiler-API on flat memory)**; flipping the VM to +> default + deleting the legacy path awaits those. See `PLAN-COMPILER-VM.md` Phase 1.final +> Status steps 7–10 (Phase 3 seed: `intern`/`text_of` native on the VM — `0626` handled). +> Build/verify: `zig build && zig build test` (688, gate OFF). Run the corpus ON the VM: +> `zig build test -Dcomptime-flat` (the build flag) OR env `SX_COMPTIME_FLAT=1`. Coverage +> trace: `SX_COMPTIME_FLAT_TRACE=1`. **Forward: Phase 3 — grow the compiler-API on the VM** +> (`find_type` / `register_struct` / reflection readers via `Vm.callCompilerFn`, then +> re-express `declare`/`define`/`type_info` as sx and delete the bespoke interp arms); +> Phase 2 (bytecode) is the orthogonal speed work. ### (superseded) prior weld resume Phase 1 done; Phase 2 welded structs were working via reflection + memory-order @@ -298,6 +312,122 @@ when reached (sentinels or accessor fns; see the design doc Risks). `List` growth; orthogonal, see `current/CHECKPOINT-METATYPE.md`.) ## Log +- **VM robustness — `Frame` bounds-check; lowering-time `#insert` wiring explored + reverted (2026-06-18).** + Explored wiring the VM at the LOWERING-time comptime site (`evalComptimeString`, the + `#insert` string fold). 12/13 `#insert` examples ran on the VM with parity, but `0737` + (an `#insert` of an unresolved `secret()`) CRASHED the VM (SIGABRT): lowering-time IR can + be malformed (a `ret Ref.none` from the unresolved name) and `Frame.get` panicked on the + out-of-range index. **Decision: reverted the lowering-time wiring** — unlike the emit-time + folds (fully lowered IR), lowering-time IR can be erroneous, and hardening the VM against + ALL malformed IR (every `ref_types[...]` / `aggType` access, not just `Frame`) is out of + scope here. The emit-time sites already give full corpus coverage. **KEPT** the defensive + fix regardless (CLAUDE.md "never crash"): `Frame.get`/`set` now bounds-check and flip a + `bad_ref` flag; the `run` loop bails (`badRef`) instead of panicking. Unit test added + (malformed `ret Ref.none` → bail, not crash). Parity **688/688** both ways. +- **Phase 3 SEED (VM plan) — compiler-call path: `intern`/`text_of` native on the VM (2026-06-18).** + `invoke` now dispatches a welded `compiler`-library fn (gated on `compiler_welded`) to + `Vm.callCompilerFn`, serviced NATIVELY on flat memory (no legacy `Interpreter`): + `intern(string)->StringId` reads the flat-memory string bytes and `internString`s into the + const-cast table (pool-only — doesn't touch type layout, so cached sizes stay valid); + `text_of(StringId)->string` materializes the pooled text back into flat memory. Unlocked + `0626`; the ONLY remaining const-init fallback is now the inline-asm global (`1654`). + Parity **688/688** (gate ON and OFF); unit test added. This is the mechanism Phase 3 grows + — the next compiler fns (`find_type`, `register_struct`, reflection readers) bind the same + way (flat-memory pointer in, handle/pointer out, no marshaling). +- **Phase 1.final step 9 (VM plan) — `-Dcomptime-flat` build flag (the "swap behind a build flag" step) (2026-06-18).** + Added the `-Dcomptime-flat` build option (build.zig → a `build_opts` options module on + `mod`; `emit_llvm.init` reads `build_opts.comptime_flat or SX_COMPTIME_FLAT env`). This is + the plan's "reach parity → swap behind a build flag → delete the old path" mechanism. + `zig build test -Dcomptime-flat` runs the FULL corpus on the VM (688/0). Verified the flag + toggles the binary: flag-built `sx` reports VM HANDLED with no env var; default-built does + not. Default OFF — `zig build test` unchanged (688/0). Env var still works for ad-hoc runs. + Next (forward): Phase 2 (bytecode) / Phase 3 (compiler-API on flat memory); eventual + default-flip + legacy deletion. +- **Phase 1.final step 8 (VM plan) — wire the `#run` side-effect path + trace-clear-on-fallback (2026-06-18).** + Wired the SECOND comptime call site (`runComptimeSideEffects`, top-level `#run ;`) + through `tryEval` with legacy fallback, mirroring the const-init fold. `tryEval` now + handles void/noreturn entries (→ `.void_val`) so a void side-effect doesn't bail at the + result conversion. **Fixed a trace-corruption** the new site exposed (`1035`): a + side-effect that pushes return-trace frames and then bails (e.g. on `print`) had the + legacy re-run DOUBLE-push them (`sx_trace_push` is a side effect on the shared buffer). + Both wiring sites now `sx_trace_clear()` right before the legacy fallback, discarding the + VM's partial pushes. **Parity 688/688** (gate ON and OFF). Most side-effects still bail + (print/global_addr/call_builtin) → legacy, but the path is now uniform. All comptime + evaluation routes through the VM-with-fallback. +- **Phase 1.final step 7 (VM plan) — is_comptime + failable/error cluster + signed-load fix; coverage 31→36 (2026-06-18).** + `is_comptime` → 1 (unlocked `1030`). Ported the failable/error-channel cluster (`1037` + escape, `1038` handled): `kindOf(error_set)→word`, `regToValue` bridges TUPLES (the + failable `(value…,tag)` shape `checkComptimeFailable` reads), `trace_frame` packs + `(func_id<<32|span.start)` from a new `call_stack` (pushed by invoke/runEntry), and + `sx_trace_push`/`sx_trace_clear` serviced NATIVELY (the VM calls the real sx_trace.c + functions linked into the compiler, so the return-trace buffer is populated identically + to legacy). raise/catch/or now run on the VM. **Surfaced + fixed a real GENERAL bug:** + `readField` was ZERO-extending signed sub-64-bit loads, so a stored `i32 -1` reloaded as + `0xFFFFFFFF` (+4.29e9) and `< 0` was false — silently hiding `raise error.Bad`; now + SIGN-extends `i8`/`i16`/`i32`/`isize` (gate-ON parity confirms it's a strict fix; unit + test added). VM HANDLES **36** corpus const-inits (was 31); **parity 688/688** (gate ON + and OFF). Only **2 fallbacks** remain, both principled: `intern` (`0626`, welded + compiler-API fn — Phase 3) + inline-asm global (`1654`). Forward work: Phase 2 (bytecode), + Phase 3 (compiler-API on flat memory). +- **Phase 1.final step 6 (VM plan) — real default context + call_indirect + func_ref + global_get; coverage 27→31 (2026-06-17).** + Per the user's direction ("the VM can set up a default context"), `runEntry` now + materializes the REAL default context instead of a zeroed one. The implicit-ctx param is + an opaque `*void`, so `materializeDefaultContext` finds the `__sx_default_context` global + and lays its initializer (`{ {null, alloc_fn, dealloc_fn}, null }`, the CAllocator thunk + func-refs) into flat memory via a new recursive `layoutConst`. With `func_ref` (function + value encoded `FuncId.index()+1`, reserving word 0 for the null fn-ptr) and + `call_indirect` (decode word → FuncId → dispatch; 0 → bail) ported, the whole allocator + protocol runs on the VM: + `context.allocator.alloc_bytes` → call_indirect → thunk → `CAllocator.alloc_bytes` → + `libc_malloc` → native flat malloc. Unlocked `0606` (string global). Also: `global_get` + lazily evaluates a comptime global's `comptime_func` (memoized) — unlocked `CT_CHAIN`; + field access (`fieldOffset`/`struct_get`) handles string/slice `{ptr@0,len@8}` fat + pointers (needed by `alloc_string`); `regToValue` maps function-typed words → `.func_ref` + (kept `1128`'s rejection byte-identical). Native `malloc` is still required (the thunk + bottoms out at it; a host pointer can't be used with flat-memory load/store). VM HANDLES + **31** corpus const-inits (was 27); **parity 688/688** (gate ON and OFF). Unit tests: + global_get, func_ref+call_indirect. Remaining fallbacks (7): `.unsupported` aggregates + (3× — `1037`/`1038`), extern/builtin `intern`+asm (2×), `trace_frame`, `is_comptime`. +- **Phase 1.final step 5 cont. (VM plan) — libc memory builtins + f32 fix; coverage 16→27 (2026-06-17).** + Identified the dominant fallback (`call to extern/builtin`) as **11× `malloc`** (0604) + + 1× `intern`. Modeled a curated set of libc MEMORY builtins natively on flat memory + (`Vm.callMemBuiltin`): `malloc`/`calloc` → `allocBytes` (16-aligned, 256-MiB cap → bail), + `free` → no-op, `memcpy`/`memmove`/`memset` on flat bytes — sandboxed (no host heap/dlsym), + target-aware; the computed result is byte-identical to legacy (which calls real libc). + This surfaced a **real latent f32 bug**: float registers hold f64 bits, but f32 MEMORY is + the 4-byte single — `readField`/`writeField` were truncating the f64 bits (writing zeros + for `1.0`); now they `@floatCast` on f32 load/store (mirrors legacy `storeAtRawPtr`). + Result: VM HANDLES **27** corpus const-inits (was 16); **parity 688/688** (gate ON and + OFF). Unit tests added (f32 round-trip; malloc → usable flat memory). Next: the `kindOf` + `.unsupported` aggregates (3×), `global_get` (2×), the rest. +- **Phase 1.final step 5 (VM plan) — implicit-context materialization; coverage 0→16 (2026-06-17).** + `tryEval` now MATERIALIZES the implicit ctx instead of skipping it: a `has_implicit_ctx` + comptime entry (sole param `*Context`) gets a zeroed `Context` of the right size/align + in flat memory, its address passed as arg 0. Const bodies that ignore the ctx run; a + body that uses the allocator hits unported `call_indirect` → bails → legacy. No func-ref + materialization needed (handled bodies don't read ctx contents; parity is the guard). + Fixed a real bug surfaced by the coverage pass: storing a `null` non-pointer optional + (the `null_addr` sentinel) into an aggregate slot OOB-bailed — `writeField` now ZEROES + the destination for a `null_addr` aggregate source (= none/empty); unit-test regression + added. Result: VM HANDLES **16** corpus const-inits (was 0); **parity 688/688 both + gate ON and OFF**. Next: port the ops the trace names — `call_builtin`/`compiler_call`/ + extern (13×, via the bridge), `kindOf` `.unsupported` aggregates (3×), `global_get` (2×), + func_ref / call_indirect / trace_frame / is_comptime. +- **Phase 1.final steps 1–4 (VM plan) — host wiring landed; coverage measured (2026-06-17).** + (1) **Hardening:** `Machine.readWord`/`writeWord`/`bytes` now return `error.OutOfBounds` + (null / out-of-range / oversized / overflow-safe) instead of `assert`-panicking; + `OutOfBounds` added to `Vm.Error`; `try` threaded through every helper + exec arm + the + bridge. New unit tests (accessor OOB returns; null-deref → `tryEval` null, not a crash). + (2) **Implicit context:** `tryEval` returns null for `has_implicit_ctx` funcs (legacy + fallback) — conservative; full ctx materialization deferred to step 5. (3) **Wiring:** + const-init fold in `emit_llvm.zig` `emitGlobals` is `(if comptime_flat) tryEval else + null) orelse interp.call(...)`, gated by env `SX_COMPTIME_FLAT` (read once into + `LLVMEmitter.comptime_flat`). Default OFF. (4) **Parity + coverage:** gate ON → full + corpus byte-identical (688, 0 failed) + manual 0605/0606/0607 byte-identical. + **Finding: 0 of 37 measured corpus const-inits are VM-handled — ALL are + `has_implicit_ctx`-gated.** Added a coverage-trace facility (`comptime_vm.last_bail_reason` + + env `SX_COMPTIME_FLAT_TRACE`). **Next: step 5 = implicit-context materialization** (the + unblocker), then port the deferred ops. 688 corpus green (gate OFF). - **Phase 1.final start (VM plan) — wiring entry point `tryEval` (2026-06-17).** `comptime_vm.tryEval(gpa, module, func_id) ?Value` runs a comptime function entirely on the VM, returns a legacy `Value` (deep-copied to `gpa`) or `null` to fall back. diff --git a/current/PLAN-COMPILER-VM.md b/current/PLAN-COMPILER-VM.md index 419caab4..0437de2e 100644 --- a/current/PLAN-COMPILER-VM.md +++ b/current/PLAN-COMPILER-VM.md @@ -142,6 +142,117 @@ host through it: 5. Grow coverage (port the deferred ops + `call_builtin`/`compiler_call` via the bridge) until the VM is the default and the legacy path is deleted. +**Status (2026-06-17): steps 1–4 DONE; step 5 = the next session.** +- **(1) Hardening — DONE.** `Machine.readWord`/`writeWord`/`bytes` return + `error.OutOfBounds` (null / out-of-range / oversized / overflow-safe) instead of + asserting. `OutOfBounds` added to `Vm.Error`; `try` threaded through + `readField`/`writeField`/`optHas`/`makeSlice`/`sliceLen`/`sliceData`/`elemAddr` and + every exec arm + the bridge. New unit tests: hardened-accessor OOB returns, and a + null-deref function → `tryEval` returns `null` (legacy fallback), not a panic. +- **(2) Implicit context — DONE (materialized, 2026-06-17 step 5).** Initially a + conservative skip; now `tryEval` MATERIALIZES the implicit ctx: a comptime entry with + `has_implicit_ctx` (whose sole param is the `*Context`) gets a zeroed `Context` of the + right size/align allocated in flat memory, its address passed as arg 0. The common + const body never reads the ctx; a body that USES the allocator loads a fn from it and + `call_indirect`s (unported) → bails → legacy. No func-ref materialization was needed: + handled bodies don't read the ctx contents, and gate-ON corpus parity (688, 0 failed) + empirically confirms no divergence. (A body that read+branched on a null allocator fn + could in principle diverge; none does — parity is the guard.) +- **(3) Wire one site — DONE.** Const-init fold in `emitGlobals` is `(if comptime_flat) + tryEval(...) else null) orelse interp.call(...)`. Gated by env `SX_COMPTIME_FLAT` + (a `LLVMEmitter.comptime_flat` field read once from `std.c.getenv` in `init`). + Default OFF → corpus unaffected (688 green). +- **(4) Parity + coverage — DONE.** Gate ON: full corpus byte-identical (688, 0 failed); + manual `sx run` of 0605/0606/0607/0608 byte-identical to gate-OFF. Coverage-trace + facility in place (`comptime_vm.last_bail_reason` + env `SX_COMPTIME_FLAT_TRACE`, + printing HANDLED / fallback+reason per init). +- **(5) Implicit-context materialization + memory builtins + f32 — DONE; op-porting CONTINUES.** + Coverage climbed **0 → 16 → 27** handled corpus const-inits (fallbacks 22 → 11); parity + stays **688/688** (gate ON and OFF) at every step. Landed, in order: implicit ctx + materialized (→16); `writeField` null-aggregate fix (storing a `null` non-pointer + optional `null_addr` sentinel into an aggregate slot OOB-bailed → now ZEROES the + destination = none/empty; unit-test regression); curated libc MEMORY builtins on flat + memory (`Vm.callMemBuiltin`: `malloc`/`calloc` → `allocBytes` 16-aligned & 256-MiB-capped, + `free` → no-op, `memcpy`/`memmove`/`memset` on flat bytes — sandboxed, target-aware, + result byte-identical to legacy; unlocked `0604`'s 11 comptime mallocs); and an **f32 + storage fix** (float registers hold f64 bits, but f32 memory is the 4-byte single — + `readField`/`writeField` now `@floatCast` instead of truncating the f64 bits, which had + written zeros for `1.0`; a real latent bug `0604` surfaced; unit tests added). +- **(6) Real default context + call_indirect + func_ref + global_get — DONE.** Coverage + **27 → 31** handled (fallbacks 11 → 7); parity stays **688/688** both gate ON and OFF. + Per the user's direction ("the VM can set up a default context"), `runEntry` now + materializes the REAL default context (not a zeroed one): the implicit-ctx param is an + opaque `*void`, so `materializeDefaultContext` finds the `__sx_default_context` global + and lays its initializer constant (`{ {null, alloc_fn, dealloc_fn}, null }`, carrying + the CAllocator thunk func-refs) into flat memory via a new recursive `layoutConst`. + With `func_ref` (a function value encoded as `FuncId.index() + 1` so word 0 stays + reserved for the NULL function pointer — `funcRefWord`/`funcRefToId`) and `call_indirect` + (decode the callee word → `FuncId` → dispatch; 0 → bail) ported, a comptime body + that allocates via `context.allocator` now runs ENTIRELY on the VM: `alloc_string` → + `context.allocator.alloc_bytes` → `call_indirect` → thunk → `CAllocator.alloc_bytes` → + `libc_malloc` → the VM's native flat-memory `malloc`. Unlocked `0606` (string global via + the allocator). Also: `global_get` lazily evaluates a comptime global's `comptime_func` + (memoized in `global_cache`) — unlocked `CT_CHAIN`; struct field access (`fieldOffset`/ + `struct_get`) now handles string/slice `{ptr@0,len@8}` fat pointers (needed by + `alloc_string`'s `s.ptr`/`s.len`); and `regToValue` maps a function-typed word back to + `.func_ref` so a func-ref result serializes identically to legacy (kept `1128`'s + rejection diagnostic byte-identical). Unit tests added (global_get, func_ref + + call_indirect). **Note: native `malloc` is still REQUIRED** — the CAllocator thunk + bottoms out at libc `malloc`, and the VM can't use a host pointer with flat-memory + load/store, so comptime `malloc` must allocate from flat memory. The default context + lets the allocator PROTOCOL run; native `malloc` is its final step. +- **(7) `is_comptime` + failable/error cluster + the signed-load fix — DONE.** Coverage + **31 → 36** handled (fallbacks 7 → 2); parity stays **688/688** both gate ON and OFF. + - **`is_comptime`** → always 1 on the VM (folds to false in compiled code). Unlocked `1030`. + - **Failable / error-channel cluster** (`1037` escape, `1038` handled): `kindOf(error_set) + → word` (a u32 tag id); `regToValue` now bridges TUPLES (the failable `(value…, tag)` + shape the host's `checkComptimeFailable` reads); `trace_frame` packs `(func_id<<32 | + span.start)` from a new `call_stack` (pushed by `invoke`/`runEntry`); and `sx_trace_push` + / `sx_trace_clear` are serviced NATIVELY (the VM calls the real sx_trace.c functions — + linked into the compiler — so the return-trace buffer the host reads is populated + identically to the legacy dlsym path). `raise`/`catch`/`or` all run on the VM now. + - **Signed sub-64-bit load fix (a real GENERAL bug the failable case surfaced):** + `readField` now SIGN-extends `i8`/`i16`/`i32`/`isize` loads (was zero-extending, so a + stored `i32 -1` reloaded as `0xFFFFFFFF` = +4.29e9 and `< 0` was false — which silently + hid `raise error.Bad`). Affects any negative signed sub-64-bit value stored & reloaded; + gate-ON corpus parity confirms it's a strict fix. Unit test added (+ failable tests + pass via 1037/1038 in the corpus). + - **Remaining fallbacks (2, both principled — the VM correctly stays on legacy):** + `intern` (`0626`, the welded compiler-API fn — Phase 3 re-homes it) and the inline-asm + global call (`1654`, never comptime-evaluable). Every other measured corpus const-init + is handled on the VM. + At this point the flat-memory VM handles essentially the entire real comptime corpus + (scalars, control flow, structs/tuples/arrays/slices/strings/optionals/enums, calls + + recursion, the implicit context + allocator protocol, globals, failables + return + traces). Phase 2 (bytecode) and Phase 3 (compiler-API on flat memory) are the forward + work; flipping the VM to default + deleting the legacy path awaits those. +- **(8) Wire the `#run` side-effect path; trace-clear-on-fallback — DONE.** The second + comptime call site (`emit_llvm.runComptimeSideEffects`, top-level `#run ;`) now + routes through `tryEval` with legacy fallback, like the const-init fold; `tryEval` yields + `.void_val` for a void/noreturn entry. Fixed a trace-corruption the new site exposed + (`1035`): a side-effect that pushes trace frames then bails (on `print`) had the legacy + re-run double-push them — both sites now `sx_trace_clear()` right before the legacy + fallback to discard the VM's partial pushes. Parity **688/688** both gate ON and OFF. All + comptime evaluation now routes through the VM-with-fallback (uniform). +- **(9) `-Dcomptime-flat` build flag — DONE (the "swap behind a build flag" step).** The VM + gate is now a build option (`build.zig` → a `build_opts` module on `mod`; `emit_llvm.init` + reads `build_opts.comptime_flat or SX_COMPTIME_FLAT env`), default OFF. `zig build test + -Dcomptime-flat` runs the FULL corpus on the VM (688/0) — the build-integrated parity + gate. Verified the flag toggles the binary (flag-built `sx` uses the VM with no env var; + default-built does not). This is the prerequisite to eventually making the VM default + + deleting the legacy path (which still awaits Phase 2/3 + broader confidence). +- **(10) Compiler-call path on the VM — `intern`/`text_of` native (Phase 3 SEED) — DONE.** + `invoke` now services a welded `compiler`-library function (the `compiler_welded` flag is + the safety boundary) via `Vm.callCompilerFn` — natively on flat memory, NO legacy + `Interpreter`: `intern(s: string) -> StringId` reads the string bytes from flat memory and + `internString`s into the (const-cast) table (pool-only, never touches type layout, so the + VM's cached sizes stay valid); `text_of(id) -> string` materializes the pooled text back + into flat memory as a fat pointer. Unlocked `0626` — the ONLY remaining const-init fallback + is now the inline-asm global (`1654`, genuinely not comptime-evaluable). Parity **688/688** + both gate ON and OFF; unit test added. This is the mechanism Phase 3 grows: the next + compiler functions (`find_type`, `register_struct`, the reflection readers) are added the + same way — flat-memory pointer in, handle/pointer out, no marshaling. + ### Phase 3 — Compiler-API on flat memory (resume the stream — no weld) With native-byte comptime values, re-home the compiler-API: diff --git a/src/ir/comptime_vm.test.zig b/src/ir/comptime_vm.test.zig index 1932ca9f..58cb1ca6 100644 --- a/src/ir/comptime_vm.test.zig +++ b/src/ir/comptime_vm.test.zig @@ -480,6 +480,61 @@ test "comptime_vm exec: non-pointer optional wrap/unwrap/has_value/coalesce" { try std.testing.expectEqual(@as(i64, 91), toI64(try v.run(&fb.func, &.{}))); } +test "comptime_vm exec: a negative i32 stored and reloaded stays negative (sign-extend)" { + // Regression (failable cluster): the legacy `.int` model is i64. Storing an + // i32 -1 writes 0xFFFFFFFF; the load must SIGN-extend (not zero-extend, which + // would read +4294967295 and make `< 0` false — the bug that hid `raise`). + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const i32ptr = table.intern(.{ .pointer = .{ .pointee = .i32 } }); + + // p := alloca i32; *p = -1; return (load p) < 0 ? 1 : 0 → 1 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.{ .alloca = .i32 }, i32ptr)); + const neg1 = fb.add(b0, inst(.{ .const_int = -1 }, .i32)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(neg1), .val_ty = .i32 } }, .void)); + const ld = fb.add(b0, inst(.{ .load = .{ .operand = ref(p) } }, .i32)); + const z = fb.add(b0, inst(.{ .const_int = 0 }, .i32)); + const lt = fb.add(b0, inst(.{ .cmp_lt = .{ .lhs = ref(ld), .rhs = ref(z) } }, .bool)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(lt) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 1), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: storing a null non-pointer optional into a slot reads back as none" { + // Regression for the implicit-ctx coverage pass: `y: ?i64 = null` lowers to a + // store of the `null_addr` optional sentinel into an aggregate slot. writeField + // must ZERO the slot (→ flag byte 0 → none), not memcpy from address 0 (OOB). + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const opt_i64 = table.intern(.{ .optional = .{ .child = .i64 } }); + const opt_ptr = table.intern(.{ .pointer = .{ .pointee = opt_i64 } }); + + // s := alloca ?i64; *s = null; return (load s) ?? 99 → 99 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const s = fb.add(b0, inst(.{ .alloca = opt_i64 }, opt_ptr)); + const n = fb.add(b0, inst(.const_null, opt_i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(s), .val = ref(n), .val_ty = opt_i64 } }, .void)); + const ld = fb.add(b0, inst(.{ .load = .{ .operand = ref(s) } }, opt_i64)); + const d = fb.add(b0, inst(.{ .const_int = 99 }, .i64)); + const co = fb.add(b0, inst(.{ .optional_coalesce = .{ .lhs = ref(ld), .rhs = ref(d) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(co) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 99), toI64(try v.run(&fb.func, &.{}))); +} + test "comptime_vm exec: pointer optional (null == 0)" { const alloc = std.testing.allocator; var table = types.TypeTable.init(alloc); @@ -570,6 +625,181 @@ test "comptime_vm exec: deref a pointer; addr_of passes through a struct address try std.testing.expectEqual(@as(i64, 80), toI64(try vm_.run(&fb.func, &.{}))); } +test "comptime_vm exec: f32 store/load round-trips through 4-byte memory" { + // Float registers hold f64 bits; f32 memory is the 4-byte IEEE-754 single. + // Regression: storing an f32 must @floatCast (NOT truncate the f64 bits — that + // wrote zeros for 1.0, since 1.0f64 = 0x3FF0000000000000, low 4 bytes = 0). + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const f32ptr = table.intern(.{ .pointer = .{ .pointee = .f32 } }); + + // p := alloca f32; *p = 1.0; return int(load p) → 1 (was 0 under the bug) + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.{ .alloca = .f32 }, f32ptr)); + const c = fb.add(b0, inst(.{ .const_float = 1.0 }, .f32)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(c), .val_ty = .f32 } }, .void)); + const l = fb.add(b0, inst(.{ .load = .{ .operand = ref(p) } }, .f32)); + const i = fb.add(b0, inst(.{ .float_to_int = .{ .operand = ref(l), .from = .f32, .to = .i64 } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(i) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 1), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: malloc builtin gives usable flat memory; free is a no-op" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + const u8ptr = module.types.intern(.{ .many_pointer = .{ .element = .u8 } }); + const u8single = module.types.intern(.{ .pointer = .{ .pointee = .u8 } }); + + // extern malloc(size: usize) -> [*]u8 (FuncId 0, no body) + const malloc_params = [_]Function.Param{.{ .name = module.types.internString("size"), .ty = .usize }}; + var mfb = Fb.init(alloc, &malloc_params, u8ptr); + mfb.func.is_extern = true; + mfb.func.name = module.types.internString("malloc"); + const malloc_id = module.addFunction(mfb.func); + + // extern free(p: [*]u8) (FuncId 1, no body) + const free_params = [_]Function.Param{.{ .name = module.types.internString("p"), .ty = u8ptr }}; + var ffb = Fb.init(alloc, &free_params, .void); + ffb.func.is_extern = true; + ffb.func.name = module.types.internString("free"); + const free_id = module.addFunction(ffb.func); + + // main(): buf := malloc(8); buf[3] = 0x42; r := buf[3]; free(buf); return r → 66 + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const sz = fb.add(b0, inst(.{ .const_int = 8 }, .usize)); + const margs = [_]Ref{ref(sz)}; + const buf = fb.add(b0, inst(.{ .call = .{ .callee = malloc_id, .args = &margs } }, u8ptr)); + const idx = fb.add(b0, inst(.{ .const_int = 3 }, .i64)); + const g = fb.add(b0, inst(.{ .index_gep = .{ .lhs = ref(buf), .rhs = ref(idx) } }, u8single)); + const val = fb.add(b0, inst(.{ .const_int = 0x42 }, .u8)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(g), .val = ref(val), .val_ty = .u8 } }, .void)); + const idx2 = fb.add(b0, inst(.{ .const_int = 3 }, .i64)); + const r = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(buf), .rhs = ref(idx2) } }, .u8)); + const fargs = [_]Ref{ref(buf)}; + _ = fb.add(b0, inst(.{ .call = .{ .callee = free_id, .args = &fargs } }, .void)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(r) } }, .void)); + const main_id = module.addFunction(fb.func); + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 66), toI64(try v.run(module.getFunction(main_id), &.{}))); +} + +test "comptime_vm exec: global_get evaluates a comptime global (lazy + cached)" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // fn base() -> i64 { return 25 } (FuncId 0) — the global's comptime_func + var bf = Fb.init(alloc, &.{}, .i64); + const bfb = bf.block(&.{}); + const c25 = bf.add(bfb, inst(.{ .const_int = 25 }, .i64)); + _ = bf.add(bfb, inst(.{ .ret = .{ .operand = ref(c25) } }, .void)); + const base_id = module.addFunction(bf.func); + + // global G :: comptime base() (GlobalId 0) + const g = module.addGlobal(.{ .name = module.types.internString("G"), .ty = .i64, .comptime_func = base_id }); + + // fn main() -> i64 { return G + G + 5 } → 25 + 25 + 5 = 55 (second read is cached) + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .global_get = g }, .i64)); + const b = fb.add(b0, inst(.{ .global_get = g }, .i64)); + const five = fb.add(b0, inst(.{ .const_int = 5 }, .i64)); + const s1 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(a), .rhs = ref(b) } }, .i64)); + const s2 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(s1), .rhs = ref(five) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s2) } }, .void)); + const main_id = module.addFunction(fb.func); + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 55), toI64(try v.run(module.getFunction(main_id), &.{}))); +} + +test "comptime_vm exec: compiler-fn intern/text_of round-trip (native, no legacy interp)" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // extern intern(s: string) -> u32 [compiler] (FuncId 0, no body) + const ip = [_]Function.Param{.{ .name = module.types.internString("s"), .ty = .string }}; + var ifb = Fb.init(alloc, &ip, .u32); + ifb.func.is_extern = true; + ifb.func.compiler_welded = true; + ifb.func.name = module.types.internString("intern"); + const intern_id = module.addFunction(ifb.func); + + // extern text_of(id: u32) -> string [compiler] (FuncId 1, no body) + const tp = [_]Function.Param{.{ .name = module.types.internString("id"), .ty = .u32 }}; + var tfb = Fb.init(alloc, &tp, .string); + tfb.func.is_extern = true; + tfb.func.compiler_welded = true; + tfb.func.name = module.types.internString("text_of"); + const textof_id = module.addFunction(tfb.func); + + // main(): return length(text_of(intern("hello"))) → 5 + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const s = fb.add(b0, inst(.{ .const_string = module.types.internString("hello") }, .string)); + const sargs = [_]Ref{ref(s)}; + const id = fb.add(b0, inst(.{ .call = .{ .callee = intern_id, .args = &sargs } }, .u32)); + const iargs = [_]Ref{ref(id)}; + const back = fb.add(b0, inst(.{ .call = .{ .callee = textof_id, .args = &iargs } }, .string)); + const len = fb.add(b0, inst(.{ .length = .{ .operand = ref(back) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(len) } }, .void)); + const main_id = module.addFunction(fb.func); + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 5), toI64(try v.run(module.getFunction(main_id), &.{}))); +} + +test "comptime_vm exec: func_ref + call_indirect dispatch" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // fn dbl(x) = x * 2 (FuncId 0) + const dbl_params = [_]Function.Param{.{ .name = dummy, .ty = .i64 }}; + var db = Fb.init(alloc, &dbl_params, .i64); + const dbb = db.block(&.{}); + const two = db.add(dbb, inst(.{ .const_int = 2 }, .i64)); + const prod = db.add(dbb, inst(.{ .mul = .{ .lhs = ref(0), .rhs = ref(two) } }, .i64)); + _ = db.add(dbb, inst(.{ .ret = .{ .operand = ref(prod) } }, .void)); + const dbl_id = module.addFunction(db.func); + + // fn main() = call_indirect(func_ref(dbl), [21]) → 42 + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const fr = fb.add(b0, inst(.{ .func_ref = dbl_id }, .i64)); + const c21 = fb.add(b0, inst(.{ .const_int = 21 }, .i64)); + const cargs = [_]Ref{ref(c21)}; + const r = fb.add(b0, inst(.{ .call_indirect = .{ .callee = ref(fr), .args = &cargs } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(r) } }, .void)); + const main_id = module.addFunction(fb.func); + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 42), toI64(try v.run(module.getFunction(main_id), &.{}))); +} + test "comptime_vm exec: direct call to another function" { const alloc = std.testing.allocator; var module = Module.init(alloc); @@ -761,8 +991,8 @@ test "comptime_vm: writeWord/readWord round-trip at each scalar size" { const vals = [_]u64{ 0xAB, 0xBEEF, 0xDEADBEEF, 0x0123456789ABCDEF }; for (sizes, vals) |size, val| { const addr = m.allocBytes(size, size); - m.writeWord(addr, size, val); - try std.testing.expectEqual(val, m.readWord(addr, size)); + try m.writeWord(addr, size, val); + try std.testing.expectEqual(val, try m.readWord(addr, size)); } } @@ -773,8 +1003,8 @@ test "comptime_vm: writeWord truncates to size and readWord zero-extends" { // Write a full 64-bit word's worth of bits through a 1-byte store: only the // low byte lands; the read zero-extends it. const addr = m.allocBytes(1, 1); - m.writeWord(addr, 1, 0xFFFF_FF42); - try std.testing.expectEqual(@as(u64, 0x42), m.readWord(addr, 1)); + try m.writeWord(addr, 1, 0xFFFF_FF42); + try std.testing.expectEqual(@as(u64, 0x42), try m.readWord(addr, 1)); } test "comptime_vm: bytes() view reflects word writes (little-endian)" { @@ -782,14 +1012,70 @@ test "comptime_vm: bytes() view reflects word writes (little-endian)" { defer m.deinit(); const addr = m.allocBytes(4, 4); - m.writeWord(addr, 4, 0xDEADBEEF); - const view = m.bytes(addr, 4); + try m.writeWord(addr, 4, 0xDEADBEEF); + const view = try m.bytes(addr, 4); try std.testing.expectEqual(@as(u8, 0xEF), view[0]); try std.testing.expectEqual(@as(u8, 0xBE), view[1]); try std.testing.expectEqual(@as(u8, 0xAD), view[2]); try std.testing.expectEqual(@as(u8, 0xDE), view[3]); } +test "comptime_vm: a malformed operand ref (Ref.none) bails, not a panic" { + // A `ret` whose operand is `Ref.none` (0xFFFFFFFF) — the kind of malformed IR + // an unresolved name leaves behind. `Frame.get` must flip `bad_ref` and the run + // must bail (error.Unsupported), never index out of bounds and panic. + var fb = Fb.init(std.testing.allocator, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = Ref.none } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + try std.testing.expectError(error.Unsupported, v.run(&fb.func, &.{})); +} + +test "comptime_vm: hardened accessors return OutOfBounds, not a panic" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + const addr = m.allocBytes(8, 8); + + // Null address (reserved guard) → OutOfBounds on every accessor. + try std.testing.expectError(error.OutOfBounds, m.readWord(vm.null_addr, 8)); + try std.testing.expectError(error.OutOfBounds, m.writeWord(vm.null_addr, 8, 0)); + try std.testing.expectError(error.OutOfBounds, m.bytes(vm.null_addr, 4)); + + // Past the end of allocated memory → OutOfBounds. + const past = m.mark() + 64; + try std.testing.expectError(error.OutOfBounds, m.readWord(@intCast(past), 1)); + try std.testing.expectError(error.OutOfBounds, m.bytes(@intCast(past), 1)); + + // Straddling the end (last valid byte + an oversized read) → OutOfBounds. + try std.testing.expectError(error.OutOfBounds, m.readWord(addr + 4, 8)); + + // A zero-length view is always valid (no memory touched), even at null. + try std.testing.expectEqual(@as(usize, 0), (try m.bytes(vm.null_addr, 0)).len); +} + +test "comptime_vm tryEval: deref of a null pointer bails (null, not a crash)" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + const i64ptr = module.types.intern(.{ .pointer = .{ .pointee = .i64 } }); + + // fn bad() -> i64 { p := (null : *i64); return p.* } → reads through addr 0. + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.const_null, i64ptr)); + const d = fb.add(b0, inst(.{ .deref = .{ .operand = ref(p) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(d) } }, .void)); + const bad_id = module.addFunction(fb.func); + + // The hardened accessors turn the null deref into error.OutOfBounds → run + // bails → tryEval returns null (legacy fallback), NOT a debug panic. + try std.testing.expect(vm.tryEval(alloc, &module, bad_id) == null); +} + test "comptime_vm: mark/reset reclaims the stack region" { var m = vm.Machine.init(std.testing.allocator); defer m.deinit(); diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index 7771369f..d49276e4 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -35,6 +35,15 @@ const Function = inst_mod.Function; const Module = mod_mod.Module; const OpTag = std.meta.Tag(inst_mod.Op); const TypeId = types.TypeId; +const FuncId = inst_mod.FuncId; + +// The error return-trace buffer (sx_trace.c, linked into the compiler) — the same +// one emit_llvm reads after a `#run` to render the comptime escape trace. A +// comptime failable that raises emits `sx_trace_push(trace_frame())` as it unwinds; +// the VM services those calls natively so the trace populates identically to legacy. +extern fn sx_trace_push(frame: u64) void; +extern fn sx_trace_clear() void; +const Span = inst_mod.Span; /// A byte offset into the machine's flat memory. `null_addr` (0) is reserved as a /// never-allocated sentinel, so a zeroed register reads as null rather than a @@ -90,36 +99,40 @@ pub const Machine = struct { } /// Read a `size`-byte (1/2/4/8) little-endian scalar at `addr` into a register - /// word (zero-extended). Bounds- and null-checked. - pub fn readWord(self: *const Machine, addr: Addr, size: usize) Reg { - const a: usize = @intCast(addr); - std.debug.assert(addr != null_addr); - std.debug.assert(a + size <= self.mem.items.len); - std.debug.assert(size <= 8); + /// word (zero-extended). Bounds- and null-checked: a null / out-of-range / + /// oversized access returns `error.OutOfBounds` (NOT a debug panic) so a + /// malformed comptime run BAILS to the legacy fallback instead of crashing the + /// compiler. This is the safety contract `tryEval` relies on for arbitrary funcs. + pub fn readWord(self: *const Machine, addr: Addr, size: usize) error{OutOfBounds}!Reg { + if (addr == null_addr or size > 8) return error.OutOfBounds; + const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds; + if (a >= self.mem.items.len or size > self.mem.items.len - a) return error.OutOfBounds; var buf: [8]u8 = @splat(0); @memcpy(buf[0..size], self.mem.items[a .. a + size]); return std.mem.readInt(u64, &buf, .little); } /// Write the low `size` bytes (1/2/4/8) of register word `val` little-endian - /// at `addr`. Bounds- and null-checked. - pub fn writeWord(self: *Machine, addr: Addr, size: usize, val: Reg) void { - const a: usize = @intCast(addr); - std.debug.assert(addr != null_addr); - std.debug.assert(a + size <= self.mem.items.len); - std.debug.assert(size <= 8); + /// at `addr`. Bounds- and null-checked → `error.OutOfBounds` (not a panic). + pub fn writeWord(self: *Machine, addr: Addr, size: usize, val: Reg) error{OutOfBounds}!void { + if (addr == null_addr or size > 8) return error.OutOfBounds; + const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds; + if (a >= self.mem.items.len or size > self.mem.items.len - a) return error.OutOfBounds; var buf: [8]u8 = undefined; std.mem.writeInt(u64, &buf, val, .little); @memcpy(self.mem.items[a .. a + size], buf[0..size]); } /// A mutable byte view of `len` bytes at `addr` (for aggregate copies / slice - /// payloads). Bounds- and null-checked. The slice is invalidated by any - /// subsequent `allocBytes` that grows the backing — re-fetch after allocating. - pub fn bytes(self: *Machine, addr: Addr, len: usize) []u8 { - const a: usize = @intCast(addr); - std.debug.assert(addr != null_addr); - std.debug.assert(a + len <= self.mem.items.len); + /// payloads). Bounds- and null-checked → `error.OutOfBounds` (not a panic). A + /// zero-length view is always valid (no memory is touched). The slice is + /// invalidated by any subsequent `allocBytes` that grows the backing — re-fetch + /// after allocating. + pub fn bytes(self: *Machine, addr: Addr, len: usize) error{OutOfBounds}![]u8 { + if (len == 0) return self.mem.items[0..0]; + if (addr == null_addr) return error.OutOfBounds; + const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds; + if (a >= self.mem.items.len or len > self.mem.items.len - a) return error.OutOfBounds; return self.mem.items[a .. a + len]; } }; @@ -133,6 +146,11 @@ pub const Machine = struct { pub const Frame = struct { regs: []Reg, gpa: std.mem.Allocator, + /// Set when `get`/`set` is handed an out-of-range Ref index — a malformed IR + /// (e.g. a `ret Ref.none` left by an unresolved name during LOWERING-time + /// comptime eval). The `run` loop checks it after each instruction and bails + /// (→ legacy fallback), so the VM never panics on imperfect IR. + bad_ref: bool = false, pub fn init(gpa: std.mem.Allocator, num_regs: usize) Frame { const regs = gpa.alloc(Reg, num_regs) catch @panic("comptime VM: out of memory (frame regs)"); @@ -144,36 +162,68 @@ pub const Frame = struct { self.gpa.free(self.regs); } - pub fn get(self: *const Frame, ref_index: usize) Reg { + pub fn get(self: *Frame, ref_index: usize) Reg { + if (ref_index >= self.regs.len) { + self.bad_ref = true; + return 0; + } return self.regs[ref_index]; } pub fn set(self: *Frame, ref_index: usize, word: Reg) void { + if (ref_index >= self.regs.len) { + self.bad_ref = true; + return; + } self.regs[ref_index] = word; } }; +/// Why the most recent `tryEval` returned `null` (bailed to the legacy +/// interpreter) — the bail `detail` (op name / one-line reason), or a fixed string +/// for the structural skips. Mirrors the legacy interp's `last_bail_detail`; the +/// host reads it under a coverage-trace gate to learn what to port next. Cleared at +/// the top of every `tryEval`; meaningful only when `tryEval` returned `null`. +pub var last_bail_reason: ?[]const u8 = null; + /// Wiring entry point: try to evaluate comptime function `func_id` entirely on the /// flat-memory VM and return its result as a legacy `Value`, or `null` if the VM /// can't handle it (unsupported op, no body, or any bail) — the caller then falls /// back to the legacy interpreter. The result is deep-copied into `gpa`, so it /// outlives the VM's flat memory (freed here on return). /// -/// SAFETY NOTE (host wiring prerequisite): the VM's memory accessors currently -/// `assert` on a null/out-of-bounds address (a debug panic), so this is only safe -/// for functions whose every access is well-formed. Before routing ARBITRARY host -/// comptime functions through here, harden `Machine.readWord`/`writeWord`/`bytes` -/// to return `error.OutOfBounds` instead of asserting — then a malformed run bails -/// (→ null → legacy fallback) rather than crashing the compiler. +/// Safe for ARBITRARY host comptime functions: the `Machine` accessors are +/// hardened to return `error.OutOfBounds` (not a debug panic) on a null/out-of- +/// range/oversized access, so a malformed run bails to `null` (→ legacy fallback) +/// rather than crashing the compiler. On a bail, `last_bail_reason` names the cause. pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.FuncId) ?Value { + last_bail_reason = null; const func = module.getFunction(func_id); - if (func.is_extern or func.blocks.items.len == 0) return null; + if (func.is_extern or func.blocks.items.len == 0) { + last_bail_reason = "extern / no body"; + return null; + } var vm = Vm.init(gpa); defer vm.deinit(); vm.table = &module.types; vm.module = module; - const reg = vm.run(func, &.{}) catch return null; - return vm.regToValue(gpa, &module.types, reg, func.ret) catch null; + + // `runEntry` materializes the implicit `*Context` (a comptime const-init / + // `#run` wrapper is nullary in user args, so the implicit ctx is its sole + // param) as a zeroed Context in flat memory and runs. The common const body + // never reads the ctx; one that uses the allocator hits unported + // `call_indirect` → bails → legacy. Gate-ON corpus parity validates this. + const reg = vm.runEntry(func_id) catch |err| { + last_bail_reason = vm.detail orelse @errorName(err); + return null; + }; + // A void/noreturn entry (a `#run ;` side-effect) produces no value — + // `regToValue` would bail on the void type, so yield `.void_val` directly. + if (func.ret == .void or func.ret == .noreturn) return .void_val; + return vm.regToValue(gpa, &module.types, reg, func.ret) catch |err| { + last_bail_reason = vm.detail orelse @errorName(err); + return null; + }; } // ── Executor ──────────────────────────────────────────────────────────────── @@ -186,12 +236,27 @@ pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod. // the declared width), float math is f64. Memory/aggregate/call ops are not ported // yet — they bail loudly (`error.Unsupported` + `detail`), never silently. -pub const Error = error{ DivisionByZero, TypeError, Unsupported }; +pub const Error = error{ DivisionByZero, TypeError, Unsupported, OutOfBounds }; fn isFloat(ty: TypeId) bool { return ty == .f32 or ty == .f64; } +/// A signed integer type narrower-or-equal to 64 bits — its loaded bytes must be +/// SIGN-extended into the register (the legacy `.int` model is i64). +fn isSignedInt(ty: TypeId) bool { + return switch (ty) { + .i8, .i16, .i32, .i64, .isize => true, + else => false, + }; +} + +/// Sign-extend a `sz`-byte (1/2/4) value (zero-extended in `raw`) to a 64-bit reg. +fn signExtendWord(raw: Reg, sz: usize) Reg { + const shift: u6 = @intCast((8 - sz) * 8); + return @bitCast((@as(i64, @bitCast(raw)) << shift) >> shift); +} + pub const Vm = struct { machine: Machine, gpa: std.mem.Allocator, @@ -209,17 +274,133 @@ pub const Vm = struct { /// tag name or a one-line explanation. Mirrors the legacy interp's /// `last_bail_detail` so the host can surface a real message, not a bare error. detail: ?[]const u8 = null, + /// Per-global memo of comptime-evaluated globals (the legacy interp's + /// `global_values`): `global_get` caches a global's Reg so a chain of globals + /// reading each other doesn't re-run inits (and so each runs at most once). + global_cache: std.AutoHashMap(u32, Reg), + /// The active call chain of `FuncId`s (mirrors the legacy interp's + /// `call_chain`). `trace_frame` packs the top of this stack into a return-trace + /// frame; pushed by `invoke`/`runEntry`, popped on return. + call_stack: std.ArrayList(FuncId) = .empty, pub const max_depth: u32 = 512; pub fn init(gpa: std.mem.Allocator) Vm { - return .{ .machine = Machine.init(gpa), .gpa = gpa }; + return .{ .machine = Machine.init(gpa), .gpa = gpa, .global_cache = std.AutoHashMap(u32, Reg).init(gpa) }; } pub fn deinit(self: *Vm) void { + self.global_cache.deinit(); + self.call_stack.deinit(self.gpa); self.machine.deinit(); } + /// Run a comptime ENTRY function (nullary in user args): materialize the + /// implicit `*Context` arg if the function declares one, then run. Shared by + /// `tryEval` (the host entry) and `evalGlobal` (a comptime global's init). The + /// materialized ctx is zeroed; a body that ignores it runs, one that uses the + /// allocator hits unported `call_indirect` and bails. + fn runEntry(self: *Vm, func_id: FuncId) Error!Reg { + const module = self.module orelse return self.failMsg("comptime VM: entry run needs a module"); + const func = module.getFunction(func_id); + var argbuf: [1]Reg = undefined; + var args: []const Reg = &.{}; + if (func.has_implicit_ctx) { + if (func.params.len != 1) return self.failMsg("comptime VM: has_implicit_ctx with non-ctx params"); + argbuf[0] = try self.materializeDefaultContext(module); + args = argbuf[0..1]; + } + self.call_stack.append(self.gpa, func_id) catch @panic("comptime VM: out of memory (call stack)"); + defer _ = self.call_stack.pop(); + return self.run(func, args); + } + + /// Materialize the default `Context` in flat memory and return its address — + /// the VM analogue of the static `__sx_default_context` global / the legacy + /// `defaultContextValue`. The implicit-ctx param is an opaque `*void`, so the + /// real Context type AND its initializer (the nested `{ {null, alloc_fn, + /// dealloc_fn}, null }` constant carrying the CAllocator thunk func-refs) come + /// from the `__sx_default_context` global. Laying that constant into flat memory + /// gives a context whose `alloc_fn`/`dealloc_fn` are real func-refs, so a + /// comptime body that allocates via `context.allocator` dispatches through + /// `call_indirect` to the thunk to `CAllocator.alloc_bytes` to `libc_malloc` to + /// the VM's native `malloc` (flat memory) — all on the VM, no host heap. If no + /// `__sx_default_context` global exists, bail (legacy fallback). + fn materializeDefaultContext(self: *Vm, module: *const Module) Error!Addr { + const table = self.table orelse return self.failMsg("comptime VM: default context needs a type table"); + for (module.globals.items) |*g| { + if (!std.mem.eql(u8, module.types.getString(g.name), "__sx_default_context")) continue; + const addr = self.machine.allocBytes(table.typeSizeBytes(g.ty), table.typeAlignBytes(g.ty)); // zeroed + if (g.init_val) |iv| try self.layoutConst(table, iv, g.ty, addr); + return addr; + } + return self.failMsg("comptime VM: no __sx_default_context global to materialize the implicit context"); + } + + /// Lay a static `ConstantValue` of type `ty` into flat memory at `addr` (the + /// destination is pre-zeroed). Scalars/func-refs write a word; a null/zero/undef + /// leaf stays zeroed; an aggregate recurses per field at the type's natural + /// offsets. Builds the default context from its global constant. + fn layoutConst(self: *Vm, table: *const types.TypeTable, cv: inst_mod.ConstantValue, ty: TypeId, addr: Addr) Error!void { + switch (cv) { + .int => |v| try self.writeField(table, addr, ty, @bitCast(v)), + .boolean => |b| try self.writeField(table, addr, ty, @intFromBool(b)), + .float => |v| try self.writeField(table, addr, ty, @bitCast(v)), + .func_ref => |fid| try self.writeField(table, addr, ty, funcRefWord(fid)), + .null_val, .zeroinit, .undef => {}, // destination already zeroed + .aggregate => |fields| { + if (ty.isBuiltin()) return self.failMsg("comptime VM: const aggregate at a builtin type"); + switch (table.get(ty)) { + .@"struct" => |s| for (fields, 0..) |fv, i| { + if (i >= s.fields.len) break; + try self.layoutConst(table, fv, s.fields[i].ty, addr + fieldOffset(table, ty, @intCast(i))); + }, + .tuple => |t| for (fields, 0..) |fv, i| { + if (i >= t.fields.len) break; + try self.layoutConst(table, fv, t.fields[i], addr + tupleFieldOffset(table, ty, @intCast(i))); + }, + .array => |a| for (fields, 0..) |fv, i| { + try self.layoutConst(table, fv, a.element, addr + @as(Addr, @intCast(i)) * @as(Addr, @intCast(table.typeSizeBytes(a.element)))); + }, + else => return self.failMsg("comptime VM: const aggregate at an unsupported type"), + } + }, + .string, .vtable => return self.failMsg("comptime VM: const string/vtable not supported in layoutConst yet"), + } + } + + /// Evaluate comptime global `gid` to its Reg value — lazily running its + /// `comptime_func` (with implicit-ctx bootstrap), or reading a scalar static + /// `init_val` — memoized in `global_cache`. The legacy `getGlobal` analogue. + fn evalGlobal(self: *Vm, gid: inst_mod.GlobalId) Error!Reg { + const module = self.module orelse return self.failMsg("comptime VM: global_get needs a module"); + const idx = gid.index(); + if (self.global_cache.get(idx)) |r| return r; + if (idx >= module.globals.items.len) return self.failMsg("comptime VM: global_get index out of range"); + const global = &module.globals.items[idx]; + const r: Reg = if (global.comptime_func) |fid| + try self.runEntry(fid) + else if (global.init_val) |iv| + try self.constToReg(iv) + else + return self.failMsg("comptime VM: global_get of a global with no comptime_func / init_val"); + self.global_cache.put(idx, r) catch @panic("comptime VM: out of memory (global cache)"); + return r; + } + + /// Convert a static `ConstantValue` (a global's `init_val`) to a Reg. Scalars + /// only for now (float regs hold f64 bits — storage narrows f32); aggregate / + /// string / vtable / func_ref bail loudly (add when a real global_get needs it). + fn constToReg(self: *Vm, cv: inst_mod.ConstantValue) Error!Reg { + return switch (cv) { + .int => |v| @bitCast(v), + .boolean => |b| @intFromBool(b), + .float => |v| @bitCast(v), + .null_val, .zeroinit, .undef => null_addr, + else => self.failMsg("comptime VM: global_get static init kind not yet supported (string/aggregate/vtable/func_ref)"), + }; + } + /// Run `func` with scalar `args` (one `Reg` word each, in param order) and /// return the scalar result word. `ret_void` / falling off a block with no /// terminator yields 0. Aggregate args/results await the memory sub-step. @@ -263,10 +444,15 @@ pub const Vm = struct { const bp = ins.op.block_param; if (bp.param_index < block_args.len) frame.set(ref, frame.get(block_args[bp.param_index].index())); + if (frame.bad_ref) return self.badRef(); ref += 1; continue; } - switch (try self.exec(ins, &frame, ref_types)) { + const step = try self.exec(ins, &frame, ref_types); + // A malformed IR (an out-of-range / `Ref.none` operand from an + // unresolved name) flips `frame.bad_ref` instead of panicking — bail. + if (frame.bad_ref) return self.badRef(); + switch (step) { .value => |w| { frame.set(ref, w); ref += 1; @@ -358,7 +544,13 @@ pub const Vm = struct { .struct_get => |fa| { const table = try self.requireTable(); const sty = aggType(table, fa, ref_types); - const fty = table.get(sty).@"struct".fields[fa.field_index].ty; + // For a real struct the field type comes from the table; for a + // string/slice fat-pointer base ({ptr,len}) the result type IS the + // field type (`ins.ty`). + const fty = if (!sty.isBuiltin() and table.get(sty) == .@"struct") + table.get(sty).@"struct".fields[fa.field_index].ty + else + ins.ty; return .{ .value = try self.readField(table, frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index), fty) }; }, .struct_gep => |fa| { @@ -400,11 +592,11 @@ pub const Vm = struct { .length => |u| { const table = try self.requireTable(); const oty = ref_types[u.operand.index()]; - if (oty == .string) return .{ .value = self.sliceLen(frame.get(u.operand.index())) }; + if (oty == .string) return .{ .value = try self.sliceLen(frame.get(u.operand.index())) }; if (!oty.isBuiltin()) { switch (table.get(oty)) { .array => |a| return .{ .value = a.length }, - .slice => return .{ .value = self.sliceLen(frame.get(u.operand.index())) }, + .slice => return .{ .value = try self.sliceLen(frame.get(u.operand.index())) }, else => {}, } } @@ -417,14 +609,14 @@ pub const Vm = struct { const table = try self.requireTable(); const text = table.getString(sid); const data = self.machine.allocBytes(text.len + 1, 1); // +1: NUL (zero-init) - if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text); - return .{ .value = self.makeSlice(table, data, text.len) }; + if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text); + return .{ .value = try self.makeSlice(table, data, text.len) }; }, .data_ptr => |u| { const table = try self.requireTable(); const oty = ref_types[u.operand.index()]; if (oty == .string or (!oty.isBuiltin() and table.get(oty) == .slice)) - return .{ .value = self.sliceData(table, frame.get(u.operand.index())) }; + return .{ .value = try self.sliceData(table, frame.get(u.operand.index())) }; self.detail = "comptime VM: .ptr (data_ptr) on a non-slice/string operand"; return error.Unsupported; }, @@ -436,7 +628,7 @@ pub const Vm = struct { self.detail = "comptime VM: array_to_slice on a non-array operand"; return error.Unsupported; } - return .{ .value = self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) }; + return .{ .value = try self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) }; }, .subslice => |s| { const table = try self.requireTable(); @@ -447,13 +639,13 @@ pub const Vm = struct { var elem: TypeId = .u8; var data: Addr = base; if (bty == .string) { - data = self.sliceData(table, base); + data = try self.sliceData(table, base); } else if (!bty.isBuiltin()) { switch (table.get(bty)) { .array => |a| elem = a.element, .slice => |sl| { elem = sl.element; - data = self.sliceData(table, base); + data = try self.sliceData(table, base); }, else => { self.detail = "comptime VM: subslice on a non-array/slice/string base"; @@ -465,14 +657,14 @@ pub const Vm = struct { return error.Unsupported; } const esz: u64 = @intCast(table.typeSizeBytes(elem)); - return .{ .value = self.makeSlice(table, data +% lo *% esz, hi - lo) }; + return .{ .value = try self.makeSlice(table, data +% lo *% esz, hi - lo) }; }, .str_eq, .str_ne => |b| { const table = try self.requireTable(); const lb = frame.get(b.lhs.index()); const rb = frame.get(b.rhs.index()); - const ls = self.machine.bytes(self.sliceData(table, lb), @intCast(self.sliceLen(lb))); - const rs = self.machine.bytes(self.sliceData(table, rb), @intCast(self.sliceLen(rb))); + const ls = try self.machine.bytes(try self.sliceData(table, lb), @intCast(try self.sliceLen(lb))); + const rs = try self.machine.bytes(try self.sliceData(table, rb), @intCast(try self.sliceLen(rb))); const eq = std.mem.eql(u8, ls, rs); return .{ .value = @intFromBool(if (std.meta.activeTag(ins.op) == .str_eq) eq else !eq) }; }, @@ -485,14 +677,14 @@ pub const Vm = struct { if (optChildIsPtr(table, child)) return .{ .value = val }; // pointer optional: the pointer const addr = self.machine.allocBytes(table.typeSizeBytes(ins.ty), table.typeAlignBytes(ins.ty)); try self.writeField(table, addr, child, val); // payload @ 0 - self.machine.writeWord(addr + table.typeSizeBytes(child), 1, 1); // has_value flag = 1 + try self.machine.writeWord(addr + table.typeSizeBytes(child), 1, 1); // has_value flag = 1 return .{ .value = addr }; }, .optional_unwrap => |u| { const table = try self.requireTable(); const opt_ty = ref_types[u.operand.index()]; const v = frame.get(u.operand.index()); - if (!self.optHas(table, opt_ty, v)) { + if (!try self.optHas(table, opt_ty, v)) { self.detail = "comptime VM: unwrap of a null optional"; return error.TypeError; } @@ -502,13 +694,13 @@ pub const Vm = struct { }, .optional_has_value => |u| { const table = try self.requireTable(); - return .{ .value = @intFromBool(self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) }; + return .{ .value = @intFromBool(try self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) }; }, .optional_coalesce => |b| { const table = try self.requireTable(); const opt_ty = ref_types[b.lhs.index()]; const v = frame.get(b.lhs.index()); - if (self.optHas(table, opt_ty, v)) { + if (try self.optHas(table, opt_ty, v)) { const child = table.get(opt_ty).optional.child; if (optChildIsPtr(table, child)) return .{ .value = v }; return .{ .value = try self.readField(table, v, child) }; @@ -534,25 +726,41 @@ pub const Vm = struct { return error.Unsupported; }, + // `is_comptime()` — always true on the comptime VM (folds to false in + // compiled code). Mirrors the legacy interp's `.is_comptime => true`. + .is_comptime => return .{ .value = @as(Reg, 1) }, + + // A comptime return-trace frame: pack `(func_id << 32 | span.start)` + // from the top of the call chain (mirrors the legacy interp). The + // failable-propagation lowering feeds this to `sx_trace_push`. + .trace_frame => { + const fid: u64 = if (self.call_stack.items.len > 0) self.call_stack.items[self.call_stack.items.len - 1].index() else 0; + return .{ .value = (fid << 32) | @as(u64, ins.span.start) }; + }, + // ── Calls ─────────────────────────────────────────── - .call => |c| { - const module = self.module orelse { - self.detail = "comptime VM: call needs a module (not provided)"; + // Direct call: resolve the static callee `FuncId` and dispatch. + .call => |c| return .{ .value = try self.invoke(c.callee, c.args, frame) }, + // Indirect call: the callee is a `func_ref` value (its `FuncId.index()` + // as a word) in a register — e.g. an allocator protocol's `alloc_fn`. + // A null (0) function pointer can't be dispatched → bail. + .call_indirect => |ci| { + const w = frame.get(ci.callee.index()); + const fid = funcRefToId(w) orelse { + self.detail = "comptime VM: call_indirect through a null function pointer"; return error.Unsupported; }; - const callee = module.getFunction(c.callee); - if (callee.is_extern or callee.blocks.items.len == 0) { - self.detail = "comptime VM: call to an extern/builtin function not yet ported"; - return error.Unsupported; - } - // Marshal arg Refs → Reg words (aggregates pass as their Addr — the - // callee shares this machine's flat memory, so no copy is needed). - const argbuf = self.gpa.alloc(Reg, c.args.len) catch @panic("comptime VM: out of memory (call args)"); - defer self.gpa.free(argbuf); - for (c.args, 0..) |a, i| argbuf[i] = frame.get(a.index()); - return .{ .value = try self.run(callee, argbuf) }; + return .{ .value = try self.invoke(fid, ci.args, frame) }; }, + // ── Globals / function values ─────────────────────── + // Read another comptime global by lazily evaluating its init (its + // `comptime_func` run on this same VM, or a scalar static value), + // memoized. Mirrors the legacy interp's `getGlobal`. + .global_get => |gid| return .{ .value = try self.evalGlobal(gid) }, + // A function value is its encoded func-ref word (see `funcRefWord`). + .func_ref => |fid| return .{ .value = funcRefWord(fid) }, + // ── Pointers ──────────────────────────────────────── // `@x` — pass through: an aggregate value already IS its address, and a // pointer value is already an address (mirrors the legacy interp). @@ -663,6 +871,149 @@ pub const Vm = struct { return error.Unsupported; } + fn badRef(self: *Vm) error{Unsupported} { + self.detail = "comptime VM: malformed IR — operand ref out of range (unresolved name?)"; + return error.Unsupported; + } + + /// Dispatch a call to function `fid` with `args` (Refs in the current frame), + /// shared by `call` (static callee) and `call_indirect` (func-ref callee). An + /// extern/bodyless callee routes to the native libc memory builtins (else + /// bails); a normal callee runs on the VM. Aggregate args pass as their Addr + /// over the shared flat memory (no copy). + fn invoke(self: *Vm, fid: inst_mod.FuncId, args: []const Ref, frame: *Frame) Error!Reg { + const module = self.module orelse return self.failMsg("comptime VM: call needs a module (not provided)"); + if (fid.index() >= module.functions.items.len) return self.failMsg("comptime VM: call to an out-of-range function id"); + const callee = module.getFunction(fid); + if (callee.is_extern or callee.blocks.items.len == 0) { + const name = module.types.getString(callee.name); + // A curated set of libc MEMORY builtins is modeled natively on flat + // memory (sandboxed, target-aware) — comptime malloc/free/memcpy/… + // never reach the host heap or dlsym. + if (try self.callMemBuiltin(name, args, frame)) |r| return r; + // A welded `compiler`-library function (`abi(.zig) extern compiler`): + // the comptime compiler-API, serviced natively on flat memory (Phase 3 + // seed). The `compiler_welded` flag is the safety boundary. + if (callee.compiler_welded) { + if (try self.callCompilerFn(name, args, frame)) |r| return r; + } + // Any other extern bails → the legacy interpreter's dlsym path. + self.detail = "comptime VM: call to an extern/builtin function not yet ported"; + return error.Unsupported; + } + const argbuf = self.gpa.alloc(Reg, args.len) catch @panic("comptime VM: out of memory (call args)"); + defer self.gpa.free(argbuf); + for (args, 0..) |a, i| argbuf[i] = frame.get(a.index()); + self.call_stack.append(self.gpa, fid) catch @panic("comptime VM: out of memory (call stack)"); + defer _ = self.call_stack.pop(); + return self.run(callee, argbuf); + } + + /// Largest single comptime allocation the VM will service natively. A bogus / + /// pathological comptime `malloc` above this bails to the legacy path (which + /// calls real libc) rather than OOM-panicking the compiler via `allocBytes`. + const max_builtin_alloc: usize = 1 << 28; // 256 MiB + + /// Read call arg `i` as a non-negative byte count (libc size/length arg). + fn argLen(self: *Vm, args: []const Ref, frame: *Frame, i: usize) Error!usize { + const w: i64 = @bitCast(frame.get(args[i].index())); + return std.math.cast(usize, w) orelse self.failMsg("comptime mem builtin: negative/oversized size arg"); + } + + /// Model a curated set of libc MEMORY builtins directly on flat memory, so a + /// comptime `malloc`/`free`/`memcpy`/… stays sandboxed (no host heap, no + /// dlsym) and target-aware. Returns the result word, or `null` if `name` is + /// not one of them (the caller then bails to the legacy interpreter). libc + /// `malloc` returns 16-byte-aligned storage; we mirror that. The COMPUTED + /// result is byte-identical to the legacy path (which calls real libc) — only + /// the backing memory differs (flat vs host heap), which the result can't see. + fn callMemBuiltin(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg { + // Error return-trace runtime (sx_trace.c, linked into the compiler). A + // comptime failable that raises emits `sx_trace_push(trace_frame())` as it + // unwinds; service it natively so the trace buffer the host reads is + // populated identically to the legacy interp's dlsym path. + if (std.mem.eql(u8, name, "sx_trace_push")) { + if (args.len >= 1) sx_trace_push(frame.get(args[0].index())); + return @as(Reg, 0); + } + if (std.mem.eql(u8, name, "sx_trace_clear")) { + sx_trace_clear(); + return @as(Reg, 0); + } + if (std.mem.eql(u8, name, "malloc")) { + if (args.len < 1) return self.failMsg("comptime malloc: missing size arg"); + const size = try self.argLen(args, frame, 0); + if (size > max_builtin_alloc) return self.failMsg("comptime malloc: size exceeds the VM cap"); + return self.machine.allocBytes(size, 16); + } + if (std.mem.eql(u8, name, "calloc")) { + if (args.len < 2) return self.failMsg("comptime calloc: missing args"); + const n = try self.argLen(args, frame, 0); + const sz = try self.argLen(args, frame, 1); + const total = std.math.mul(usize, n, sz) catch return self.failMsg("comptime calloc: size overflow"); + if (total > max_builtin_alloc) return self.failMsg("comptime calloc: size exceeds the VM cap"); + return self.machine.allocBytes(total, 16); // allocBytes zero-inits + } + if (std.mem.eql(u8, name, "free")) { + // No per-object free: comptime allocations live to `Vm.deinit`. + return @as(Reg, 0); + } + if (std.mem.eql(u8, name, "memcpy") or std.mem.eql(u8, name, "memmove")) { + if (args.len < 3) return self.failMsg("comptime memcpy: missing args"); + const dst = frame.get(args[0].index()); + const src = frame.get(args[1].index()); + const n = try self.argLen(args, frame, 2); + if (n > 0) { + const d = try self.machine.bytes(dst, n); + const s = try self.machine.bytes(src, n); + // Overlap-safe (memmove semantics; correct for memcpy's too). + if (dst < src) std.mem.copyForwards(u8, d, s) else std.mem.copyBackwards(u8, d, s); + } + return dst; // libc returns dst + } + if (std.mem.eql(u8, name, "memset")) { + if (args.len < 3) return self.failMsg("comptime memset: missing args"); + const dst = frame.get(args[0].index()); + const byte: u8 = @truncate(frame.get(args[1].index())); + const n = try self.argLen(args, frame, 2); + if (n > 0) @memset(try self.machine.bytes(dst, n), byte); + return dst; // libc returns dst + } + return null; // not a modeled builtin → caller bails to legacy + } + + /// Service a welded `compiler`-library function natively on flat memory — the + /// comptime compiler-API (Phase 3 of `PLAN-COMPILER-VM.md`). Returns the result + /// word, or `null` for an unknown name (caller bails → legacy). Mirrors the + /// legacy `compiler_lib` handlers, but reads/writes flat memory directly instead + /// of marshaling `Value`s. The seed pair is the string-pool round-trip: + /// `intern(s: string) -> StringId` and `text_of(id: StringId) -> string`. + fn callCompilerFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg { + const table = try self.requireTable(); + if (std.mem.eql(u8, name, "intern")) { + if (args.len != 1) return self.failMsg("comptime intern: expected one string arg"); + const s = frame.get(args[0].index()); // string fat-pointer Addr + const text = try self.machine.bytes(try self.sliceData(table, s), @intCast(try self.sliceLen(s))); + // The string pool is genuinely mutable; the VM holds the table `const` + // (it never mutates TYPE layout — interning a string is pool-only, so it + // can't invalidate the cached type sizes the VM relies on). Same access + // the legacy `compiler_lib.mintTable` uses. + const id = @constCast(table).internString(text); + return @as(Reg, @intFromEnum(id)); + } + if (std.mem.eql(u8, name, "text_of")) { + if (args.len != 1) return self.failMsg("comptime text_of: expected one StringId arg"); + const raw = frame.get(args[0].index()); + if (raw > std.math.maxInt(u32)) return self.failMsg("comptime text_of: StringId out of range"); + const id: types.StringId = @enumFromInt(@as(u32, @intCast(raw))); + const text = table.getString(id); + const data = self.machine.allocBytes(text.len + 1, 1); // +1: NUL (zero-init) + if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text); + return try self.makeSlice(table, data, text.len); + } + return null; // not a known compiler function → caller bails to legacy + } + // ── Reg ↔ Value bridge (legacy-interop boundary) ──────────────────────── // // The wiring step routes a comptime eval through the VM, falling back to the @@ -692,7 +1043,7 @@ pub const Vm = struct { else => return self.failMsg("value→reg: expected a string literal value"), }; const data = self.machine.allocBytes(text.len + 1, 1); - if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text); + if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text); return self.makeSlice(table, data, text.len); } const info = table.get(ty); @@ -722,11 +1073,18 @@ pub const Vm = struct { .word => { if (isFloat(ty)) return .{ .float = @bitCast(reg) }; if (ty == .bool) return .{ .boolean = reg != 0 }; + // A function-typed word is an encoded func-ref; map it back to + // `.func_ref` (or `.null_val` for the null word) so the host + // serializes it identically to the legacy (e.g. the comptime-global + // func-ref rejection diagnostic). + if (isFuncRefType(table, ty)) { + return if (funcRefToId(reg)) |fid| .{ .func_ref = fid } else .null_val; + } return .{ .int = @bitCast(reg) }; }, .aggregate => { if (ty == .string) { - const src = self.machine.bytes(self.sliceData(table, reg), @intCast(self.sliceLen(reg))); + const src = try self.machine.bytes(try self.sliceData(table, reg), @intCast(try self.sliceLen(reg))); return .{ .string = alloc.dupe(u8, src) catch return self.failMsg("reg→value: out of memory (string)") }; } const info = table.get(ty); @@ -738,6 +1096,17 @@ pub const Vm = struct { } return .{ .aggregate = out }; } + if (info == .tuple) { + // A failable `(value…, error_tag)` is a tuple; the host's + // `checkComptimeFailable` reads the last field as the tag. + const elems = info.tuple.fields; + const out = alloc.alloc(Value, elems.len) catch return self.failMsg("reg→value: out of memory (tuple)"); + for (elems, 0..) |ety, i| { + const fr = try self.readField(table, reg + tupleFieldOffset(table, ty, @intCast(i)), ety); + out[i] = try self.regToValue(alloc, table, fr, ety); + } + return .{ .aggregate = out }; + } return self.failMsg("reg→value: aggregate shape not bridged yet"); }, .unsupported => return self.failMsg("reg→value: unsupported type"), @@ -759,6 +1128,7 @@ pub const Vm = struct { return switch (table.get(ty)) { .pointer, .many_pointer, .function => .word, .@"enum" => .word, // payloadless enum: i64 (or its backing) — a word + .error_set => .word, // the error channel is a u32 tag id — a word .@"struct", .array, .tuple, .slice => .aggregate, // `?T`: a pointer child is null-as-0 (word); else `{T, i1}` by-address. .optional => |o| if (optChildIsPtr(table, o.child)) .word else .aggregate, @@ -766,6 +1136,29 @@ pub const Vm = struct { }; } + /// A function value (func-ref) is encoded in a register as `FuncId.index() + 1` + /// so that 0 is reserved for the NULL function pointer (a `FuncId` of 0 is a + /// real function and must stay distinguishable from null). `funcRefWord` encodes; + /// `funcRefToId` decodes (returns null for the 0/null word). + fn funcRefWord(fid: inst_mod.FuncId) Reg { + return @as(Reg, fid.index()) + 1; + } + fn funcRefToId(word: Reg) ?inst_mod.FuncId { + if (word == null_addr) return null; + return inst_mod.FuncId.fromIndex(@intCast(word - 1)); + } + + /// Is `ty` a function value type — a function type directly, or a pointer to + /// one? Such a word holds an encoded func-ref (see `funcRefWord`), not a raw int. + fn isFuncRefType(table: *const types.TypeTable, ty: TypeId) bool { + if (ty.isBuiltin()) return false; + return switch (table.get(ty)) { + .function => true, + .pointer => |p| !p.pointee.isBuiltin() and table.get(p.pointee) == .function, + else => false, + }; + } + /// A `?T` whose child is a pointer/many-pointer/function is represented as a /// bare pointer (null == 0), not a `{T, i1}` aggregate — mirrors `typeSizeBytes`. fn optChildIsPtr(table: *const types.TypeTable, child: TypeId) bool { @@ -779,18 +1172,33 @@ pub const Vm = struct { /// Does an optional value `v` of type `opt_ty` hold a value? A pointer optional /// is present iff non-null; a `{T,i1}` optional is none when `v` is `null_addr` /// (the `const_null` form) else its flag byte (at offset `sizeof(child)`) is set. - fn optHas(self: *Vm, table: *const types.TypeTable, opt_ty: TypeId, v: Reg) bool { + fn optHas(self: *Vm, table: *const types.TypeTable, opt_ty: TypeId, v: Reg) Error!bool { const child = table.get(opt_ty).optional.child; if (optChildIsPtr(table, child)) return v != null_addr; if (v == null_addr) return false; - return self.machine.readWord(v + table.typeSizeBytes(child), 1) != 0; + return (try self.machine.readWord(v + table.typeSizeBytes(child), 1)) != 0; } /// Read a value of type `ty` from flat address `addr`: a scalar reads its /// bytes; an aggregate value IS its address (it lives inline at `addr`). + /// `f32` is special: float REGISTERS hold f64 bits (like the legacy interp's + /// `.float`), but memory holds the 4-byte IEEE-754 single — so read 4 bytes as + /// `f32` and widen to the f64 register form. A SIGNED sub-64-bit integer + /// (`i8`/`i16`/`i32`/`isize`) is SIGN-extended into the 64-bit register — the + /// legacy `.int` model is i64, so a stored-and-reloaded negative value must + /// stay negative (else e.g. `i32 -1` reloads as `0xFFFFFFFF` and `< 0` is false). fn readField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId) Error!Reg { + if (ty == .f32) { + const bits: u32 = @truncate(try self.machine.readWord(addr, 4)); + const f: f32 = @bitCast(bits); + return @bitCast(@as(f64, f)); + } return switch (kindOf(table, ty)) { - .word => self.machine.readWord(addr, table.typeSizeBytes(ty)), + .word => { + const sz = table.typeSizeBytes(ty); + const raw = try self.machine.readWord(addr, sz); + return if (isSignedInt(ty) and sz < 8) signExtendWord(raw, sz) else raw; + }, .aggregate => addr, .unsupported => { self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)"; @@ -801,13 +1209,28 @@ pub const Vm = struct { /// Write register word `val` (of type `ty`) to flat address `addr`: a scalar /// writes its bytes; an aggregate copies `sizeof(ty)` bytes from `val` (its - /// source address) into `addr`. + /// source address) into `addr`. A `null_addr` aggregate source is the + /// null/none sentinel (a non-pointer `?T` set to `null`, an empty slice/string, + /// …): there is no source object to copy, so the destination is ZEROED — the + /// all-zero representation IS none / `{ptr:0,len:0}` (flag byte 0 → not present). fn writeField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId, val: Reg) Error!void { + // `f32`: the register holds f64 bits (see `readField`); narrow to a 4-byte + // IEEE-754 single for storage — mirrors the legacy interp's `@floatCast`. + if (ty == .f32) { + const f: f32 = @floatCast(@as(f64, @bitCast(val))); + const bits: u32 = @bitCast(f); + return self.machine.writeWord(addr, 4, bits); + } switch (kindOf(table, ty)) { - .word => self.machine.writeWord(addr, table.typeSizeBytes(ty), val), + .word => try self.machine.writeWord(addr, table.typeSizeBytes(ty), val), .aggregate => { const n = table.typeSizeBytes(ty); - if (n > 0) @memcpy(self.machine.bytes(addr, n), self.machine.bytes(val, n)); + if (n == 0) return; + if (val == null_addr) { + @memset(try self.machine.bytes(addr, n), 0); + } else { + @memcpy(try self.machine.bytes(addr, n), try self.machine.bytes(val, n)); + } }, .unsupported => { self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)"; @@ -819,8 +1242,11 @@ pub const Vm = struct { /// The byte offset of struct field `idx`, computed the same way /// `TypeTable.typeSizeBytes` lays a struct out (each field aligned to its own /// alignment, in declaration order) — so init/get/gep agree, and the layout - /// matches the table's size computation. + /// matches the table's size computation. A string/slice is a `{ptr@0, len@8}` + /// fat pointer (the `makeSlice` layout), accessed by field 0 (ptr) / 1 (len). fn fieldOffset(table: *const types.TypeTable, sty: TypeId, idx: u32) Addr { + if (sty == .string or (!sty.isBuiltin() and table.get(sty) == .slice)) + return if (idx == 0) 0 else 8; const fields = table.get(sty).@"struct".fields; var off: usize = 0; for (fields, 0..) |f, i| { @@ -874,7 +1300,7 @@ pub const Vm = struct { /// base (`slice` / `string`). fn elemAddr(self: *Vm, table: *const types.TypeTable, base_ty: TypeId, base: Reg, idx_word: Reg, elem_size: usize) Error!Addr { const data: Addr = blk: { - if (base_ty == .string) break :blk self.machine.readWord(base, table.pointer_size); + if (base_ty == .string) break :blk try self.machine.readWord(base, table.pointer_size); if (base_ty == .cstring) break :blk base; if (base_ty.isBuiltin()) { self.detail = "comptime VM: indexing an unsupported builtin base"; @@ -882,7 +1308,7 @@ pub const Vm = struct { } break :blk switch (table.get(base_ty)) { .array, .pointer, .many_pointer => base, - .slice => self.machine.readWord(base, table.pointer_size), + .slice => try self.machine.readWord(base, table.pointer_size), else => { self.detail = "comptime VM: indexing a non-array/pointer/slice base"; return error.Unsupported; @@ -896,20 +1322,20 @@ pub const Vm = struct { /// Build a `{ptr, len}` fat pointer (slice/string value) in flat memory and /// return its address. `ptr` is `pointer_size` bytes at offset 0; `len` is an /// i64 at offset 8 (the layout `typeSizeBytes` uses for slice/string: 16B). - fn makeSlice(self: *Vm, table: *const types.TypeTable, data: Addr, len: u64) Addr { + fn makeSlice(self: *Vm, table: *const types.TypeTable, data: Addr, len: u64) Error!Addr { const fp = self.machine.allocBytes(16, 8); - self.machine.writeWord(fp, table.pointer_size, data); - self.machine.writeWord(fp + 8, 8, len); + try self.machine.writeWord(fp, table.pointer_size, data); + try self.machine.writeWord(fp + 8, 8, len); return fp; } /// Read the `.len` field (i64 @ offset 8) of a fat-pointer value at `base`. - fn sliceLen(self: *Vm, base: Addr) u64 { + fn sliceLen(self: *Vm, base: Addr) Error!u64 { return self.machine.readWord(base + 8, 8); } /// Read the `.ptr` field (`pointer_size` @ offset 0) of a fat-pointer at `base`. - fn sliceData(self: *Vm, table: *const types.TypeTable, base: Addr) Addr { + fn sliceData(self: *Vm, table: *const types.TypeTable, base: Addr) Error!Addr { return self.machine.readWord(base, table.pointer_size); } }; diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index d447e7a3..9e3b62cc 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -32,6 +32,8 @@ const Module = ir_module.Module; const interp_mod = @import("interp.zig"); const Interpreter = interp_mod.Interpreter; const Value = interp_mod.Value; +const comptime_vm = @import("comptime_vm.zig"); +const build_opts = @import("build_opts"); // The vendored error-trace ring buffer (library/vendors/sx_trace_runtime/sx_trace.c) // is linked into the compiler. Comptime `#run` evaluation pushes frames to it via @@ -113,6 +115,18 @@ pub const LLVMEmitter = struct { // file or the JIT — the emit-time diagnostic is the surfaced error. comptime_failed: bool = false, + // When set (env `SX_COMPTIME_FLAT`, → a `-Dcomptime-flat` build flag later), + // comptime const-init folds try the flat-memory VM (`comptime_vm.tryEval`) + // first and fall back to the legacy tagged interpreter on null. Default OFF so + // the corpus is unaffected until the VM reaches parity (Phase 1.final step d). + comptime_flat: bool = false, + + // When set (env `SX_COMPTIME_FLAT_TRACE`, only meaningful with `comptime_flat`), + // each comptime const-init reports to stderr whether the VM handled it or fell + // back to the legacy interpreter (with the bail reason) — the coverage signal + // for porting the next ops. Default OFF. + comptime_flat_trace: bool = false, + // Allocator for temporary bookkeeping alloc: Allocator, @@ -321,6 +335,10 @@ pub const LLVMEmitter = struct { .build_config = .{}, .di_files = std.StringHashMap(c.LLVMMetadataRef).init(alloc), .frame_str_cache = std.StringHashMap(c.LLVMValueRef).init(alloc), + // Enabled by the `-Dcomptime-flat` build flag OR the `SX_COMPTIME_FLAT` + // env var (either turns it on); default OFF (legacy interpreter). + .comptime_flat = build_opts.comptime_flat or std.c.getenv("SX_COMPTIME_FLAT") != null, + .comptime_flat_trace = std.c.getenv("SX_COMPTIME_FLAT_TRACE") != null, }; } @@ -845,19 +863,39 @@ pub const LLVMEmitter = struct { Interpreter.last_bail_op = null; Interpreter.last_bail_builtin = null; Interpreter.last_bail_detail = null; - const result = interp_inst.call(func_id, &.{}) catch |err| blk: { - // A comptime `#run` side-effect that bails must NOT silently - // truncate its output and still ship a successful build. - // Surface the bail loudly and fail the build, mirroring the - // const-init path in emitGlobals. Whatever output the run - // produced before the bail is flushed below so the user sees - // where execution stopped. - const op = Interpreter.last_bail_op orelse ""; - const detail = Interpreter.last_bail_detail orelse ""; - const sep: []const u8 = if (detail.len > 0) ": " else ""; - std.debug.print("error: comptime `#run` ({s}) failed: {s} (op={s}{s}{s})\n", .{ fname, @errorName(err), op, sep, detail }); - self.comptime_failed = true; - break :blk Value.void_val; + // Flat-memory VM fast path (gated by `SX_COMPTIME_FLAT`), same as the + // const-init fold: a VM-handled side-effect that needs no `print`/extern + // runs entirely on the VM (no buffered output); anything it can't handle + // (`print`, an unported op) bails → `null` → the legacy interpreter below. + const vm_result: ?Value = if (self.comptime_flat) + comptime_vm.tryEval(self.alloc, self.ir_mod, func_id) + else + null; + if (self.comptime_flat and self.comptime_flat_trace) { + if (vm_result != null) + std.debug.print("[comptime-vm] HANDLED run '{s}'\n", .{fname}) + else + std.debug.print("[comptime-vm] fallback run '{s}': {s}\n", .{ fname, comptime_vm.last_bail_reason orelse "" }); + } + const result = vm_result orelse fallback: { + // The VM bailed: discard any return-trace frames it pushed before + // bailing (`sx_trace_push` is a side effect on the shared buffer), + // else the legacy re-run double-pushes them (see 1035). + if (self.comptime_flat) sx_trace_clear(); + break :fallback interp_inst.call(func_id, &.{}) catch |err| blk: { + // A comptime `#run` side-effect that bails must NOT silently + // truncate its output and still ship a successful build. + // Surface the bail loudly and fail the build, mirroring the + // const-init path in emitGlobals. Whatever output the run + // produced before the bail is flushed below so the user sees + // where execution stopped. + const op = Interpreter.last_bail_op orelse ""; + const detail = Interpreter.last_bail_detail orelse ""; + const sep: []const u8 = if (detail.len > 0) ": " else ""; + std.debug.print("error: comptime `#run` ({s}) failed: {s} (op={s}{s}{s})\n", .{ fname, @errorName(err), op, sep, detail }); + self.comptime_failed = true; + break :blk Value.void_val; + }; }; // Route #run `print` output to fd 1 so it joins the // JIT-executed runtime's stream. Same call site shape as @@ -925,17 +963,40 @@ pub const LLVMEmitter = struct { Interpreter.last_bail_builtin = null; Interpreter.last_bail_detail = null; sx_trace_clear(); - const result = interp_inst.call(func_id, &.{}) catch |err| blk: { - // Surface the bail loudly instead of silently filling - // the const with zero. Stale state from a previous - // comptime function would otherwise hide the error. - const op = Interpreter.last_bail_op orelse ""; - const detail = Interpreter.last_bail_detail orelse ""; - const sep: []const u8 = if (detail.len > 0) ": " else ""; + // Flat-memory VM fast path (gated by `SX_COMPTIME_FLAT`): run the + // comptime initializer on the VM; `null` (unsupported op / any + // bail / implicit-ctx) falls through to the legacy interpreter + // below, which produces the identical result. Default OFF. + const vm_result: ?Value = if (self.comptime_flat) + comptime_vm.tryEval(self.alloc, self.ir_mod, func_id) + else + null; + // Coverage trace (gated): report whether the VM handled this + // comptime init or fell back, and why — names what to port next. + if (self.comptime_flat and self.comptime_flat_trace) { const gname = self.ir_mod.types.getString(global.name); - std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail }); - self.comptime_failed = true; - break :blk .void_val; + if (vm_result != null) { + std.debug.print("[comptime-vm] HANDLED init '{s}'\n", .{gname}); + } else { + std.debug.print("[comptime-vm] fallback init '{s}': {s}\n", .{ gname, comptime_vm.last_bail_reason orelse "" }); + } + } + const result = vm_result orelse fallback: { + // The VM bailed: discard any return-trace frames it pushed + // before bailing, so the legacy re-run doesn't double-push. + if (self.comptime_flat) sx_trace_clear(); + break :fallback interp_inst.call(func_id, &.{}) catch |err| blk: { + // Surface the bail loudly instead of silently filling + // the const with zero. Stale state from a previous + // comptime function would otherwise hide the error. + const op = Interpreter.last_bail_op orelse ""; + const detail = Interpreter.last_bail_detail orelse ""; + const sep: []const u8 = if (detail.len > 0) ": " else ""; + const gname = self.ir_mod.types.getString(global.name); + std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail }); + self.comptime_failed = true; + break :blk .void_val; + }; }; // A bare failable `NAME :: #run f();`: the comptime function // returns the failable tuple; split it. Escaping error → diff --git a/src/ir/lower/comptime.zig b/src/ir/lower/comptime.zig index ca52b79b..60ae8136 100644 --- a/src/ir/lower/comptime.zig +++ b/src/ir/lower/comptime.zig @@ -568,6 +568,12 @@ pub fn evalComptimeString(self: *Lowering, expr: *const Node) ?[:0]const u8 { // dealloc thunks at the bottom of the dispatch. const ct_func_id = self.createComptimeFunction("__insert", expr, .string); + // NOTE: the flat-memory VM is intentionally NOT wired at this LOWERING-time + // site. Unlike the emit-time const-init / `#run` folds (which run on fully + // lowered IR), lowering-time IR can be malformed (e.g. a `ret Ref.none` left by + // an unresolved name — see `0737`), and routing that through the VM is out of + // scope until the VM is fully hardened against arbitrary malformed IR. The + // emit-time sites already give the VM full corpus coverage. var interp = interp_mod.Interpreter.init(self.module, self.alloc); defer interp.deinit(); if (self.diagnostics) |d| if (d.import_sources) |sm| interp.setSourceMap(sm);