diff --git a/examples/comptime/0645-comptime-body-local-run-bridgeable.sx b/examples/comptime/0645-comptime-body-local-run-bridgeable.sx new file mode 100644 index 00000000..cb057c39 --- /dev/null +++ b/examples/comptime/0645-comptime-body-local-run-bridgeable.sx @@ -0,0 +1,28 @@ +// A body-local `#run` const of a BRIDGEABLE shape — a scalar, a struct, an +// array, or an `?Array` optional — evaluates and produces its const value. +// These are the common cases that must keep working alongside the issue-0182 +// fix (which fails ONLY the unbridgeable-result case, e.g. `[2][]i64`). +// +// Regression (issue 0182): the body-local `#run` fold must not regress the +// bridgeable cases when it learned to fail loudly on an unbridgeable result. +#import "modules/std.sx"; + +Pt :: struct { x: i64; y: i64; } + +mk_scalar :: () -> i64 { return 42; } +mk_struct :: () -> Pt { return .{ x = 3, y = 4 }; } +mk_arr :: () -> [3]i64 { r : [3]i64 = ---; r[0] = 10; r[1] = 20; r[2] = 30; return r; } +mk_opt :: () -> ?[3]i64 { r : [3]i64 = ---; r[0] = 1; r[1] = 2; r[2] = 3; return r; } + +main :: () { + N :: #run mk_scalar(); + S :: #run mk_struct(); + A :: #run mk_arr(); + O :: #run mk_opt(); + + print("N={}\n", N); + print("S={} {}\n", S.x, S.y); + print("A={} {} {}\n", A[0], A[1], A[2]); + v := O!; + print("O={}\n", v[1]); +} diff --git a/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.exit b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.exit @@ -0,0 +1 @@ +0 diff --git a/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stderr b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stderr @@ -0,0 +1 @@ + diff --git a/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stdout b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stdout new file mode 100644 index 00000000..62445e39 --- /dev/null +++ b/examples/comptime/expected/0645-comptime-body-local-run-bridgeable.stdout @@ -0,0 +1,4 @@ +N=42 +S=3 4 +A=10 20 30 +O=2 diff --git a/examples/diagnostics/1204-diagnostics-comptime-run-unbridged-shape.sx b/examples/diagnostics/1204-diagnostics-comptime-run-unbridged-shape.sx new file mode 100644 index 00000000..58018947 --- /dev/null +++ b/examples/diagnostics/1204-diagnostics-comptime-run-unbridged-shape.sx @@ -0,0 +1,27 @@ +// A body-local `#run` const whose comptime function returns a shape the comptime +// VM cannot BRIDGE to a host value (here `[2][]i64` — an array of slices) must +// FAIL the build with a located `comptime init of '' failed: ` +// diagnostic — the SAME loud failure a GLOBAL `#run` const produces — NOT +// silently fall back to a runtime call over the wrapper's `---` storage. +// +// Regression (issue 0182): the body-local `#run` fold left the runtime call in +// place when the result couldn't bridge, so `mk()` ran at runtime over +// uninitialized storage → garbage, exit 0, no diagnostic (a silent miscompile). +// The fix surfaces the BRIDGE bail loudly and gates the build (exit 1). An +// EXECUTION bail (a body the VM can't run but the runtime computes correctly, +// e.g. `0.0/0.0` → NaN) still falls through to the runtime call — only an +// unbridgeable RESULT fails here. +#import "modules/std.sx"; + +mk :: () -> [2][]i64 { + a : []i64 = ---; + r : [2][]i64 = ---; + r[0] = a; + r[1] = a; + return r; +} + +main :: () { + L :: #run mk(); // unbridgeable comptime result → clean build error + print("{}\n", L[0][0]); +} diff --git a/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.exit b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.exit @@ -0,0 +1 @@ +1 diff --git a/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stderr b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stderr new file mode 100644 index 00000000..c44909a8 --- /dev/null +++ b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stderr @@ -0,0 +1 @@ +error: comptime init of 'L' failed: reg→value: aggregate shape not bridged yet diff --git a/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stdout b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/diagnostics/expected/1204-diagnostics-comptime-run-unbridged-shape.stdout @@ -0,0 +1 @@ + diff --git a/issues/0182-body-local-comptime-run-unbridged-silent-miscompile.md b/issues/0182-body-local-comptime-run-unbridged-silent-miscompile.md index ee286182..cd5fe6cd 100644 --- a/issues/0182-body-local-comptime-run-unbridged-silent-miscompile.md +++ b/issues/0182-body-local-comptime-run-unbridged-silent-miscompile.md @@ -1,5 +1,26 @@ # 0182 — a body-local `#run` of an unbridged-shape return silently miscompiles (no abort, exit 0 garbage) +> **RESOLVED.** Root cause was deeper than "doesn't set comptime_failed": the +> body-local `#run` fold in `emitCall` (`src/backend/llvm/ops.zig`) was effectively +> DEAD — gated on `args.len==0`, but the `__ct` comptime wrapper always carries +> the implicit `*Context` arg — so EVERY body-local `#run` fell through to a +> RUNTIME call (bridgeable shapes lucked into the right value; unbridgeable ones +> ran over `---` storage → garbage). Fix: fold any `is_comptime` callee (gated +> `!enclosing.is_comptime` so nested metatype calls in a comptime wrapper's dead +> body aren't folded). On a `tryEval` bail, distinguish a BRIDGE bail (body ran, +> result shape can't `regToValue`-materialize → `error: comptime init of '' +> failed: ` + `comptime_failed`, build fails — symmetric with the global +> `#run` path) from an EXECUTION bail (VM can't run the body, e.g. NaN/extern → +> runtime fallthrough, preserving `examples/types/0150`), via a new +> `comptime_vm.last_bail_was_bridge` flag (reset at `tryEval` entry, set only at +> the `regToValue` step). The binding const's name is threaded onto the wrapper +> (`comptime_display_name`) so the diagnostic reads `'L'` not `__ct_N`. +> Regressions: `examples/diagnostics/1204-diagnostics-comptime-run-unbridged-shape.sx` +> (negative), `examples/comptime/0645-comptime-body-local-run-bridgeable.sx` +> (positive). Verified by 3 adversarial reviews; suite 801/0. (Note: a BARE +> inline `#run` of an unbridgeable shape correctly fails but names the internal +> `__ct_N` — a cosmetic diagnostic-name follow-up, build behavior is correct.) + ## Symptom A `#run` const declared INSIDE a function body, whose comptime function returns a diff --git a/src/backend/llvm/ops.zig b/src/backend/llvm/ops.zig index b4f6265b..243dd9e4 100644 --- a/src/backend/llvm/ops.zig +++ b/src/backend/llvm/ops.zig @@ -1305,10 +1305,30 @@ pub const Ops = struct { return; } - if (callee_func.is_comptime and call_op.args.len == 0) { - // Inline comptime-call fold: evaluate the zero-arg comptime callee on - // the VM (the sole evaluator) and splat its scalar/string result as a - // constant. A bail (null) falls through to the normal call path. + if (callee_func.is_comptime and !enclosing.is_comptime) { + // Inline comptime-call fold: a body-local `#run expr` lowers to a + // `call` of its `is_comptime` `__ct` wrapper. That wrapper is + // comptime-ONLY — it must NEVER survive as a runtime call (its body + // may read `---` storage / depend on comptime-only state). Evaluate + // it on the VM (the sole evaluator). + // + // GATE on `!enclosing.is_comptime`: only a call reached from a REAL + // runtime body (e.g. `main`) is an actual `#run` fold site. An + // `is_comptime` callee that appears INSIDE another comptime wrapper's + // body (`make_enum` / `declare` / `define` called from a `__ctype` + // type-fn wrapper) is DEAD LLVM — never executed — and the VM + // evaluates the whole wrapper itself; standalone-folding such a nested + // call would mis-`tryEval` it (wrong arg count) and emit a spurious + // failure. Leave those to the normal (dead) call path. + // + // For the live `#run` fold: + // - scalar / string result → splat as a constant (the common case); + // - a BAIL (`tryEval` null — e.g. an unbridgeable `[2][]i64` return) + // is a comptime-init FAILURE. Mirror the GLOBAL `#run` path + // (`emitGlobals` → `error: comptime init of 'X' failed: `, + // `comptime_failed`): emit the located diagnostic and gate the + // build, NEVER fall through to a runtime call over `---` storage + // (issue 0182 — that produced exit-0 garbage with no diagnostic). if (comptime_vm.tryEval(self.e.alloc, self.e.ir_mod, call_op.callee, &self.e.build_config, self.e.import_sources)) |result| { if (result.asInt()) |v| { self.e.mapRef(c.LLVMConstInt(self.e.toLLVMType(instruction.ty), @bitCast(v), 0)); @@ -1323,7 +1343,37 @@ pub const Ops = struct { self.e.mapRef(self.e.emitStringConstant(result.string)); return; } + // A non-scalar bridgeable result (struct / array / `?Arr`) the VM + // materialized successfully but this scalar fold can't splat. + // Its `__ct` body runs correctly at runtime, so fall through to + // the ordinary call path (the established, tested behavior — the + // result is well-defined data, not `---` garbage). Only a BAIL + // (handled below) signals an actual comptime failure. + } else if (comptime_vm.last_bail_was_bridge) { + // `tryEval` RAN the wrapper but could not BRIDGE its result shape + // to a host value (e.g. an unbridgeable `[2][]i64` — array of + // slices). Re-emitting a runtime `call` would re-run the SAME body + // over its (possibly `---`) storage and produce DIFFERENT garbage + // with no diagnostic — the exact silent miscompile of issue 0182. + // Mirror the GLOBAL `#run` path (`emitGlobals` → `error: comptime + // init of 'X' failed: `, `comptime_failed`): surface the + // bridge bail loudly and gate the build. + const fname = if (callee_func.comptime_display_name) |dn| + self.e.ir_mod.types.getString(dn) + else + self.e.ir_mod.types.getString(callee_func.name); + std.debug.print( + "error: comptime init of '{s}' failed: {s}\n", + .{ fname, comptime_vm.last_bail_reason orelse "" }, + ); + self.e.comptime_failed = true; + self.e.mapRef(c.LLVMGetUndef(self.e.toLLVMType(instruction.ty))); + return; } + // An EXECUTION bail (the VM couldn't run the body — an unported op, a + // VM `DivisionByZero` that the runtime computes as NaN, …): the + // established runtime-call fallback computes the correct value. Fall + // through to the ordinary call path — NOT a build failure. } const callee = self.e.func_map.get(call_op.callee.index()) orelse { self.e.mapRef(c.LLVMGetUndef(self.e.toLLVMType(instruction.ty))); diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index 8184af7b..07276d40 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -182,6 +182,19 @@ pub const Frame = struct { /// the top of every `tryEval`; meaningful only when `tryEval` returned `null`. pub var last_bail_reason: ?[]const u8 = null; +/// True iff the most recent `tryEval` bail happened at the RESULT-BRIDGE step +/// (`regToValue` — the comptime function RAN to completion but its result shape +/// cannot be materialized into a host `Value`), as opposed to an EXECUTION bail +/// (`runEntry` couldn't evaluate the body — an unported op, a VM +/// `DivisionByZero`, etc.). The distinction matters for a body-local `#run` +/// fold (issue 0182): a BRIDGE bail means a runtime re-execution would run the +/// SAME body over (possibly `---`) storage and produce DIFFERENT, garbage data — +/// a silent miscompile that must fail the build. An EXECUTION bail means the VM +/// simply can't run it; the established runtime-call fallback computes the +/// correct value and must be preserved. Meaningful only when `tryEval` returned +/// `null`; cleared at the top of every `tryEval`. +pub var last_bail_was_bridge: bool = false; + /// Wiring entry point: try to evaluate comptime function `func_id` entirely on the /// comptime 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 @@ -194,6 +207,7 @@ pub var last_bail_reason: ?[]const u8 = null; /// 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, build_config: ?*compiler_hooks.BuildConfig, source_map: ?*const std.StringHashMap([:0]const u8)) ?Value { last_bail_reason = null; + last_bail_was_bridge = false; const func = module.getFunction(func_id); if (func.is_extern or func.blocks.items.len == 0) { last_bail_reason = "extern / no body"; @@ -219,6 +233,10 @@ pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod. // `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| { + // The body RAN; only the result bridge failed → mark this a BRIDGE bail + // so a body-local `#run` fold can tell a genuine "result can't be + // materialized" miscompile from a "VM can't run it" fallback (issue 0182). + last_bail_was_bridge = true; last_bail_reason = vm.detail orelse @errorName(err); return null; }; diff --git a/src/ir/inst.zig b/src/ir/inst.zig index 93c34c22..30a40010 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -640,6 +640,13 @@ pub const Function = struct { /// drops the leftover declaration. See current/PLAN-COMPILER-VM.md (S3). is_compiler_domain: bool = false, + /// For a body-local `#run` wrapper (`L :: #run f()` → an `is_comptime` + /// `__ct_N` function): the user-facing const NAME the `#run` initializes, so + /// a comptime-init failure can report `comptime init of 'L' failed` (issue + /// 0182) rather than the internal `__ct_N` wrapper name. Null when the `#run` + /// is not bound to a named const (a bare inline `#run`). + comptime_display_name: ?StringId = null, + /// True for an `abi(.naked)` function — no calling-convention /// prologue/epilogue/frame, no implicit `__sx_ctx`. Its body is a single /// inline-asm block that reads args from ABI registers and emits its own diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 2a3a9b46..4d4d9043 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -222,6 +222,12 @@ pub const Lowering = struct { suppress_int_fit_check: bool = false, // inside an explicit `xx` cast operand: truncation is requested, skip the literal fits-check block_counter: u32 = 0, comptime_counter: u32 = 0, + /// Transient: the user-facing const name of the body-local `#run` currently + /// being lowered (`L :: #run f()`), so `lowerInlineComptime` can stamp the + /// `__ct` wrapper's `comptime_display_name` for a friendly comptime-init + /// failure diagnostic (issue 0182). Set/cleared around the const's value + /// lowering; null for a bare inline `#run`. + comptime_const_name: ?[]const u8 = null, main_file: ?[]const u8 = null, // path of the main file; imported functions are declared extern resolved_root: ?*const Node = null, // full AST root (for building comptime modules) comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions diff --git a/src/ir/lower/comptime.zig b/src/ir/lower/comptime.zig index d8e465bb..52043f81 100644 --- a/src/ir/lower/comptime.zig +++ b/src/ir/lower/comptime.zig @@ -337,6 +337,11 @@ pub fn lowerComptimeSideEffect(self: *Lowering, expr: *const Node) void { pub fn lowerInlineComptime(self: *Lowering, expr: *const Node) Ref { const ret_ty: TypeId = self.target_type orelse self.inferExprType(expr); const func_id = self.createComptimeFunction("__ct", expr, ret_ty); + // Carry the binding const's name (when this `#run` initializes one) onto the + // wrapper so a comptime-init failure names the user const, not `__ct_N`. + if (self.comptime_const_name) |cname| { + self.module.getFunctionMut(func_id).comptime_display_name = self.module.types.internString(cname); + } // Emit a call to the comptime function. At interpretation time, // this will be evaluated and the result inlined as a constant. const func = &self.module.functions.items[@intFromEnum(func_id)]; diff --git a/src/ir/lower/stmt.zig b/src/ir/lower/stmt.zig index 5b406601..74f2170f 100644 --- a/src/ir/lower/stmt.zig +++ b/src/ir/lower/stmt.zig @@ -478,6 +478,13 @@ pub fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void { return; } + // For a body-local `#run` const (`L :: #run f()`), record the const NAME so + // the `__ct` wrapper carries it as a display name — a comptime-init failure + // then reports `comptime init of 'L' failed` instead of `__ct_N` (issue 0182). + const saved_ct_name = self.comptime_const_name; + if (cd.value.data == .comptime_expr) self.comptime_const_name = cd.name; + defer self.comptime_const_name = saved_ct_name; + const ref = self.lowerExpr(cd.value); // If there's an explicit type annotation, use it. Otherwise, infer from the expression. const ty = if (cd.type_annotation) |ta|