Found in a modularity review of main @ 8e8970a. Tracking issue. CLAUDE.md names the GC triad as an invariant that "must move in lockstep" — it currently doesn't, and the guard that exists only fires in one direction.
1. The GC triad's "exactly the edges" contract is already violated
eigenscript.c:2454-2456 states that gc_clear_node clears "exactly the edges GC_FOR_EACH_CHILD reports, plus leaf refs."
GC_FOR_EACH_CHILD (eigenscript.c:2418-2430) reports two owned edges out of a VAL_FN:
case VAL_FN:
if (gc_env_is_node(_v->data.fn.closure)) { /* edge 1: closure env */
void *CHILD_OBJ = _v->data.fn.closure;
int CHILD_KIND = GC_KIND_ENV; BODY
}
if (_v->data.fn.body_count == -1 && _v->data.fn.body) { /* edge 2: chunk */
void *CHILD_OBJ = (EigsChunk *)_v->data.fn.body;
int CHILD_KIND = GC_KIND_CHUNK; BODY
}
gc_clear_node's VAL_FN case (eigenscript.c:2485-2490) clears only the closure:
case VAL_FN: {
Env *clo = v->data.fn.closure;
v->data.fn.closure = NULL;
env_decref(clo);
break;
The chunk ref survives cycle-breaking and is dropped later, indirectly, via val_decref in the unpin pass (:2606) reaching the value destructor — a third mirror the comment never mentions. Both dispatches also end in default: break;.
The accounting abort at :2551-2565 (accounted > rc → abandon collection) catches inventing an edge. A missed edge is the leak direction and is invisible — collection just quietly reclaims less. So a new owning edge out of Value/Env/Chunk must reach three places, one of them undocumented, with a guard that only fires the other way.
Fix: either make gc_clear_node clear the chunk edge too (and document why the destructor path is then redundant), or amend the comment to name the third mirror honestly. Better: derive both dispatches from one X-macro edge table so "the edges" has a single definition.
2. CallFrame is initialized field-by-field at 5 sites with no compiler check
CallFrame (vm.h:411-435, 13 fields) is pushed at vm.c:2037-2050, :2401-2410, :3545-3560, :4264, and :5376-5385 — five open-coded assignment blocks, no designated initializer, no = (CallFrame){0}, no memset. A field added to the struct and missed at one site is uninitialized stack garbage, and nothing warns.
Suspend/resume is a bulk memcpy (vm.c:5686, :5700), which vm.h:430-434 explicitly relies on ("POD — rides the task save/restore memcpy"). But teardown drops refs field-by-field at vm.c:5562-5568 and :5902-5908, and both loops handle exactly two fields (env if owns_env, and chunk).
So a new owned pointer field rides the memcpy correctly as an aliased ref but is never released by either teardown loop → silent leak; a field that shouldn't be aliased → double free. The POD-vs-owned distinction exists only in prose.
Fix: a single callframe_init(...) used by all five sites, and a paired callframe_release(...) used by both teardown loops, so the owned-field set has one definition instead of seven.
Found in a modularity review of
main@8e8970a. Tracking issue. CLAUDE.md names the GC triad as an invariant that "must move in lockstep" — it currently doesn't, and the guard that exists only fires in one direction.1. The GC triad's "exactly the edges" contract is already violated
eigenscript.c:2454-2456states thatgc_clear_nodeclears "exactly the edgesGC_FOR_EACH_CHILDreports, plus leaf refs."GC_FOR_EACH_CHILD(eigenscript.c:2418-2430) reports two owned edges out of aVAL_FN:gc_clear_node'sVAL_FNcase (eigenscript.c:2485-2490) clears only the closure:The chunk ref survives cycle-breaking and is dropped later, indirectly, via
val_decrefin the unpin pass (:2606) reaching the value destructor — a third mirror the comment never mentions. Both dispatches also end indefault: break;.The accounting abort at
:2551-2565(accounted > rc→ abandon collection) catches inventing an edge. A missed edge is the leak direction and is invisible — collection just quietly reclaims less. So a new owning edge out ofValue/Env/Chunkmust reach three places, one of them undocumented, with a guard that only fires the other way.Fix: either make
gc_clear_nodeclear the chunk edge too (and document why the destructor path is then redundant), or amend the comment to name the third mirror honestly. Better: derive both dispatches from one X-macro edge table so "the edges" has a single definition.2.
CallFrameis initialized field-by-field at 5 sites with no compiler checkCallFrame(vm.h:411-435, 13 fields) is pushed atvm.c:2037-2050,:2401-2410,:3545-3560,:4264, and:5376-5385— five open-coded assignment blocks, no designated initializer, no= (CallFrame){0}, nomemset. A field added to the struct and missed at one site is uninitialized stack garbage, and nothing warns.Suspend/resume is a bulk
memcpy(vm.c:5686,:5700), whichvm.h:430-434explicitly relies on ("POD — rides the task save/restore memcpy"). But teardown drops refs field-by-field atvm.c:5562-5568and:5902-5908, and both loops handle exactly two fields (envifowns_env, andchunk).So a new owned pointer field rides the memcpy correctly as an aliased ref but is never released by either teardown loop → silent leak; a field that shouldn't be aliased → double free. The POD-vs-owned distinction exists only in prose.
Fix: a single
callframe_init(...)used by all five sites, and a pairedcallframe_release(...)used by both teardown loops, so the owned-field set has one definition instead of seven.