comptime VM: host wiring, full corpus parity, build flag, Phase 3 seed

Phase 1.final of the flat-memory comptime VM — wire the host through it,
reach corpus parity, and gate it behind a build flag — plus the first
Phase 3 (compiler-API) step. Default OFF; legacy interpreter unchanged.

Host wiring + hardening:
- Machine accessors return error.OutOfBounds (no debug panic) on bad
  addresses; Frame.get/set bounds-check and bail (no panic) on a malformed
  operand ref (e.g. a ret Ref.none from an unresolved name).
- tryEval routed at both comptime call sites in emit_llvm — the const-init
  fold and the #run side-effect path — with per-eval legacy fallback;
  yields .void_val for void/noreturn entries. Both sites sx_trace_clear()
  before the legacy fallback so a partial VM run that pushed trace frames
  doesn't double-push on re-run.

VM coverage (all corpus const-inits except the inline-asm global):
- Implicit context materialized from the __sx_default_context global; the
  full allocator protocol runs on the VM (context.allocator.alloc ->
  call_indirect -> CAllocator thunk -> libc_malloc -> native flat malloc).
- Native libc memory builtins (malloc/calloc/free/memcpy/memmove/memset)
  on flat memory; f32 stored/loaded as the 4-byte single; signed sub-64-bit
  loads sign-extended; global_get (lazy + memoized); func_ref/call_indirect
  (func-ref encoded fid+1, 0 reserved for null); string/slice fat-pointer
  field access; is_comptime; the failable/error cluster (error_set tuples,
  trace_frame + native sx_trace_push/clear -> raise/catch/or + return traces).

Build flag + Phase 3 seed:
- -Dcomptime-flat (build_opts module) OR SX_COMPTIME_FLAT env enables the VM;
  zig build test -Dcomptime-flat runs the full corpus on the VM (688/0).
- intern/text_of serviced natively on flat memory via Vm.callCompilerFn
  (compiler_welded boundary) — the seed the rest of the compiler-API grows on.

Parity 688/688 gate ON and OFF. Unit tests added throughout. The
lowering-time #insert wiring was explored and reverted (lowering-time IR can
be malformed; full malformed-IR hardening is a prerequisite, deferred).
This commit is contained in:
agra
2026-06-18 08:27:58 +03:00
parent b8f3d6fd78
commit 0367d96d9b
7 changed files with 1142 additions and 108 deletions

View File

@@ -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();