ERR/E5.2: comptime #run of an escaping failable → diagnostic + halt

A bare failable `#run` (no catch/or) whose error escapes used to segfault (const
form `x :: #run f()`) or silently succeed (statement form `#run f();`). Now the
compiler reports the raised tag name + the resolved return trace at the #run site
and halts with a non-zero exit.

- lower.zig: a failable #run's comptime function returns the full failable tuple
  (so the error slot is inspectable) while the global is typed as the success
  value; failable side-effects return the tuple instead of void.
- emit_llvm.zig: read the always-on comptime trace buffer (extern sx_trace_*);
  comptimeErrChannel + checkComptimeFailable split the result (non-zero tag →
  reportComptimeEscape + comptime_failed flag; success → value part). Wired into
  emitGlobals (const) and runComptimeSideEffects (statement, now filtered by the
  __run name; buffer cleared before each eval).
- core.zig: generateCode returns error.ComptimeError when comptime_failed, so the
  driver aborts before JIT/link.

catch / or / onfail compose at comptime exactly as at runtime; a successful bare
#run yields the value. Regressions: examples/1037-errors-comptime-run-escape
(diagnostic, exit 1) + 1038-errors-comptime-run-handled (exit 164). Suite: 326.
This commit is contained in:
agra
2026-06-01 20:04:17 +03:00
parent 9e660f30c2
commit 549f97c731
11 changed files with 215 additions and 21 deletions

View File

@@ -0,0 +1,18 @@
// Comptime `#run` of a failable whose error ESCAPES (no `catch` / `or`): the
// compiler reports the raised tag name + the return trace at the `#run` site and
// halts with a non-zero exit (E5.2). Before this, a bare failable `#run`
// segfaulted (const form) or silently succeeded (statement form).
#import "modules/std.sx";
E :: error { Bad, Empty }
parse :: (n: s32) -> (s32, !E) {
if n < 0 { raise error.Bad; }
if n == 0 { raise error.Empty; }
return n * 2;
}
x :: #run parse(-1); // error.Bad escapes → comptime diagnostic + halt
main :: () -> s32 { return x; }

View File

@@ -0,0 +1,31 @@
// Comptime `#run` of a failable composes with the handlers exactly as at
// runtime: `catch` absorbs, `or` terminates, a successful bare `#run` yields the
// value (error channel stripped), and an `onfail` in the evaluated body still
// runs during comptime unwinding (E5.2).
#import "modules/std.sx";
E :: error { Bad, Empty }
parse :: (n: s32) -> (s32, !E) {
if n < 0 { raise error.Bad; }
if n == 0 { raise error.Empty; }
return n * 2;
}
guard :: (ok: bool) -> !E {
onfail print("comptime cleanup\n");
if !ok { raise error.Bad; }
return;
}
ok_v :: #run parse(5); // success → 10 (value, error stripped)
caught :: #run parse(-1) catch e 99; // Bad → 99
ored :: #run parse(0) or 55; // Empty → 55
#run guard(false) catch e { }; // onfail fires during the comptime unwind
main :: () -> s32 {
print("ok={} caught={} ored={}\n", ok_v, caught, ored);
return ok_v + caught + ored; // 10 + 99 + 55 = 164
}

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,4 @@
error: comptime `#run` (x) raised an unhandled error: error.Bad
error return trace (most recent call last):
parse at 1037-errors-comptime-run-escape.sx:11:17
help: handle it at the `#run` site — `#run <expr> catch e { ... }` or `#run <expr> or <default>`

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
164

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
comptime cleanup
--- build done ---
ok=10 caught=99 ored=55

View File

@@ -152,6 +152,9 @@ pub const Compilation = struct {
// callbacks can re-enter the interpreter via `invokeByName`.
self.ir_module = ir_mod_ptr;
self.ir_emitter = emitter;
// A comptime `#run` raised an unhandled error — the diagnostic + trace
// were already printed to stderr; abort before JIT/link (E5.2).
if (emitter.comptime_failed) return error.ComptimeError;
}
/// Re-enter the IR interpreter after `generateCode` (and after linking,

View File

@@ -27,6 +27,14 @@ const interp_mod = @import("interp.zig");
const Interpreter = interp_mod.Interpreter;
const Value = interp_mod.Value;
// The vendored error-trace ring buffer (library/vendors/sx_trace_runtime/sx_trace.c)
// is linked into the compiler. Comptime `#run` evaluation pushes frames to it via
// foreign `sx_trace_push` calls; after a `#run` we read it here to render the
// return trace for an escaping comptime error (E5.2).
extern fn sx_trace_len() u32;
extern fn sx_trace_frame_at(i: u32) u64;
extern fn sx_trace_clear() void;
fn isIdentByte(b: u8) bool {
return (b >= 'a' and b <= 'z') or (b >= 'A' and b <= 'Z') or (b >= '0' and b <= '9') or b == '_';
}
@@ -92,6 +100,10 @@ pub const LLVMEmitter = struct {
// IR Module being emitted
ir_mod: *const Module,
// Set when a comptime `#run` raised an unhandled error (E5.2). The driver
// (core.generateCode) aborts with a non-zero exit after emit() when set.
comptime_failed: bool = false,
// Allocator for temporary bookkeeping
alloc: Allocator,
@@ -1329,36 +1341,123 @@ pub const LLVMEmitter = struct {
}
}
/// The error-set channel of a (possibly failable) type: the set itself for
/// a pure `-> !` result, or the last tuple slot for `-> (T..., !)`. null if
/// the type carries no error channel. Mirror of lower.errorChannelOf.
fn comptimeErrChannel(self: *LLVMEmitter, ty: TypeId) ?TypeId {
if (ty.isBuiltin()) return null;
switch (self.ir_mod.types.get(ty)) {
.error_set => return ty,
.tuple => |t| {
if (t.fields.len == 0) return null;
const last = t.fields[t.fields.len - 1];
if (last.isBuiltin()) return null;
return if (self.ir_mod.types.get(last) == .error_set) last else null;
},
else => return null,
}
}
/// Inspect a failable `#run` result. On a non-zero error tag, print the
/// comptime-error diagnostic + return trace, flag compilation failed, and
/// return null. On success, return the value part (error channel stripped):
/// `void_val` for a pure failable, the lone value for `(T, !)`, the
/// value-tuple for multi-value. (E5.2)
fn checkComptimeFailable(self: *LLVMEmitter, result: Value, fail_ty: TypeId, label: []const u8) ?Value {
const channel = self.comptimeErrChannel(fail_ty) orelse return result;
var tag: u32 = 0;
var success: Value = .void_val;
if (channel == fail_ty) {
// pure failable — the result IS the error tag (u32)
tag = switch (result) {
.int => |v| @truncate(@as(u64, @bitCast(v))),
else => 0,
};
} else {
// value-carrying — the result is the `{values..., tag}` aggregate
const fields = switch (result) {
.aggregate => |f| f,
else => return result,
};
if (fields.len == 0) return result;
tag = switch (fields[fields.len - 1]) {
.int => |v| @truncate(@as(u64, @bitCast(v))),
else => 0,
};
success = if (fields.len == 2) fields[0] else .{ .aggregate = fields[0 .. fields.len - 1] };
}
if (tag == 0) {
sx_trace_clear();
return success;
}
self.reportComptimeEscape(label, tag);
sx_trace_clear();
self.comptime_failed = true;
return null;
}
/// Print the locked comptime-escape diagnostic: the raised tag name, the
/// resolved return trace from the thread-local buffer, and a help line.
fn reportComptimeEscape(self: *LLVMEmitter, label: []const u8, tag: u32) void {
const tname = self.ir_mod.types.tags.getName(tag);
std.debug.print("error: comptime `#run` ({s}) raised an unhandled error: error.{s}\n", .{ label, tname });
const n = sx_trace_len();
if (n > 0) {
std.debug.print("error return trace (most recent call last):\n", .{});
var i: u32 = 0;
while (i < n) : (i += 1) {
const packed_frame = sx_trace_frame_at(i);
const fid: u32 = @intCast(packed_frame >> 32);
const offset: u32 = @truncate(packed_frame);
if (fid >= self.ir_mod.functions.items.len) continue;
const func = self.ir_mod.getFunction(FuncId.fromIndex(fid));
const fname = self.ir_mod.types.getString(func.name);
const file_full = func.source_file orelse "";
const file = std.fs.path.basename(file_full);
var line: usize = 1;
var col: usize = 1;
if (self.import_sources) |sm| {
if (sm.get(file_full)) |src| {
const loc = errors.SourceLoc.compute(src, offset);
line = loc.line;
col = loc.col;
}
}
std.debug.print(" {s} at {s}:{d}:{d}\n", .{ fname, file, line, col });
}
}
std.debug.print("help: handle it at the `#run` site — `#run <expr> catch e {{ ... }}` or `#run <expr> or <default>`\n", .{});
}
/// Run comptime side-effect functions (e.g., `#run main();` at top level).
/// These are functions marked `is_comptime = true` with void return that
/// aren't associated with any global. They produce compile-time output.
fn runComptimeSideEffects(self: *LLVMEmitter) void {
for (self.ir_mod.functions.items, 0..) |func, i| {
if (!func.is_comptime or func.ret != .void) continue;
// Skip functions that are global initializers (already run by emitGlobals)
var is_global_init = false;
for (self.ir_mod.globals.items) |global| {
if (global.comptime_func) |gf| {
if (@intFromEnum(gf) == i) {
is_global_init = true;
break;
}
}
}
if (is_global_init) continue;
// `#run expr;` side-effects are the `__run_N` wrappers (global
// initializers are named after their global and run by emitGlobals;
// inline `__ct`/`__insert` wrappers run from their call sites). A
// failable side-effect carries the error channel on `func.ret`.
if (!func.is_comptime) continue;
const fname = self.ir_mod.types.getString(func.name);
if (!std.mem.startsWith(u8, fname, "__run")) continue;
// Run the side-effect function via interpreter
const func_id = ir_inst.FuncId.fromIndex(@intCast(i));
var interp_inst = Interpreter.init(self.ir_mod, self.alloc);
interp_inst.build_config = &self.build_config;
if (self.import_sources) |sm| interp_inst.setSourceMap(sm);
_ = interp_inst.call(func_id, &.{}) catch {};
sx_trace_clear();
const result = interp_inst.call(func_id, &.{}) catch Value.void_val;
// Route #run `print` output to fd 1 so it joins the
// JIT-executed runtime's stream. Same call site shape as
// `core.flushInterpOutput` — see issue-0047.
if (interp_inst.output.items.len > 0) {
_ = std.c.write(1, interp_inst.output.items.ptr, interp_inst.output.items.len);
}
// A bare failable `#run f();` whose error escapes → diagnostic + halt.
if (self.comptimeErrChannel(func.ret) != null) {
_ = self.checkComptimeFailable(result, func.ret, "top-level statement");
}
interp_inst.deinit();
}
}
@@ -1394,6 +1493,7 @@ pub const LLVMEmitter = struct {
Interpreter.last_bail_op = null;
Interpreter.last_bail_builtin = null;
Interpreter.last_bail_detail = null;
sx_trace_clear();
const result = interp_inst.call(func_id, &.{}) catch |err| blk: {
// Surface the bail loudly instead of silently filling
// the const with zero. Stale state from a previous
@@ -1405,7 +1505,22 @@ pub const LLVMEmitter = struct {
std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail });
break :blk .void_val;
};
const init_val = self.valueToLLVMConst(result, global.ty, &interp_inst, self.ir_mod.types.getString(global.name));
// A bare failable `NAME :: #run f();`: the comptime function
// returns the failable tuple; split it. Escaping error →
// diagnostic + halt (leave the global undef); success → the
// value part materializes into the global's success type (E5.2).
const cf_ret = self.ir_mod.getFunction(func_id).ret;
var init_value = result;
if (self.comptimeErrChannel(cf_ret) != null) {
if (self.checkComptimeFailable(result, cf_ret, self.ir_mod.types.getString(global.name))) |succ| {
init_value = succ;
} else {
c.LLVMSetInitializer(llvm_global, c.LLVMGetUndef(llvm_ty));
self.global_map.put(@intCast(i), llvm_global) catch {};
continue;
}
}
const init_val = self.valueToLLVMConst(init_value, global.ty, &interp_inst, self.ir_mod.types.getString(global.name));
c.LLVMSetInitializer(llvm_global, init_val);
} else if (global.init_val) |iv| {
const init_val = switch (iv) {

View File

@@ -8486,30 +8486,46 @@ pub const Lowering = struct {
// infer the global's type from the comptime expression's return
// shape. `resolveType(null)` returns `.s64` for legacy reasons —
// good for primitive helpers, silently wrong for anything else.
const ret_ty: TypeId = if (type_ann) |n|
const expr_ty = self.inferExprType(expr);
// A failable `#run` (bare, no `catch`/`or`): the comptime function
// returns the full failable tuple so the #run site can inspect the
// error slot, but the GLOBAL is typed as the success value. On a
// comptime error the global never materializes — emit halts with a
// diagnostic + trace (E5.2). A handled `#run … catch/or …` already
// strips the error channel, so it lands here as non-failable.
const is_failable = self.errorChannelOf(expr_ty) != null;
const func_ret: TypeId = if (is_failable)
expr_ty
else if (type_ann) |n|
self.resolveTypeWithBindings(n)
else
self.inferExprType(expr);
const func_id = self.createComptimeFunction(name, expr, ret_ty);
expr_ty;
const global_ty: TypeId = if (is_failable) self.failableSuccessType(expr_ty) else func_ret;
const func_id = self.createComptimeFunction(name, expr, func_ret);
// Add a global constant whose initializer will be filled by the interpreter.
const name_id = self.module.types.internString(name);
const gid = self.module.addGlobal(.{
.name = name_id,
.ty = ret_ty,
.ty = global_ty,
.init_val = null, // will be filled by interpreter at emit time
.is_const = true,
.comptime_func = func_id,
});
// Register for runtime lookup: identifier resolution emits global_get
self.global_names.put(name, .{ .id = gid, .ty = ret_ty }) catch {};
self.global_names.put(name, .{ .id = gid, .ty = global_ty }) catch {};
}
/// Lower a standalone `#run expr;` at the top level (side-effect only).
/// Creates a comptime function that the interpreter should execute.
fn lowerComptimeSideEffect(self: *Lowering, expr: *const Node) void {
_ = self.createComptimeFunction("__run", expr, .void);
// A failable side-effect `#run f();` returns the failable tuple so the
// emit-time runner can detect an escaping error and halt (E5.2);
// non-failable side effects stay `void`.
const expr_ty = self.inferExprType(expr);
const ret: TypeId = if (self.errorChannelOf(expr_ty) != null) expr_ty else .void;
_ = self.createComptimeFunction("__run", expr, ret);
}
/// Lower a `#run expr` that appears inline within an expression.