mem: Phase 1.3 — closure env allocation through context.allocator

The closure trampoline's env-buffer heap-copy in `lowerLambda` used to
call `.heap_alloc` directly (libc malloc, no protocol). Now it routes
through `allocViaContext` like every other compiler-internal alloc,
so a closure created inside `push Context.{ allocator = ... }` honors
the installed allocator — trackers count the env, arenas absorb it,
custom allocators see it. Closes the last `.heap_alloc` shortcut for
sx-internal allocations.

One ordering subtlety fixed alongside: the deferred restore of
`current_ctx_ref` at lowerLambda exit fired AFTER the env-and-closure
build section, so `allocViaContext` was reading `Ref.fromIndex(0)`
(the lambda's own ctx param, only valid inside the lambda body) when
emitting the alloc in the CALLER's scope. Without the explicit
restore, the env_heap dispatch silently routed through the default
context — the captured tracker never saw it. Fixed by restoring
`current_ctx_ref` right after `self.builder.func = saved_func`, before
the env build.

Regression test: `examples/133-closure-env-routes-through-context-allocator.sx`
mirrors the 130-xx-value pattern — install a Tracer via `push Context`,
create a capturing closure inside, assert `Tracer.count = 1`. Without
the fix the count is 0 (env goes through default context). Verified
by stashing the lower.zig change and re-running.

Bonus: `examples/50-smoke.sx` "closure-gpa" output flips from
`allocs=-1` to `allocs=0`. The old `-1` was the bug's signature —
the test manually `dealloc`'d the env after the closure ran, but the
GPA had never seen the matching alloc, so its counter went negative.
With Phase 1.3 the alloc/dealloc balance at 0. Snapshot regen.

155/155 example tests pass (133 new + 50-smoke regen). Chess green on
macOS / iOS sim / Android.
This commit is contained in:
agra
2026-05-25 12:18:27 +03:00
parent e843b7769d
commit 8e21cc5f73
5 changed files with 65 additions and 3 deletions

View File

@@ -5828,6 +5828,15 @@ pub const Lowering = struct {
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
// Restore the caller's `current_ctx_ref` BEFORE we emit the env
// alloc/memcpy below — those run in the caller's scope, and
// `allocViaContext` reads `current_ctx_ref` to find the
// installed allocator. Without this, the env_heap dispatch
// would still see `Ref.fromIndex(0)` (the lambda's own ctx
// param), which doesn't exist in the caller's frame and
// silently routes through the default context instead of any
// surrounding `push Context.{ allocator = ... }`.
self.current_ctx_ref = saved_ctx_ref_lam;
// Create proper closure type (user-visible params only — skip ctx + env).
const skip_count: usize = if (lambda_wants_ctx) 2 else 1;
@@ -5852,11 +5861,15 @@ pub const Lowering = struct {
self.builder.store(gep, val);
}
// Copy env to heap (so it outlives the stack frame)
// Copy env to heap (so it outlives the stack frame).
// Route through `context.allocator.alloc` rather than calling
// libc malloc directly so closures respect a surrounding
// `push Context.{ allocator = ... }` and a tracker / arena
// counts the env allocation alongside everything else.
const env_byte_size = self.computeEnvSize(capture_list);
const env_size = self.builder.constInt(@intCast(env_byte_size), .s64);
const ptr_void = self.module.types.ptrTo(.void);
const env_heap = self.builder.emit(.{ .heap_alloc = .{ .operand = env_size } }, ptr_void);
const env_heap = self.allocViaContext(env_size, ptr_void);
// memcpy(heap, stack_alloca, size)
_ = self.callForeign("memcpy", &.{ env_heap, env_local, env_size }, ptr_void);