fix(gc,vm): generate the two lockstep dispatches from one edge table; one CallFrame init/release - #810
Conversation
…auguralSystems#743) gc_clear_node missed the VAL_FN -> compiled-chunk edge that GC_FOR_EACH_CHILD reports (body_count == -1, the OP_CLOSURE ref); the ref was only recovered later by the value destructor in the unpin pass -- a third mirror the lockstep comment never named. Both dispatches are now generated from one GC_EDGE_TABLE X-macro (one row per owned edge: walk predicate + clear action side by side), so a new owning edge out of Value/Env/Chunk is added exactly once and the two cannot drift. The value destructor's chunk_decref is documented as the non-cycle mirror. Two safety invariants of the VAL_FN chunk row are now spelled out at the table: GC_EDGE_CLEAR ignores IS_NODE, so the unconditional chunk_decref((EigsChunk *)fn.body) is type-safe ONLY because make_fn sets body = NULL (OP_CLOSURE is its sole real-chunk writer, body_count == -1) and chunk_decref(NULL) no-ops; and the `body = NULL` in that CLEAR is load-bearing -- it makes the value destructor's later chunk_decref a no-op rather than a double-decref (use-after-free). CallFrame init was open-coded at every frame push and release at every saved-frame teardown loop. All 4 push sites (jit_helper_call, vm_run_ex entry, OP_CALL, OP_DISPATCH) now use callframe_init. callframe_release is declared in vm.h (non-static) so ALL THREE saved-frame teardown loops -- task_sched_thread_free and task_do_kill (vm.c) and task_free (builtins.c) -- share the one definition of the owned-field set {env iff owns_env, chunk}; no hand-encoded CallFrame release remains at any teardown site. The live-frame POP paths (OP_RETURN, vm_error_halt, the CHECK_ERROR unwind) keep their inline drops on purpose: they also drain the operand-stack window and restore the per-frame loop-stall globals, and OP_RETURN parks reusable envs / defers the chunk ref -- a plain release would be a subset. The vm.h owned-set comment names all three teardown sites and flags those pop paths for audit. The refactor also fixes a real bug it was designed to catch: the OP_DISPATCH push never stamped saved_stall_count/saved_loop_iter, so on RETURN the interpreted-dispatch path restored those unstamped counter values instead of the correct saved ones. This restore-on-RETURN is new, corrected behavior on the dispatch path, not pre-InauguralSystems#743 parity, and a probe makes the change observable: pre-fix a dispatch-per-iteration loop's __loop_iterations__ froze at a wrong value (1) after the first dispatch RETURN; post-fix it tracks the loop (41..44). Rebased onto cd9f82e, which carries InauguralSystems#806 (the ValType switches went exhaustive, gc_clear_node and GC_FOR_EACH_CHILD among them). Both conflicts resolve to this table: it supersedes the two hand-written dispatches, and InauguralSystems#806's own edit to gc_clear_node's VAL_FN case did NOT add the missing chunk edge, so the bug this closes is still live on main. InauguralSystems#806's compile-time forcing is preserved rather than dropped: because the table's rows are selected by `_v->type == VAL_x` guards, which -Werror=switch cannot see, gc_value_is_node now carries the gate (exhaustive switch, no default: arm; a value type is a node exactly when it has rows). Verified by planting an 11th enumerator -- the build fails at gc_value_is_node, as it does at InauguralSystems#806's other 15 sites. Gates (umask 022, on the rebase onto cd9f82e): CC=gcc ./build.sh rc=0; release run_all_tests.sh 3364/3364; make asan rc=0; the suite under CI's sanitizer env (ASAN_OPTIONS=detect_leaks=1, UBSAN_OPTIONS halt_on_error + print_stacktrace) 3368/3368, zero LeakSanitizer/AddressSanitizer reports; [87] closure-cycle strict leak gate clean direct under LSan (17/17, rc=0) and [101] test_spawn_gc rc=0. The task_free conversion is proven leak-free under ASan+LSan by a probe that suspends owns_env worker frames inside a try and tears them down via task_free at exit with zero findings (byte-identical output before and after the rebase). Co-Authored-By: Kimi K3 <noreply@kimi.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3a774d2 to
97ffab6
Compare
InauguralPhysicist
left a comment
There was a problem hiding this comment.
Reviewed against merge-base cd9f823, table row by row against both original dispatches. This is the right fix for #743 — structure where there was prose — and the #806 interaction was handled exactly right: moving the exhaustiveness gate to gc_value_is_node preserves the build-error property the table's _v->type == VAL_x guards would have silently dropped. Verified findings below; one change requested.
Verified (for the record):
-
Every row of
GC_EDGE_TABLEmatches the original walker and clearer: env parent/slots, chunkfunctions[]/env_cache, fn closure/chunk, list/dict elements. The only semantic delta is the intended one (the fn→chunk clear edge, with the load-bearingbody = NULL). -
The unconditional
chunk_decref(fn.body)safety claim checks out against the tree:make_fn(body = NULL,body_count = 0) andOP_CLOSURE(vm.c:3486) are the only writers offn.body/body_count, so no non-chunk pointer can reach the clear today. Good call documenting the AST-body revival trap at the row. -
No
break/continuehazard from the new loop structure: all threeGC_FOR_EACH_CHILDcall-site bodies are plain blocks. And the cached_e/_c/_vare element copies, sogcu_add's realloc during the step-1 walk can't invalidate them. -
All four real push sites are converted. (The issue counted five, but its
:4264citation wasLOOP_ENV_FRESH— an env-ownership swap on an existing frame, not a push — correctly left alone. The PR body's "five hand-written init sites" inherits the miscount; the CHANGELOG's "all four push sites" is the accurate one.) -
All three saved-frame teardown loops share
callframe_release, with the #726g_try_depthfixups retained where they matter.task_sched_thread_freenot having one is fine by construction — it runs at fatal exit, where there is no later uncaught error left to silence. -
Reproduced the OP_DISPATCH fix red-then-green locally (release builds of
cd9f823vs97ffab6):define d0(x) as: return x t is [d0] y is 1 y is 100 loop while not converged: y is y * 2 junk is dispatch of [t, 0, y] print of __loop_iterations__main prints
1(frozen after the first dispatch return); this branch prints34, matching the equivalent-shape loop without dispatch. A plainOP_CALLin the body (junk is d0 of y) is unaffected on both — the bug was dispatch-specific, exactly as described.
Requested change — one regression test:
You're right that the collector half can't be pinned (the destructor mirror masks it; the planted-enumerator probe is the correct evidence there, and I appreciate the straight disclosure). But the OP_DISPATCH stall-counter half is pinnable — it's the user-visible fix your own CHANGELOG entry describes, and the repro above is red on pre-fix main and green here. Please add it as a check (natural home: tests/test_dispatch.eigs, or test_loop_exit.eigs if you prefer it with the other __loop_iterations__ pins) asserting the dispatch-per-iteration loop's __loop_iterations__ matches its no-dispatch twin rather than freezing at 1. Repo rule: every bug fix lands with a test that fails without it — this one is cheap and this PR earned it.
One gate note: ASan+UBSan (full suite) is still running as I post this; it needs to be green before merge, as usual for anything touching the collector.
Everything else — the vm.h owned-set contract comment, the rules-file update merged with the #806 sentence, the deliberate non-conversion of vm_error_halt/CHECK_ERROR and the OP_RETURN family — is exactly how I'd want it done.
Lands per review on InauguralSystems#810: the collector half of InauguralSystems#743 cannot be pinned (the value destructor masks the missed edge, which is why the planted-enumerator probe is the evidence there), but the loop-counter half is user-visible and red on pre-fix main, so it gets a regression test. Three checks in section [68], mid-file with the other OP_DISPATCH pins; the runner derives its tally from the file's own "Tests: N" line, so no run_all_tests.sh change is needed. The pin is on the DELTA, not on a count. Measuring first showed __loop_iterations__ is a cumulative process-wide counter, not a per-loop one: on this box a dispatch-per-iteration loop reads 105 where the plain loop before it read 88, because the reading carries every earlier loop's iterations. So "the dispatch loop's counter equals its no-dispatch twin's counter" is not true as a raw-value comparison in a program with more than one loop -- what holds is that each loop CONTRIBUTES exactly its own iteration count. Both loops are asserted against that invariant, comparing each loop's delta with a count it keeps by hand, so the pin survives any retuning of the convergence heuristic. Hardcoding the number would have been wrong anyway: the reviewer's box prints 34 where this one prints 17. Two shapes the test needs to stay honest. A third check asserts the dispatch loop ran more than one iteration -- without it a single-iteration loop would satisfy the delta check even with the counter frozen at 1, and the pin would be vacuous. And a warm-up loop establishes the baseline the two measured loops subtract, because __loop_iterations__ is an undefined variable until some loop has run (the pre-fix binary is what surfaced that: it failed on the baseline read before ever reaching the assertion). Mutation-proved rather than assumed. Against a release build of upstream/main at cd9f82e (pre-fix, built in a worktree): "FAIL: dispatch loop contributes its own iteration count", Dispatch Tests: 17/18, rc=1. Against this branch: 18/18, "All tests passed", rc=0. Exactly one check flips -- the plain-loop and non-vacuity checks pass on both, as they should. Gates (umask 022): CC=gcc ./build.sh rc=0, release run_all_tests.sh 3367/3367; make asan rc=0, suite under CI's sanitizer env (ASAN_OPTIONS=detect_leaks=1, UBSAN_OPTIONS halt_on_error + print_stacktrace) 3371/3371 with zero LeakSanitizer/AddressSanitizer reports and no leak-tally note; test_dispatch.eigs run directly under ASan+LSan rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Test added in I put it in One correction, and it matters for how the check is written. The invariant as you phrased it — the dispatch loop's On the fixed build each loop's delta equals its own iteration count (17, 105−17=88, 193−105=88); pre-fix the dispatch loop contributes 1 instead of 17 and every later reading stays shifted by the shortfall. Raw values coincide only in a single-loop program — which is exactly the shape of your repro, so it reads true there. So I pinned the delta invariant instead: each loop's contribution equals its own counted iterations, checked for both the plain and the dispatch loop, plus a non-vacuity guard that the dispatch loop ran more than once (a 1-iteration loop would satisfy the delta check even with the counter frozen). Red on pre-fix and Incidentally your repro prints 34 on your box and 17 on mine from identical source — the convergence heuristic is entropy-driven — so nothing in the test depends on either number. Suite: 3367/3367 release, 3371/3371 under ASan+UBSan with leak detection, zero sanitizer findings, What this still doesn't pin, so it's on the record rather than in a commit message: the |
InauguralPhysicist
left a comment
There was a problem hiding this comment.
Approving. Verified the new test red-then-green locally: on a pre-fix binary (current main + an unrelated refactor, vm.c untouched) tests/test_dispatch.eigs fails 17/18 with exactly one check flipping — FAIL: dispatch loop contributes its own iteration count — and on 12ab915 it's 18/18. The delta-invariant framing is correct and better than what I asked for: you're right that the counter is cumulative process-wide, so the raw-value equality I proposed only holds in single-loop programs (my repro's shape, which is why it read true). The non-vacuity guard closes the 1-iteration hole. Good call putting it in test_dispatch.eigs next to the #353/#459 pins with the self-asserting harness, and thank you for appending rather than amending — the row-by-row review does still point at real commits.
The saved_stall_count half staying unpinned is the right call for the reason you gave: it's pre-existing design with no user-visible reader, and freezing it in a test would turn a revisitable choice into a compatibility promise. On the record is the right place for it.
All 16 checks green on the new head. Merging.
What does this PR do?
Replaces the two prose-enforced lockstep invariants in #743 with structure: the GC's walker and clearer are now generated from one edge table, and
CallFramehas exactly one init and one release definition instead of five hand-written sites. The bug the prose was failing to prevent is fixed as a side effect —gc_clear_nodewas missing theVAL_FN→ chunk edge thatGC_FOR_EACH_CHILDreports.Related Issues and Pull Requests
Fixes #743
Changes
src/eigenscript.c: oneGC_EDGE_TABLEmacro is now expanded twice —GC_EDGE_WALK(:2632) andGC_EDGE_CLEAR(:2643) — so the walker and the clearer cannot drift. The missingVAL_FN→ chunk edge is gone by construction rather than by having been spotted.src/vm.c:callframe_init(:477) andcallframe_release(:508) are the single definitions; the four hand-written push sites and all three teardown sites now route through them. (The issue says five; its fifth citation at:4264isLOOP_ENV_FRESH, an env-ownership swap on an existing frame rather than a push, and is correctly left alone — thanks for catching that this body had inherited the miscount.)src/vm.h:callframe_releaseexposed non-static (:472) so the third teardown site can reach it, and the stale comment above it corrected — it described a two-site invariant that had already grown a third.src/builtins.c: the third teardown site (task_free,:5521) converted from hand-written teardown tocallframe_release.CHANGELOG.md:### Fixedentry under[Unreleased]..claude/rules/c-runtime-memory.md: the edge-table rule documented, merged with your-Werror=switchsentence from runtime: ValType switches go exhaustive, finishing #738 — first build caught real drift #806.Interaction with #806, worth your attention. Rebasing onto current
mainconflicted with9945c52, which rewrote the two switches this PR replaces. Taking the edge table wholesale would have silently dropped the property you had just landed — that a newValTypeis a build error at the GC sites — because the table selects rows by_v->type == VAL_xguards, which-Werror=switchcannot see. Sogc_value_is_node(:2472) became an exhaustivedefault:-free switch instead: a node is exactly a type with table rows, same truth table, and the compile-time gate lands there. Verified by planting an 11th enumerator inValTypeand building:eigenscript.c:2474:5: error: enumeration value 'VAL_PROBE_NEW' not handled in switch [-Werror=switch], ingc_value_is_node, alongside your other #806 sites. Probe reverted.Two invariants left deliberately alone, since both are load-bearing and neither is drift:
GC_EDGE_CLEARignoresIS_NODE(the clear pass must reach non-node children too), andfn.body = NULLafter the chunk release is what stops the destructor double-releasing.vm_error_haltandCHECK_ERRORkeep their inline teardown — they unwind partially-built frames wherecallframe_release's preconditions do not hold, so folding them in would be a correctness change wearing a cleanup's clothes.One thing the issue's framing gets slightly wrong, for the record: the value
OP_DISPATCHrestores is unstamped (0), not a previous frame's value. Nothing reads a stale frame; the defect is the missing chunk edge, not a stale restore.Testing
Against merge-base
cd9f823(currentmain):CC=gcc ./build.sh, thencd tests && bash run_all_tests.sh): 3364/3364, exit 0.make asan, then the suite withASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1): 3368/3368, exit 0, zeroAddressSanitizer/LeakSanitizerlines in the log.[87]run directly under leak detection (ASAN_OPTIONS=detect_leaks=1 ../src/eigenscript test_closure_cycles.eigs): 17/17, exit 0. Section[101](test_spawn_gc.eigs, worker-created cycles): exit 0.task_free's converted teardown exercised by a probe script that suspends tasks inside atryso they are still live at exit: zero sanitizer findings, output byte-identical to the pre-rebase run.[98]doc-drift gate passes with the CHANGELOG entry in place.Not run locally: clang, macOS,
freestanding,extensions,database,valgrind,tsan,bench— leaving those to CI.Follow-ups / Known Limitations
VAL_FN→ chunk edge was masked by the destructor mirror, so the leak never surfaced through the existing gates;[87]'s strict leak gate covers the collector half but does not pin this specific edge. A regression test that fails without the fix would need a cycle whose only chunk reference is the collected one — happy to add it if you want the pin, but I did not want to imply the suite was catching something it was not.callframe_initis a static helper on every frame push. If inlining does not fully collapse it, thebenchinstruction-count gate is the plausible red leg; I have not measured it locally.Checklist
make testpasses locally — 3364/3364 release, 3368/3368 under ASan+UBSan with leak detection ondocs/BUILTINS.md— n/a, no new builtinsdocs/STDLIB.md— n/aGenerated by Claude Opus 5 (brief, implementation, verification, review), Kimi K3 (implementation)