ERR/E3.2: wire trace push/clear into raise/try/catch/or/destructure

Connect the E3.1 buffer to codegen. Push sites: `raise` (always escapes — push
before cleanup) and `try`'s propagation branch (the failure that escapes to the
caller). Clear sites: `catch` handler entry (via runCatchBody, error path only),
the `or value` terminator's failure branch, and a destructure that binds a
failable's error slot — so an absorbed failure leaves no residue.

Helpers in lower.zig: emitTracePush / emitTraceClear (call getTraceFids, no-op
when traces are off), tracesEnabled (opt_level == .none/.less — `sx run`
defaults to -O0, so on in dev; .default/.aggressive are release → off, zero
overhead), and placeholderTraceFrame (a nonzero u64 until DWARF/E3.0 supplies
real PCs and E3.3 resolves them).

Verified end-to-end via a #foreign sx_trace_len probe: catch/or/multi-slot-
destructure drive len back to 0; release (--opt default) emits no push/clear at
all (debug showed a residual where release showed 0).

examples/241-error-trace-buffer.sx is a focused regression (white-box: reads
sx_trace_len directly, pending E3.3's public trace.print_current).

KNOWN GAP (documented, deferred to the E1.8 flow-check binding-site work): a
single-binding capture of a PURE failable (`er := pure_failable()`, not a
comma destructure) goes through lowerVarDecl, not lowerDestructureDecl, so it
doesn't clear — the trace over-retains until the next absorbing site. Harmless
today (nothing reads the buffer at function exit yet) but wrong per spec.

Gates: zig build, zig build test, bash tests/run_examples.sh (278 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
This commit is contained in:
agra
2026-06-01 08:28:46 +03:00
parent 51f5277380
commit ea40724b61
4 changed files with 102 additions and 4 deletions

View File

@@ -8387,6 +8387,11 @@ pub const Lowering = struct {
scope.put(name, .{ .ref = slot, .ty = field_ty, .is_alloca = true });
}
}
// Destructuring a failable's result binds the error slot to a variable:
// the user now owns the error explicitly, so the trace is absorbed
// (ERR E3.2). A plain (non-failable) tuple destructure clears nothing.
if (self.errorChannelOf(ty) != null) self.emitTraceClear();
}
// ── Comptime lowering ────────────────────────────────────────────
@@ -13059,6 +13064,43 @@ pub const Lowering = struct {
return .{ .push = self.trace_push_fid.?, .clear = self.trace_clear_fid.? };
}
/// Error return-traces are emitted in debug-ish builds and skipped in
/// release (ERR E3.2 build-mode gating). `sx run` defaults to `-O0`
/// (`.none`), the common dev path; `.default`/`.aggressive` are release.
/// The spec's `--release-traces` opt-in + a `BuildOptions.error_traces`
/// accessor are a later refinement; for now the opt level is the gate.
fn tracesEnabled(self: *Lowering) bool {
const tc = self.target_config orelse return true; // no target → treat as debug
return tc.opt_level == .none or tc.opt_level == .less;
}
/// Emit a trace-buffer push of `frame` (an opaque u64) at a failure site.
/// No-op when traces are disabled (release). `frame` is a placeholder until
/// DWARF (E3.0) supplies real return-address PCs and E3.3 resolves them.
fn emitTracePush(self: *Lowering, frame: Ref) void {
if (!self.tracesEnabled()) return;
const fids = self.getTraceFids();
const coerced = self.coerceToType(frame, self.builder.getRefType(frame), .u64);
const args = self.alloc.dupe(Ref, &.{coerced}) catch return;
_ = self.builder.emit(.{ .call = .{ .callee = fids.push, .args = args } }, .void);
}
/// Emit a trace-buffer clear at an absorbing site (`catch` / `or value` /
/// destructure). No-op when traces are disabled.
fn emitTraceClear(self: *Lowering) void {
if (!self.tracesEnabled()) return;
const fids = self.getTraceFids();
_ = self.builder.emit(.{ .call = .{ .callee = fids.clear, .args = &.{} } }, .void);
}
/// A placeholder trace frame for a failure site (ERR E3.2). Until DWARF
/// (E3.0) provides return-address PCs, push a nonzero constant so the buffer
/// records that a frame occurred (the formatter notes "frame unavailable").
/// Nonzero because frame value 0 is the buffer's out-of-range / empty marker.
fn placeholderTraceFrame(self: *Lowering) Ref {
return self.builder.constInt(1, .u64);
}
/// When a namespaced import (`Ns :: #import "..."`) contains foreign-class
/// declarations, ALSO register them under their qualified name `Ns.Class`
/// so receiver types like `*Ns.Class` can find the fcd. The recursive
@@ -15329,7 +15371,11 @@ pub const Lowering = struct {
}
}
// (3) Emit the failure return. Pure-failable: the return type IS the
// (3) Push a trace frame: `raise` always escapes the function (ERR E3.2).
// Before cleanup, so the frame records the raise site itself.
self.emitTracePush(self.placeholderTraceFrame());
// (4) Emit the failure return. Pure-failable: the return type IS the
// error set, so return the tag value directly.
if (ret_ty == err_set) {
const tag_ty = self.builder.getRefType(tag_ref);
@@ -15541,10 +15587,13 @@ pub const Lowering = struct {
const ok_bb = self.freshBlock("try.ok");
self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});
// Propagation: run the function's cleanups (defers + onfails, since
// this is an error exit), then return the caller's failure carrying
// this tag (pure caller → `ret(tag)`; value-carrying → `ret {undef…, tag}`).
// Propagation: push a trace frame (this `try` failure escapes to the
// caller — ERR E3.2), run the function's cleanups (defers + onfails,
// since this is an error exit), then return the caller's failure
// carrying this tag (pure caller → `ret(tag)`; value-carrying →
// `ret {undef…, tag}`).
self.builder.switchToBlock(prop_bb);
self.emitTracePush(self.placeholderTraceFrame());
self.emitErrorCleanup(self.func_defer_base, err_val);
self.emitErrorReturn(caller_ret, caller_set, err_val);
@@ -15667,6 +15716,10 @@ pub const Lowering = struct {
/// catch), returns the body's value (or null if the body diverged); when
/// null (pure-failable catch), runs the body for effect and returns null.
fn runCatchBody(self: *Lowering, ce: *const ast.CatchExpr, err_val: Ref, err_set: TypeId, want_ty: ?TypeId) ?Ref {
// `catch` absorbs the LHS's failure: clear the trace buffer before the
// handler runs (ERR E3.2), so a failure consumed here leaves no residue.
// Runs on the error/handle path only (this fn is called from handle_bb).
self.emitTraceClear();
var handle_scope = Scope.init(self.alloc, self.scope);
const saved_scope = self.scope;
self.scope = &handle_scope;
@@ -15731,6 +15784,9 @@ pub const Lowering = struct {
self.builder.condBr(is_err, fail_bb, &.{}, merge_bb, &.{succ_val});
self.builder.switchToBlock(fail_bb);
// The `or value` terminator absorbs the LHS's failure: clear the trace
// buffer before producing the fallback (ERR E3.2).
self.emitTraceClear();
const saved_target = self.target_type;
self.target_type = succ_ty;
const rhs_val = self.lowerExpr(bop.rhs);