Skip to content

Commit c92071c

Browse files
committed
fix: task_state stuck-slot bug on init failure; vm_defuse leak; document api.Runtime.init() staleness invariant
Follow-up deep audit (3 parallel passes: narrow-int @intcast overflow, the stale-setActive bug class elsewhere, leaks/stale-slices in this session's own new code) after the ipv6InCidr and activate() bugs. - task_state.zig claimSlot: slot.status was set to .ready BEFORE vs.create()/vs.init() ran. On failure (realistically OutOfMemory), the slot was left stuck .ready with vs == null -- permanently removed from the pool, since only .empty/.dead slots are reclaimed. Under sustained memory pressure with repeated spawn attempts, this could exhaust the task table and produce spurious TooManyTasks well before 64 live tasks exist. Fixed by only setting .ready after vs is fully initialized. - vm_defuse.zig buildDefusedCode: `out` (up to chunk.MaxCode) leaked if decodeAt (Pass 2) or remapConstFuncIps (Pass 3) failed after the alloc succeeded -- reachable on every gbc_writer.write() call under memory pressure. Fixed with errdefer. - api.Runtime.init() has the identical "returned by value, internal self-pointers go stale" shape as the activate() bug just fixed, but is currently safe because every real entry point (run, runPath, call, etc.) re-activates on its own final address before touching global state. Documented the invariant so a future method doesn't silently break it. Verified clean under standard, -Dpreset=stress, and -Dgc_stress=true.
1 parent 50dfa33 commit c92071c

3 files changed

Lines changed: 35 additions & 1 deletion

File tree

src/lang/task_state.zig

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,22 @@ pub const State = struct {
129129
// shape; skip it on wraparound so idFor() never mints a
130130
// TaskId indistinguishable from the null ref.
131131
if (slot.generation == 0) slot.generation = 1;
132-
slot.status = .ready;
132+
// status is set to .ready only AFTER vs is fully created and
133+
// initialized (not before, as this used to do): a failure in
134+
// create()/init() (realistically OutOfMemory) must leave the
135+
// slot .empty and reclaimable by a later claimSlot call, not
136+
// stuck .ready with vs == null -- that used to permanently
137+
// remove the slot from the pool for the rest of the process's
138+
// life (an .empty/.dead check is all that lets a slot be
139+
// reused; .ready never matches either). Under sustained memory
140+
// pressure with repeated spawn attempts, this could exhaust
141+
// the task table and produce spurious TooManyTasks well before
142+
// 64 *live* tasks exist.
133143
const vs = try self.allocator.create(vm_state.State);
134144
errdefer self.allocator.destroy(vs);
135145
vs.* = .{};
136146
try vs.init(vm_state.MaxStack, vm_state.MaxFrames, cfg.max_defers, 0, self.allocator);
147+
slot.status = .ready;
137148
slot.vs = vs;
138149
slot.owns_vs = true;
139150
return .{ .idx = idx, .id = self.idFor(idx) };

src/lang/vm_defuse.zig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,13 @@ pub fn buildDefusedCode(cs: *chunk.State, alloc: std.mem.Allocator) ![]u8 {
593593

594594
// Pass 2: emit expanded instructions with corrected jump offsets.
595595
const out = try alloc.alloc(u8, new_len);
596+
// decodeAt below (Pass 2) and remapConstFuncIps (Pass 3) can both still
597+
// fail after this alloc succeeds -- without this, `out` (up to
598+
// chunk.MaxCode) leaks on every such failure, reachable on every
599+
// gbc_writer.write() call under memory pressure at these specific
600+
// internal allocations. The function's own success path returns `out`
601+
// itself (not freeing it), so this never fires there.
602+
errdefer alloc.free(out);
596603
old_ip = 0;
597604
new_ip = 0;
598605
while (old_ip < old_len) {

src/runtime/api.zig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@ pub const Runtime = struct {
132132
};
133133
}
134134

135+
// Returns Runtime BY VALUE, copied at least twice more after
136+
// initWithConfig runs (into `rt` below, then again into the caller's
137+
// own storage on return) — inner.activate()'s process-global pointers
138+
// captured during initWithConfig are stale by the time this function
139+
// returns, exactly the bug class fixed 2026-08-20 in
140+
// compiler_test.zig's manually-driven VMContext tests (see
141+
// Runtime.activate()'s doc comment). Calling activate() again here
142+
// would not help — it would just be stale again after the next copy.
143+
// This is safe ONLY because every current entry point that touches
144+
// vm/tasks_mod/fs_state/net_state/http_state (run, runPath,
145+
// runPathWithSources, runPathWithSourceProvider, call, runIncremental,
146+
// begin/continueRun) transitively calls self.inner.reset() or
147+
// self.inner.activate() on ITS OWN final, stable `self` before doing
148+
// anything else. Any NEW api.Runtime method that touches that state
149+
// MUST do the same as its first action — do not assume init()'s work
150+
// survives the return.
135151
pub fn init(config: Config) !Runtime {
136152
var inner: rt_mod.Runtime = undefined;
137153
try inner.initWithConfig(

0 commit comments

Comments
 (0)