comptime VM: harden against malformed lowering-time IR (P3.4-prep)

Prerequisite for wiring the VM at the lowering-time comptime site
(runComptimeTypeFunc), where IR can be malformed (an unresolved name lowers to a
dangling / Ref.none operand — the 0737 crash). Close the remaining panic vectors
so the VM bails (-> legacy fallback) instead of aborting:

- Vm.refTy(ref_types, r): a bounds-checked accessor replacing every raw
  ref_types[ref.index()] in exec — the type-side companion to Frame.get's
  bad_ref value-side guard.
- aggType is now a bailing method (Error!TypeId) routed through refTy.
- the block-dispatch loop bounds-checks the branch target before indexing
  func.blocks.items (a malformed br target). global_get was already guarded.

No behavior change: gate OFF and -Dcomptime-flat both 697/0. Unit test added
(a cmp_lt with a Ref.none operand bails, not panics).
This commit is contained in:
agra
2026-06-18 11:45:40 +03:00
parent 9ae3934f0f
commit 34734d415b
3 changed files with 61 additions and 17 deletions

View File

@@ -325,6 +325,21 @@ when reached (sentinels or accessor fns; see the design doc Risks).
`List` growth; orthogonal, see `current/CHECKPOINT-METATYPE.md`.)
## Log
- **Phase 3 P3.4-prep (VM plan) — harden the VM against malformed lowering-time IR (2026-06-18).**
Prerequisite for wiring the VM at the LOWERING-time comptime site (`runComptimeTypeFunc`),
where IR can be malformed (an unresolved name lowers to a dangling / `Ref.none` operand —
the 0737 crash). Closed the remaining panic vectors so the VM BAILS (→ legacy fallback)
instead of aborting: (1) a checked `Vm.refTy(ref_types, r)` replaces every raw
`ref_types[ref.index()]` in `exec` (the type-side companion to `Frame.get`'s `bad_ref`
value-side guard); (2) `aggType` is now a bailing method (`Error!TypeId`) using `refTy`;
(3) the block-dispatch loop bounds-checks the branch target before indexing
`func.blocks.items`. `global_get` was already guarded. No behavior change — gate OFF and
ON both **697/0**; unit test added (a `cmp_lt` with a `Ref.none` operand bails, not
panics). **Next:** wire `tryEval` into `runComptimeTypeFunc` behind the flag with legacy
fallback and measure (most minting type-fns will still bail at the welded-write call /
`Type`-result conversion until the VM models `Type` values + the VM-native write side land
— those are the steps that actually move lowering-time comptime onto the VM, toward
deleting legacy).
- **Phase 3 P3.3 (VM plan) — WRITE side: declare_type + pointer_to + ONE kind-branching register_type (2026-06-18).**
The mutating compiler-API: `declare_type(name) -> Type` (forward handle), `pointer_to(t) -> Type`
(build `*T`), and `register_type(handle, kind, members: []Member) -> Type` which branches on

View File

@@ -1236,6 +1236,23 @@ test "comptime_vm: a malformed operand ref (Ref.none) bails, not a panic" {
try std.testing.expectError(error.Unsupported, v.run(&fb.func, &.{}));
}
test "comptime_vm: a malformed operand TYPE ref bails (refTy), not a panic" {
// A comparison whose lhs is `Ref.none` exercises the `ref_types` (type-side)
// accessor `refTy` — the companion to the value-side `Frame.get` guard. Raw
// `ref_types[Ref.none.index()]` would index out of bounds and panic; it must
// bail (error.Unsupported) so the host falls back to the legacy interpreter.
var fb = Fb.init(std.testing.allocator, &.{}, .bool);
defer fb.deinit();
const b0 = fb.block(&.{});
const c = fb.add(b0, inst(.{ .const_int = 1 }, .i64));
const r = fb.add(b0, inst(.{ .cmp_lt = .{ .lhs = Ref.none, .rhs = ref(c) } }, .bool));
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(r) } }, .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();

View File

@@ -436,6 +436,8 @@ pub const Vm = struct {
// only writes its own Ref range).
var block_args: []const Ref = &.{};
while (true) {
// A malformed branch target (out-of-range block) bails, not panics.
if (current.index() >= func.blocks.items.len) return self.badRef();
const blk = &func.blocks.items[current.index()];
var ref: usize = blk.first_ref;
var jumped = false;
@@ -498,7 +500,7 @@ pub const Vm = struct {
// ── Comparison (operand type drives signedness/kind) ─
.cmp_eq, .cmp_ne, .cmp_lt, .cmp_le, .cmp_gt, .cmp_ge => |b| {
const r = try self.cmp(std.meta.activeTag(ins.op), ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index()));
const r = try self.cmp(std.meta.activeTag(ins.op), (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()));
return .{ .value = @intFromBool(r) };
},
@@ -525,7 +527,7 @@ pub const Vm = struct {
},
.store => |s| {
const table = try self.requireTable();
const vty = if (s.val_ty != .void) s.val_ty else ref_types[s.val.index()];
const vty = if (s.val_ty != .void) s.val_ty else (try self.refTy(ref_types, s.val));
try self.writeField(table, frame.get(s.ptr.index()), vty, frame.get(s.val.index()));
return .{ .value = 0 }; // store has a void result but still occupies a Ref slot
},
@@ -543,7 +545,7 @@ pub const Vm = struct {
},
.struct_get => |fa| {
const table = try self.requireTable();
const sty = aggType(table, fa, ref_types);
const sty = try self.aggType(table, fa, ref_types);
// 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`).
@@ -555,7 +557,7 @@ pub const Vm = struct {
},
.struct_gep => |fa| {
const table = try self.requireTable();
const sty = aggType(table, fa, ref_types);
const sty = try self.aggType(table, fa, ref_types);
return .{ .value = frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index) };
},
@@ -573,7 +575,7 @@ pub const Vm = struct {
},
.tuple_get => |fa| {
const table = try self.requireTable();
const tty = aggType(table, fa, ref_types);
const tty = try self.aggType(table, fa, ref_types);
const fty = table.get(tty).tuple.fields[fa.field_index];
return .{ .value = try self.readField(table, frame.get(fa.base.index()) + tupleFieldOffset(table, tty, fa.field_index), fty) };
},
@@ -581,17 +583,17 @@ pub const Vm = struct {
// ── Arrays (contiguous, elem-size stride) ───────────
.index_get => |b| {
const table = try self.requireTable();
const addr = try self.elemAddr(table, ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(ins.ty));
const addr = try self.elemAddr(table, (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(ins.ty));
return .{ .value = try self.readField(table, addr, ins.ty) };
},
.index_gep => |b| {
const table = try self.requireTable();
const elem_ty = pointeeOf(table, ins.ty);
return .{ .value = try self.elemAddr(table, ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(elem_ty)) };
return .{ .value = try self.elemAddr(table, (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(elem_ty)) };
},
.length => |u| {
const table = try self.requireTable();
const oty = ref_types[u.operand.index()];
const oty = (try self.refTy(ref_types, u.operand));
if (oty == .string) return .{ .value = try self.sliceLen(frame.get(u.operand.index())) };
if (!oty.isBuiltin()) {
switch (table.get(oty)) {
@@ -614,7 +616,7 @@ pub const Vm = struct {
},
.data_ptr => |u| {
const table = try self.requireTable();
const oty = ref_types[u.operand.index()];
const oty = (try self.refTy(ref_types, u.operand));
if (oty == .string or (!oty.isBuiltin() and table.get(oty) == .slice))
return .{ .value = try self.sliceData(table, frame.get(u.operand.index())) };
self.detail = "comptime VM: .ptr (data_ptr) on a non-slice/string operand";
@@ -622,7 +624,7 @@ pub const Vm = struct {
},
.array_to_slice => |u| {
const table = try self.requireTable();
var aty = ref_types[u.operand.index()];
var aty = (try self.refTy(ref_types, u.operand));
if (!aty.isBuiltin() and table.get(aty) == .pointer) aty = table.get(aty).pointer.pointee;
if (aty.isBuiltin() or table.get(aty) != .array) {
self.detail = "comptime VM: array_to_slice on a non-array operand";
@@ -635,7 +637,7 @@ pub const Vm = struct {
const base = frame.get(s.base.index());
const lo: u64 = @bitCast(frame.get(s.lo.index()));
const hi: u64 = @bitCast(frame.get(s.hi.index()));
const bty = if (s.base_ty != .void) s.base_ty else ref_types[s.base.index()];
const bty = if (s.base_ty != .void) s.base_ty else (try self.refTy(ref_types, s.base));
var elem: TypeId = .u8;
var data: Addr = base;
if (bty == .string) {
@@ -682,7 +684,7 @@ pub const Vm = struct {
},
.optional_unwrap => |u| {
const table = try self.requireTable();
const opt_ty = ref_types[u.operand.index()];
const opt_ty = (try self.refTy(ref_types, u.operand));
const v = frame.get(u.operand.index());
if (!try self.optHas(table, opt_ty, v)) {
self.detail = "comptime VM: unwrap of a null optional";
@@ -694,11 +696,11 @@ pub const Vm = struct {
},
.optional_has_value => |u| {
const table = try self.requireTable();
return .{ .value = @intFromBool(try self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) };
return .{ .value = @intFromBool(try self.optHas(table, (try self.refTy(ref_types, u.operand)), frame.get(u.operand.index()))) };
},
.optional_coalesce => |b| {
const table = try self.requireTable();
const opt_ty = ref_types[b.lhs.index()];
const opt_ty = (try self.refTy(ref_types, b.lhs));
const v = frame.get(b.lhs.index());
if (try self.optHas(table, opt_ty, v)) {
const child = table.get(opt_ty).optional.child;
@@ -717,7 +719,7 @@ pub const Vm = struct {
return .{ .value = @as(Reg, ei.tag) };
},
.enum_tag => |u| {
const oty = ref_types[u.operand.index()];
const oty = (try self.refTy(ref_types, u.operand));
const v = frame.get(u.operand.index());
if (oty.isBuiltin()) return .{ .value = v }; // already an integer tag
const table = try self.requireTable();
@@ -876,6 +878,16 @@ pub const Vm = struct {
return error.Unsupported;
}
/// The IR type of operand `r`, bounds-checked. Lowering-time IR can carry an
/// out-of-range / `Ref.none` operand (an unresolved name lowers to a dangling
/// ref); reading `ref_types` raw would panic, so bail instead — the host then
/// falls back to the legacy interpreter. The companion to `Frame.get`'s
/// `bad_ref` guard (which covers the value side; this covers the type side).
fn refTy(self: *Vm, ref_types: []const TypeId, r: Ref) Error!TypeId {
if (r.index() >= ref_types.len) return self.badRef();
return ref_types[r.index()];
}
/// 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
@@ -1329,9 +1341,9 @@ pub const Vm = struct {
/// The struct type a `FieldAccess` operates on: the explicit `base_type` when
/// lowering set it, else the base operand's Ref type — dereferenced when the
/// base is a POINTER (`struct_gep` on an `alloca` result is `*S` → `S`).
fn aggType(table: *const types.TypeTable, fa: inst_mod.FieldAccess, ref_types: []const TypeId) TypeId {
fn aggType(self: *Vm, table: *const types.TypeTable, fa: inst_mod.FieldAccess, ref_types: []const TypeId) Error!TypeId {
if (fa.base_type) |bt| return bt;
const rt = ref_types[fa.base.index()];
const rt = try self.refTy(ref_types, fa.base);
if (!rt.isBuiltin()) {
const info = table.get(rt);
if (info == .pointer) return info.pointer.pointee;