fix: body-local #run of an unbridged shape fails loudly instead of silent garbage (issue 0182)
The body-local #run fold in emitCall 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; an unbridgeable shape (e.g. [2][]i64) ran over --- storage -> garbage, exit 0, no diagnostic. 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 (result can't regToValue- materialize -> error: comptime init of 'X' failed: <reason> + 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 types/0150), via comptime_vm.last_bail_was_bridge (reset at tryEval entry, set only at regToValue). The const name is threaded onto the wrapper (comptime_display_name) so the diagnostic reads the source name, not __ct_N. Regressions: diagnostics/1204 (negative), comptime/0645 (positive). Verified by 3 adversarial reviews, suite 801/0.
This commit is contained in:
28
examples/comptime/0645-comptime-body-local-run-bridgeable.sx
Normal file
28
examples/comptime/0645-comptime-body-local-run-bridgeable.sx
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
N=42
|
||||||
|
S=3 4
|
||||||
|
A=10 20 30
|
||||||
|
O=2
|
||||||
@@ -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 '<name>' failed: <reason>`
|
||||||
|
// 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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
error: comptime init of 'L' failed: reg→value: aggregate shape not bridged yet
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
# 0182 — a body-local `#run` of an unbridged-shape return silently miscompiles (no abort, exit 0 garbage)
|
# 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 '<name>'
|
||||||
|
> failed: <reason>` + `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
|
## Symptom
|
||||||
|
|
||||||
A `#run` const declared INSIDE a function body, whose comptime function returns a
|
A `#run` const declared INSIDE a function body, whose comptime function returns a
|
||||||
|
|||||||
@@ -1305,10 +1305,30 @@ pub const Ops = struct {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callee_func.is_comptime and call_op.args.len == 0) {
|
if (callee_func.is_comptime and !enclosing.is_comptime) {
|
||||||
// Inline comptime-call fold: evaluate the zero-arg comptime callee on
|
// Inline comptime-call fold: a body-local `#run expr` lowers to a
|
||||||
// the VM (the sole evaluator) and splat its scalar/string result as a
|
// `call` of its `is_comptime` `__ct` wrapper. That wrapper is
|
||||||
// constant. A bail (null) falls through to the normal call path.
|
// 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: <reason>`,
|
||||||
|
// `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 (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| {
|
if (result.asInt()) |v| {
|
||||||
self.e.mapRef(c.LLVMConstInt(self.e.toLLVMType(instruction.ty), @bitCast(v), 0));
|
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));
|
self.e.mapRef(self.e.emitStringConstant(result.string));
|
||||||
return;
|
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: <reason>`, `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 "<unknown>" },
|
||||||
|
);
|
||||||
|
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 {
|
const callee = self.e.func_map.get(call_op.callee.index()) orelse {
|
||||||
self.e.mapRef(c.LLVMGetUndef(self.e.toLLVMType(instruction.ty)));
|
self.e.mapRef(c.LLVMGetUndef(self.e.toLLVMType(instruction.ty)));
|
||||||
|
|||||||
@@ -182,6 +182,19 @@ pub const Frame = struct {
|
|||||||
/// the top of every `tryEval`; meaningful only when `tryEval` returned `null`.
|
/// the top of every `tryEval`; meaningful only when `tryEval` returned `null`.
|
||||||
pub var last_bail_reason: ?[]const u8 = 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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 {
|
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_reason = null;
|
||||||
|
last_bail_was_bridge = false;
|
||||||
const func = module.getFunction(func_id);
|
const func = module.getFunction(func_id);
|
||||||
if (func.is_extern or func.blocks.items.len == 0) {
|
if (func.is_extern or func.blocks.items.len == 0) {
|
||||||
last_bail_reason = "extern / no body";
|
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.
|
// `regToValue` would bail on the void type, so yield `.void_val` directly.
|
||||||
if (func.ret == .void or func.ret == .noreturn) return .void_val;
|
if (func.ret == .void or func.ret == .noreturn) return .void_val;
|
||||||
return vm.regToValue(gpa, &module.types, reg, func.ret) catch |err| {
|
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);
|
last_bail_reason = vm.detail orelse @errorName(err);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -640,6 +640,13 @@ pub const Function = struct {
|
|||||||
/// drops the leftover declaration. See current/PLAN-COMPILER-VM.md (S3).
|
/// drops the leftover declaration. See current/PLAN-COMPILER-VM.md (S3).
|
||||||
is_compiler_domain: bool = false,
|
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
|
/// True for an `abi(.naked)` function — no calling-convention
|
||||||
/// prologue/epilogue/frame, no implicit `__sx_ctx`. Its body is a single
|
/// 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
|
/// inline-asm block that reads args from ABI registers and emits its own
|
||||||
|
|||||||
@@ -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
|
suppress_int_fit_check: bool = false, // inside an explicit `xx` cast operand: truncation is requested, skip the literal fits-check
|
||||||
block_counter: u32 = 0,
|
block_counter: u32 = 0,
|
||||||
comptime_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
|
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)
|
resolved_root: ?*const Node = null, // full AST root (for building comptime modules)
|
||||||
comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions
|
comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions
|
||||||
|
|||||||
@@ -337,6 +337,11 @@ pub fn lowerComptimeSideEffect(self: *Lowering, expr: *const Node) void {
|
|||||||
pub fn lowerInlineComptime(self: *Lowering, expr: *const Node) Ref {
|
pub fn lowerInlineComptime(self: *Lowering, expr: *const Node) Ref {
|
||||||
const ret_ty: TypeId = self.target_type orelse self.inferExprType(expr);
|
const ret_ty: TypeId = self.target_type orelse self.inferExprType(expr);
|
||||||
const func_id = self.createComptimeFunction("__ct", expr, ret_ty);
|
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,
|
// Emit a call to the comptime function. At interpretation time,
|
||||||
// this will be evaluated and the result inlined as a constant.
|
// this will be evaluated and the result inlined as a constant.
|
||||||
const func = &self.module.functions.items[@intFromEnum(func_id)];
|
const func = &self.module.functions.items[@intFromEnum(func_id)];
|
||||||
|
|||||||
@@ -478,6 +478,13 @@ pub fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void {
|
|||||||
return;
|
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);
|
const ref = self.lowerExpr(cd.value);
|
||||||
// If there's an explicit type annotation, use it. Otherwise, infer from the expression.
|
// If there's an explicit type annotation, use it. Otherwise, infer from the expression.
|
||||||
const ty = if (cd.type_annotation) |ta|
|
const ty = if (cd.type_annotation) |ta|
|
||||||
|
|||||||
Reference in New Issue
Block a user