mem: Steps 5-7 — context-identifier rebind + interp ctx bootstrap

Step 5 — `context` resolves through `current_ctx_ref`. The compile-time
emit of the default GPA into the `context` global is gone; entry points
already bind `current_ctx_ref` to `&__sx_default_context` and every
sx-to-sx call forwards it. `allocViaContext` sources from
`current_ctx_ref` too. `matchContextAllocCall` is kept as a comptime
escape hatch: the ct_module spun up by `evalComptimeString` doesn't get
the full Allocator/CAllocator/Context type registration so the protocol-
dispatch chain wouldn't run in the interp; codegen also wins from the
direct libc malloc/free.

Step 6 — `push Context.{...}` stack-discipline rewrite. Allocates a
fresh `Context` slot, binds `current_ctx_ref` to it for the body's
lexical scope, restores on exit. No global, no walk.

Step 7 — interp parity. `defaultContextValue()` builds the Context
aggregate (CAllocator thunks for alloc/dealloc, null data) on demand.
`interp.call` bootstraps slot_ptr(0) when an entry function with
implicit ctx is called sans args; `materializeCtxArg` dereferences the
caller's slot_ptr into the aggregate at every sx-to-sx call boundary so
the callee's `load(ref_0)` lands on the value; `load` of an aggregate
is a passthrough. `.global_addr` of `__sx_default_context` returns the
aggregate directly so exported entries' first-line `global_addr(...)`
runs cleanly in `#run`.

`ct_lowering` inherits `implicit_ctx_enabled` + `has_implicit_ctx` so
functions lowered into the ct module carry ctx like their main-module
twins.

152/152 example tests pass. Snapshots regen.
This commit is contained in:
agra
2026-05-25 09:10:04 +03:00
parent 92c6b47f12
commit 4bf5908792
14 changed files with 440 additions and 966 deletions

View File

@@ -164,6 +164,37 @@ pub const Interpreter = struct {
self.hooks.deinit();
}
// ── Implicit Context ──────────────────────────────────────────
/// Build the default Context aggregate for top-level interp calls.
/// Mirrors the static `__sx_default_context` LLVM global: a Context
/// whose `allocator` field is the stateless CAllocator inline-protocol
/// value (alloc/dealloc thunks bottom out at libc malloc/free).
fn defaultContextValue(self: *Interpreter) Value {
const tbl_ptr: *const @import("types.zig").TypeTable = &self.module.types;
const tbl = @constCast(tbl_ptr);
const alloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_alloc");
const dealloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_dealloc");
var alloc_fn: Value = .null_val;
var dealloc_fn: Value = .null_val;
for (self.module.functions.items, 0..) |func, i| {
if (func.name == alloc_thunk_name) alloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) };
if (func.name == dealloc_thunk_name) dealloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) };
}
const allocator_fields = self.alloc.alloc(Value, 3) catch unreachable;
allocator_fields[0] = .null_val; // CAllocator receiver — stateless
allocator_fields[1] = alloc_fn;
allocator_fields[2] = dealloc_fn;
const allocator_val: Value = .{ .aggregate = allocator_fields };
const ctx_fields = self.alloc.alloc(Value, 2) catch unreachable;
ctx_fields[0] = allocator_val;
ctx_fields[1] = .null_val;
return .{ .aggregate = ctx_fields };
}
// ── Heap operations ────────────────────────────────────────────
fn heapAlloc(self: *Interpreter, size: usize) Value.HeapPtr {
@@ -367,9 +398,23 @@ pub const Interpreter = struct {
var frame = Frame.initSized(self.alloc, total_refs);
defer frame.deinit();
// Bind parameters as initial refs (indices 0..N-1)
// Implicit-context bootstrap: when an entry point with implicit
// ctx is called without an explicit ctx arg, materialise the
// default context in a fresh slot and bind slot_ptr(0) to ref 0.
// This is the interp-side equivalent of FFI-inbound wrappers
// installing `&__sx_default_context` at function entry.
var skip_first: u32 = 0;
if (func.has_implicit_ctx and args.len + 1 == func.params.len) {
const ctx_val = self.defaultContextValue();
const slot = frame.allocSlot(self.alloc);
frame.storeSlot(slot, ctx_val);
frame.setRef(0, .{ .slot_ptr = slot });
skip_first = 1;
}
// Bind parameters as initial refs (indices skip_first..N-1)
for (args, 0..) |arg, i| {
frame.setRef(@intCast(i), arg);
frame.setRef(@intCast(i + skip_first), arg);
}
// Start at the entry block (index 0)
@@ -536,6 +581,10 @@ pub const Interpreter = struct {
}
return .{ .value = slot_val };
},
// The implicit __sx_ctx arrives as an aggregate after
// materializeCtxArg dereferences the caller's slot_ptr.
// `load(ref_0)` then naturally yields the Context value.
.aggregate => return .{ .value = ptr },
else => return error.CannotEvalComptime,
}
},
@@ -667,6 +716,14 @@ pub const Interpreter = struct {
// its own slot table and read garbage.
args[i] = self.materializeForCall(frame, frame.getRef(ref));
}
// The implicit __sx_ctx is logically a `*Context` but the
// interp can't dereference cross-frame slot_ptrs. Materialise
// args[0] to the loaded Context aggregate so the callee can
// treat its slot 0 as the value directly.
const callee_func = self.module.getFunction(c.callee);
if (callee_func.has_implicit_ctx and args.len >= 1) {
args[0] = self.materializeCtxArg(frame, args[0]);
}
const result = try self.call(c.callee, args);
return .{ .value = result };
},
@@ -1020,8 +1077,18 @@ pub const Interpreter = struct {
const val = try self.getGlobal(gid);
return .{ .value = val };
},
.global_addr => {
// Address-of-global not meaningful in interpreter
.global_addr => |gid| {
// The implicit-context default global is the only global
// whose address sees runtime use. Return the Context
// aggregate directly so `load(args[0])` yields it via the
// aggregate-passthrough branch of the `.load` handler.
if (gid.index() < self.module.globals.items.len) {
const global = &self.module.globals.items[gid.index()];
const name = self.module.types.getString(global.name);
if (std.mem.eql(u8, name, "__sx_default_context")) {
return .{ .value = self.defaultContextValue() };
}
}
return error.CannotEvalComptime;
},
.func_ref => |fid| {
@@ -1114,7 +1181,11 @@ pub const Interpreter = struct {
const args = self.alloc.alloc(Value, ci.args.len) catch return error.CannotEvalComptime;
defer self.alloc.free(args);
for (ci.args, 0..) |ref, i| {
args[i] = frame.getRef(ref);
args[i] = self.materializeForCall(frame, frame.getRef(ref));
}
const target = self.module.getFunction(fid);
if (target.has_implicit_ctx and args.len >= 1) {
args[0] = self.materializeCtxArg(frame, args[0]);
}
const result = try self.call(fid, args);
return .{ .value = result };
@@ -1232,6 +1303,17 @@ pub const Interpreter = struct {
/// emitted by `struct_gep` / `index_gep`) into the resolved parent value.
/// Slot indices are frame-local; a slice passed across a call would otherwise
/// read its data_ptr out of the callee's slot table.
/// Resolve the implicit __sx_ctx arg to its loaded Context value so
/// callees can treat their own slot 0 as the aggregate directly
/// (no cross-frame slot_ptr indirection).
fn materializeCtxArg(self: *Interpreter, frame: *Frame, val: Value) Value {
_ = self;
return switch (val) {
.slot_ptr => |slot| frame.loadSlot(slot),
else => val,
};
}
fn materializeForCall(self: *Interpreter, frame: *Frame, val: Value) Value {
switch (val) {
.aggregate => |fields| {