Skip to content

Lockstep invariants enforced only by prose: gc_clear_node misses an edge GC_FOR_EACH_CHILD reports, and CallFrame has 5 hand-written init sites #743

Description

@InauguralPhysicist

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions