diff --git a/current/CHECKPOINT-COMPILER-API.md b/current/CHECKPOINT-COMPILER-API.md index 581db930..383cf4e7 100644 --- a/current/CHECKPOINT-COMPILER-API.md +++ b/current/CHECKPOINT-COMPILER-API.md @@ -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 diff --git a/src/ir/comptime_vm.test.zig b/src/ir/comptime_vm.test.zig index 9be82967..eddfb5c5 100644 --- a/src/ir/comptime_vm.test.zig +++ b/src/ir/comptime_vm.test.zig @@ -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(); diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index c79396a9..befa6870 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -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;