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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user