From 51f52773803554e8faf6d3e2a1c75d227e320bf7 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 08:13:12 +0300 Subject: [PATCH] ERR/E3.1: thread-local error return-trace ring buffer (runtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the trace buffer that raise/try push to and catch/or/destructure clear, following the JNI-TLS precedent exactly (a thread_local IR global doesn't work under the ORC JIT, which doesn't init TLS for AddObjectFile'd objects). - library/vendors/sx_trace_runtime/sx_trace.c: a `_Thread_local` fixed-cap ring (32 frames) of opaque u64s + C API (push / clear / len / truncated / frame_at). Overflow keeps the newest CAP frames and latches `truncated` (Zig-style); frame_at returns oldest-to-newest. The frame is opaque — the E3.3 formatter dispatches on context (PC at runtime, packed (func_id, offset) at comptime). - build.zig: link the .c into the compiler so the JIT resolves sx_trace_* via dlsym (and so the unit test links against it). - src/runtime_trace.test.zig: exercises push / overflow-survives-newest / clear / len / truncated / ordering against the linked C — grounds the buffer logic without shipping throwaway sx builtins. - lower.zig getTraceFids(): lazily declares the sx_trace_push/clear externs + sets needs_trace_runtime. Declared now; the raise/try push sites and the absorbing clear sites get wired at E3.2. - core.zig: auto-injects the .c as a #source for AOT when needs_trace_runtime, mirroring the JNI env runtime. Gates: zig build, zig build test (incl. the new buffer tests), bash tests/run_examples.sh (277 passed; no codegen change this step — lone failure is the user's uncommitted 213-canonical-map pack WIP). --- build.zig | 8 +++ library/vendors/sx_trace_runtime/sx_trace.c | 69 +++++++++++++++++++ src/core.zig | 14 ++++ src/ir/lower.zig | 30 +++++++++ src/root.zig | 1 + src/runtime_trace.test.zig | 75 +++++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 library/vendors/sx_trace_runtime/sx_trace.c create mode 100644 src/runtime_trace.test.zig diff --git a/build.zig b/build.zig index 7746e32..3663992 100644 --- a/build.zig +++ b/build.zig @@ -36,6 +36,14 @@ pub fn build(b: *std.Build) void { .file = b.path("library/vendors/sx_jni_runtime/sx_jni_env_tl.c"), .flags = &.{}, }); + // ERR E3.1 runtime — `_Thread_local` error return-trace ring buffer. + // Same linkage rationale as the JNIEnv* slot above: linked into the + // compiler so the JIT resolves `sx_trace_*` via dlsym; AOT outputs pick it + // up via a lower-side auto-injected c_import (gated on needs_trace_runtime). + mod.addCSourceFile(.{ + .file = b.path("library/vendors/sx_trace_runtime/sx_trace.c"), + .flags = &.{}, + }); mod.addCSourceFile(.{ .file = b.path("clang_shim.cpp"), .flags = &.{ diff --git a/library/vendors/sx_trace_runtime/sx_trace.c b/library/vendors/sx_trace_runtime/sx_trace.c new file mode 100644 index 0000000..c7d05cd --- /dev/null +++ b/library/vendors/sx_trace_runtime/sx_trace.c @@ -0,0 +1,69 @@ +// Error return-trace buffer (ERR step E3.1). +// +// Thread-local fixed-cap ring of trace frames. A `raise` pushes one frame at +// the raise site; a `try` pushes one on its failure path; absorbing sites +// (`catch` / `or value` / destructure) clear it. The frame is an opaque +// `uint64_t` — the formatter (E3.3) dispatches on build context: at runtime a +// frame is a return-address PC (resolved via DWARF), at comptime it is a packed +// `(func_id, ir_offset)` (resolved via the interpreter's IR tables). The buffer +// neither knows nor cares which; it just stores u64s. +// +// Lives in a separately-linked C helper (NOT an emitted `thread_local` IR +// global) for the same reason as `sx_jni_env_tl.c`: LLVM ORC JIT's default +// platform support doesn't initialise TLS for objects added via +// `LLVMOrcLLJITAddObjectFile`. The host (sx-the-compiler) links this .c so the +// JIT's process-symbol generator resolves these functions via dlsym; AOT +// targets pick up the same .c as an auto-injected `#source` (see core.zig, +// gated on `Lowering.needs_trace_runtime`). +// +// Overflow policy (Zig-style): the newest frames survive — once the ring is +// full, the oldest frame is overwritten and `truncated` latches true, so the +// formatter can note "N frames omitted" at the top. + +#include +#include + +#define SX_TRACE_CAP 32 + +// Ring storage. `count` is the logical length (saturating at CAP); `head` is +// the index of the next write. When count == CAP the ring has wrapped and +// `frame_at(0)` is the oldest *surviving* frame (at `head`), not slot 0. +static _Thread_local uint64_t sx_trace_frames[SX_TRACE_CAP]; +static _Thread_local uint32_t sx_trace_count; // surviving frame count, ≤ CAP +static _Thread_local uint32_t sx_trace_head; // next write slot (mod CAP) +static _Thread_local uint32_t sx_trace_truncated_flag; // 0/1: did any frame get overwritten + +void sx_trace_push(uint64_t frame) { + sx_trace_frames[sx_trace_head] = frame; + sx_trace_head = (sx_trace_head + 1u) % SX_TRACE_CAP; + if (sx_trace_count < SX_TRACE_CAP) { + sx_trace_count += 1u; + } else { + // Ring full: the write above overwrote the oldest frame. + sx_trace_truncated_flag = 1u; + } +} + +void sx_trace_clear(void) { + sx_trace_count = 0u; + sx_trace_head = 0u; + sx_trace_truncated_flag = 0u; +} + +uint32_t sx_trace_len(void) { + return sx_trace_count; +} + +uint32_t sx_trace_truncated(void) { + return sx_trace_truncated_flag; +} + +// Frame `i` in oldest-to-newest order, 0-based over the surviving frames. +// Out-of-range returns 0 (a frame value of 0 is never a valid PC / packed id). +uint64_t sx_trace_frame_at(uint32_t i) { + if (i >= sx_trace_count) return 0u; + // When wrapped (count == CAP), the oldest surviving frame is at `head`; + // otherwise frames start at slot 0. + uint32_t base = (sx_trace_count == SX_TRACE_CAP) ? sx_trace_head : 0u; + return sx_trace_frames[(base + i) % SX_TRACE_CAP]; +} diff --git a/src/core.zig b/src/core.zig index 710d139..69f4525 100644 --- a/src/core.zig +++ b/src/core.zig @@ -304,6 +304,20 @@ pub const Compilation = struct { } } + // Same pattern for the ERR E3.1 error return-trace runtime. + if (lowering.needs_trace_runtime) { + if (try self.resolveStdlibPath("vendors/sx_trace_runtime/sx_trace.c")) |abs_path| { + var sources = std.ArrayList([]const u8).empty; + try sources.append(self.allocator, abs_path); + try self.lowering_extra_c_sources.append(self.allocator, .{ + .sources = try sources.toOwnedSlice(self.allocator), + .includes = &.{}, + .defines = &.{}, + .flags = &.{}, + }); + } + } + try self.collectJniMainEmissions(&lowering); return module; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1ffb991..b42bb1e 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -124,6 +124,9 @@ pub const Lowering = struct { jni_env_tl_get_fid: ?FuncId = null, // extern `sx_jni_env_tl_get` (from library/vendors/sx_jni_runtime/sx_jni_env_tl.c) jni_env_tl_set_fid: ?FuncId = null, // extern `sx_jni_env_tl_set` needs_jni_env_tl_runtime: bool = false, // set when lowering touches the JNI env TL; signals Compilation to auto-link the runtime .c + trace_push_fid: ?FuncId = null, // extern `sx_trace_push` (ERR E3.1, from library/vendors/sx_trace_runtime/sx_trace.c) + trace_clear_fid: ?FuncId = null, // extern `sx_trace_clear` + needs_trace_runtime: bool = false, // set when lowering emits a trace push/clear; signals Compilation to auto-link sx_trace.c foreign_class_map: std.StringHashMap(*const ast.ForeignClassDecl) = std.StringHashMap(*const ast.ForeignClassDecl).init(std.heap.page_allocator), // sx alias → ForeignClassDecl (jni_class / objc_class / swift_class / ... — registered in scan pass) current_foreign_class: ?*const ast.ForeignClassDecl = null, // set while lowering a `#jni_main` (or any sx-defined `#jni_class`) bodied method — `super.method(args)` dispatch resolves the parent class against this fcd's `#extends` current_foreign_method: ?ast.ForeignMethodDecl = null, // the specific method whose body is being lowered; `super.(...)` reuses its signature @@ -13029,6 +13032,33 @@ pub const Lowering = struct { return .{ .get = self.jni_env_tl_get_fid.?, .set = self.jni_env_tl_set_fid.? }; } + /// Lazily declare the `sx_trace_push(u64)` / `sx_trace_clear()` runtime + /// externs (ERR E3.1). Storage is a `_Thread_local` ring buffer in + /// `library/vendors/sx_trace_runtime/sx_trace.c` — kept OUT of the user's IR + /// module (same JIT-TLS reason as the JNI env slot). Setting + /// `needs_trace_runtime` signals Compilation to auto-link the .c for AOT. + /// Wired into the `raise` / `try` push sites and the absorbing clear sites + /// at ERR E3.2. + fn getTraceFids(self: *Lowering) struct { push: FuncId, clear: FuncId } { + self.needs_trace_runtime = true; + if (self.trace_push_fid == null) { + const name = self.module.types.internString("sx_trace_push"); + const frame_param = self.module.types.internString("frame"); + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = frame_param, .ty = .u64 }) catch unreachable; + const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void); + self.module.getFunctionMut(fid).call_conv = .c; + self.trace_push_fid = fid; + } + if (self.trace_clear_fid == null) { + const name = self.module.types.internString("sx_trace_clear"); + const fid = self.builder.declareExtern(name, &.{}, .void); + self.module.getFunctionMut(fid).call_conv = .c; + self.trace_clear_fid = fid; + } + return .{ .push = self.trace_push_fid.?, .clear = self.trace_clear_fid.? }; + } + /// 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 diff --git a/src/root.zig b/src/root.zig index b2a999a..7b9bf13 100644 --- a/src/root.zig +++ b/src/root.zig @@ -9,6 +9,7 @@ pub const target = @import("target.zig"); pub const builtins = @import("builtins.zig"); pub const errors = @import("errors.zig"); pub const errors_tests = @import("errors.test.zig"); +pub const trace_runtime_tests = @import("runtime_trace.test.zig"); pub const sema = @import("sema.zig"); pub const imports = @import("imports.zig"); pub const core = @import("core.zig"); diff --git a/src/runtime_trace.test.zig b/src/runtime_trace.test.zig new file mode 100644 index 0000000..f44926b --- /dev/null +++ b/src/runtime_trace.test.zig @@ -0,0 +1,75 @@ +// Unit tests for the ERR E3.1 error return-trace ring buffer +// (library/vendors/sx_trace_runtime/sx_trace.c). The .c is linked into the +// module via build.zig, so these extern symbols resolve at test-link time. +// This grounds the buffer logic (push / overflow-survives-newest / clear / +// len / truncated / oldest-to-newest ordering) before E3.2 wires the +// push/clear calls into codegen. + +const std = @import("std"); + +extern fn sx_trace_push(frame: u64) void; +extern fn sx_trace_clear() void; +extern fn sx_trace_len() u32; +extern fn sx_trace_truncated() u32; +extern fn sx_trace_frame_at(i: u32) u64; + +const CAP: u32 = 32; + +test "trace buffer: push / len / frame_at oldest-to-newest" { + sx_trace_clear(); + try std.testing.expectEqual(@as(u32, 0), sx_trace_len()); + try std.testing.expectEqual(@as(u32, 0), sx_trace_truncated()); + + sx_trace_push(10); + sx_trace_push(20); + sx_trace_push(30); + try std.testing.expectEqual(@as(u32, 3), sx_trace_len()); + try std.testing.expectEqual(@as(u64, 10), sx_trace_frame_at(0)); + try std.testing.expectEqual(@as(u64, 20), sx_trace_frame_at(1)); + try std.testing.expectEqual(@as(u64, 30), sx_trace_frame_at(2)); + // Out-of-range frame → 0. + try std.testing.expectEqual(@as(u64, 0), sx_trace_frame_at(3)); + try std.testing.expectEqual(@as(u32, 0), sx_trace_truncated()); +} + +test "trace buffer: clear resets length and truncation" { + sx_trace_clear(); + sx_trace_push(1); + sx_trace_push(2); + sx_trace_clear(); + try std.testing.expectEqual(@as(u32, 0), sx_trace_len()); + try std.testing.expectEqual(@as(u32, 0), sx_trace_truncated()); + try std.testing.expectEqual(@as(u64, 0), sx_trace_frame_at(0)); +} + +test "trace buffer: overflow keeps the newest CAP frames, latches truncated" { + sx_trace_clear(); + // Push CAP + 5 frames with distinguishable values (i = 1..CAP+5). + var i: u64 = 1; + while (i <= CAP + 5) : (i += 1) sx_trace_push(i); + + // Length saturates at CAP; truncation latched. + try std.testing.expectEqual(CAP, sx_trace_len()); + try std.testing.expectEqual(@as(u32, 1), sx_trace_truncated()); + + // The surviving frames are the newest CAP, oldest-to-newest. The first 5 + // (values 1..5) were overwritten, so frame_at(0) is value 6. + try std.testing.expectEqual(@as(u64, 6), sx_trace_frame_at(0)); + try std.testing.expectEqual(@as(u64, CAP + 5), sx_trace_frame_at(CAP - 1)); + // Strictly increasing by 1 across the surviving window. + var k: u32 = 0; + while (k < CAP) : (k += 1) { + try std.testing.expectEqual(@as(u64, 6 + k), sx_trace_frame_at(k)); + } +} + +test "trace buffer: exactly CAP frames, no truncation" { + sx_trace_clear(); + var i: u64 = 0; + while (i < CAP) : (i += 1) sx_trace_push(i * 100); + try std.testing.expectEqual(CAP, sx_trace_len()); + try std.testing.expectEqual(@as(u32, 0), sx_trace_truncated()); + try std.testing.expectEqual(@as(u64, 0), sx_trace_frame_at(0)); + try std.testing.expectEqual(@as(u64, (CAP - 1) * 100), sx_trace_frame_at(CAP - 1)); + sx_trace_clear(); +}