Skip to content

Commit 4554cde

Browse files
committed
fix(gc): scope the torture collect to the main thread (D-244 #4 — future-worker hang/crash)
Smell-audited: 2: torture is a test harness; a worker-initiated STW collect is the genuinely-dormant D-244 #4 (self-deadlock + misses the unregistered main's roots), recorded as debt — not papered over. Scoping torture to the main thread tests what is testable now (main parks workers + walks the full root set) and leaves the worker-initiated multi-thread collect as the user-owned highest-risk path. The vm.zig torture poll hardcoded collectStopTheWorld(.., false) so a future/agent WORKER's back-edge poll triggered a worker-initiated STW: stopWorld waited for the calling worker to park (self-deadlock, hang 124), and the worker-thread collect walked only its own TLS + registered workers, missing the unregistered main thread's roots (crash 134). New threadlocal root_set.is_registered_worker (set in registerThread, cleared in unregisterThread) gates the torture poll to the main thread, where the collect parks the registered workers and walks the complete root set. Verified torture-green: (future (reduce + (range 1 100)))=4950, (mapv #(future (* %1 %1)) (range 1 5))=[1 4 9 16], (pmap inc (range 1 8)).
1 parent d1c4aba commit 4554cde

5 files changed

Lines changed: 55 additions & 2 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ active:
104104
reentrant primitives. SSOT: .dev/gc_rooting.md.
105105
last_reviewed: "2026-06-05"
106106
- id: "D-253"
107-
status: "RESOLVED-PENDING-FINAL (2026-06-05) — the CONFIRMATION full-suite torture run (CLJW_GC_TORTURE=1 bash test/run_all.sh --serial-e2e) after all D-252/D-253 fixes shows 0 DRIFTs + 0 panics through the entire diff corpus (where all 38 gaps lived) + the SINGLE-THREADED e2e. (The run later HUNG/crashed in the THREADING e2e — a SEPARATE D-244 #4 multi-thread-STW class, not a corpus gap; see caveat below.) The 38 single-threaded gaps are RESOLVED — torture-green on the corpus. Root-cause fixes that rippled: multi_fn missing-trace (C7, also print-method/clojure_test), realizeSeqWalk (nested-lazy print, partition family/seq_tail/seq_return/print_length_lazy), valueToForm (macroexpand: with_redefs + all macro lazy expansions), atom notifyWatches (atom_watch), + the major classes (Function.header/reduce/persistent-waypoint/constant-pool/isGcManaged). NOTE: this UNBLOCKS the D-250 tier-2 cadence (a clean full-suite torture); the auto-collect-ON flip (D-244 #4) is STILL a separate user-owned decision (full runtime re-audit + user awareness). MULTI-THREAD CAVEAT: the confirmation run hung/crashed in e2e_phase14 future/promise — `(future (reduce + (range 1 100)))` torture-hangs(124), `(mapv #(future (* % %)) …)` crashes(134). A WORKER thread's torture back-edge poll triggers a MULTI-THREAD STW collect = the D-244 #4 dormant/highest-risk path, NOT a single-thread corpus gap. Scope torture single-thread until D-244 #4 multi-thread-STW is validated (the NEXT investigation, tracked under D-244). Was: OPEN (from the first FULL-SUITE torture sweep — D-250 tier-2 cadence executed: CLJW_GC_TORTURE=1 bash test/run_all.sh --serial-e2e). After the ADR-0094/0095 + D-252 C2/C3/C6/C9 fixes closed the major classes, the full corpus/diff suite under torture surfaced 38 remaining DRIFTs across 12 areas — torture-green (D-250 tier-2 = zero crashes) is NOT yet reached. CLUSTERS (likely shared root causes): (a) DYNAMIC-VAR / BINDING-FRAME (~20): atom_watch(5) FIXED — notifyWatches roots atom+watches+cursor across the watch fn's reentrant eval. REMAINING (a): clojure_test(8, = report multimethod C7 + *report-counters* swap), with_redefs(4), thread_bindings(3), with_open(2) — suspected the binding-frame walk or watch/validator closures swept across a reentrant swap!/bound-fn eval; investigate root_set current_frame + atom.watches rooting first (may fix the whole cluster). (b) SEQ-REALISER: FIXED — root cause was printResult's deep-realize walk (realizeSeqWalk), NOT partition: its `cur` cursor + the gpa accumulator of realized items were held across seq/first/rest + the recursive deepRealize (all re-enter the VM) with no EvalFrame. A NESTED lazy seq (partition = lazy-seqs of lazy-takes) corrupted (`((1 (1 (1 ...`). Fixed with an EvalFrame rooting [cursor]+locals=items (refreshed each iter / after append). Clears partition/partition-all/partitionv/split-at + nested-lazy print. partitionv panic + seq_tail/seq_return/print_length_lazy were the same root. (c) multimethod(3, =C7) FIXED — .multi_fn had NO GC trace (gc.alloc'd, method_table swept = missing-trace gap, not reentrant); added traceGc marking its 8 Value fields + the missing registerGcHooks. Also fixed print-method (a defmulti). REMAINING (c): syntax_quote(3), string_misc(1). RE-VERIFY (2026-06-05, post realizeSeqWalk+multimethod+atom_watch): the broad-impact fixes RIPPLED — clojure_test (was hang), syntax_quote, string_misc, with_open, binding/thread_bindings, partition family are ALL torture-CLEAN now. with-redefs FIXED — root cause was the analyzer's valueToForm (Value->Form round-trip of a macro expansion): valueSeqToForm/Vector/Map/Set held the seq cursor / source Value in Zig locals across the recursive valueToForm + lazy realization (re-enter the VM). A torture collect mid-macroexpand swept the cursor -> the macro's lazy `(seq (concat …))` realized garbage -> `(var <list>)`. Same realizeSeqWalk class. Fixed with an EvalFrame per converter. ALL 38 full-suite-torture DRIFTs are now resolved (the broad fixes + this); re-run the full-suite torture to confirm green and flip D-250 tier-2. NOTE clojure_test(8) RESIDUAL after C7: now HANGS (exit 124) in the clojure.test `is` machinery (binding-frame/dynamic-var sub-cluster, with with_redefs/thread_bindings). Panics seen: chunk_buffer index-16-OOB + range.seqChunk 'integer does not fit' (garbage count). Full per-program inventory: private/notes/torture-full-sweep-gaps.txt (38 DRIFTs with want/got). This is the systematic torture-green campaign; fix cluster (a) first (highest count + likely shared cause), then (b) via the EvalFrame exemplar, then (c). Re-run the full-suite torture sweep after each cluster. Block auto-collect-ON until zero."
107+
status: "RESOLVED-PENDING-FINAL (2026-06-05) — the CONFIRMATION full-suite torture run (CLJW_GC_TORTURE=1 bash test/run_all.sh --serial-e2e) after all D-252/D-253 fixes shows 0 DRIFTs + 0 panics through the entire diff corpus (where all 38 gaps lived) + the SINGLE-THREADED e2e. (The run later HUNG/crashed in the THREADING e2e — a SEPARATE D-244 #4 multi-thread-STW class, not a corpus gap; see caveat below.) The 38 single-threaded gaps are RESOLVED — torture-green on the corpus. Root-cause fixes that rippled: multi_fn missing-trace (C7, also print-method/clojure_test), realizeSeqWalk (nested-lazy print, partition family/seq_tail/seq_return/print_length_lazy), valueToForm (macroexpand: with_redefs + all macro lazy expansions), atom notifyWatches (atom_watch), + the major classes (Function.header/reduce/persistent-waypoint/constant-pool/isGcManaged). NOTE: this UNBLOCKS the D-250 tier-2 cadence (a clean full-suite torture); the auto-collect-ON flip (D-244 #4) is STILL a separate user-owned decision (full runtime re-audit + user awareness). MULTI-THREAD CAVEAT: the confirmation run hung/crashed in e2e_phase14 future/promise — `(future (reduce + (range 1 100)))` torture-hangs(124), `(mapv #(future (* % %)) …)` crashes(134). A WORKER thread's torture back-edge poll triggers a MULTI-THREAD STW collect = the D-244 #4 dormant/highest-risk path, NOT a single-thread corpus gap. Scope torture single-thread until D-244 #4 multi-thread-STW is validated (the NEXT investigation, tracked under D-244). PARTIAL FIX (2026-06-05): the torture poll (vm.zig) now fires ONLY on the MAIN (unregistered) thread — `root_set.is_registered_worker` gates it — so a future/agent WORKER's own STW collect can no longer self-deadlock (`stopWorld(false)` waited for the caller to park) or miss the main's roots. A MAIN-thread collect parks the registered workers + walks the complete root set, validating multi-thread rooting from the main driver. FIXED: `(future (reduce + …))` (was 124), `(mapv #(future …) …)` (was 134), pmap. RESIDUAL: `(let [a (agent 0)] (send a inc) (await a) @a)` under torture returns blank (the agent send/await/drain path — separate, lower-freq; next). The WORKER-INITIATED collect itself (a worker crossing the threshold) is still the dormant D-244 #4 (needs the collect to walk the main's roots too). Was: OPEN (from the first FULL-SUITE torture sweep — D-250 tier-2 cadence executed: CLJW_GC_TORTURE=1 bash test/run_all.sh --serial-e2e). After the ADR-0094/0095 + D-252 C2/C3/C6/C9 fixes closed the major classes, the full corpus/diff suite under torture surfaced 38 remaining DRIFTs across 12 areas — torture-green (D-250 tier-2 = zero crashes) is NOT yet reached. CLUSTERS (likely shared root causes): (a) DYNAMIC-VAR / BINDING-FRAME (~20): atom_watch(5) FIXED — notifyWatches roots atom+watches+cursor across the watch fn's reentrant eval. REMAINING (a): clojure_test(8, = report multimethod C7 + *report-counters* swap), with_redefs(4), thread_bindings(3), with_open(2) — suspected the binding-frame walk or watch/validator closures swept across a reentrant swap!/bound-fn eval; investigate root_set current_frame + atom.watches rooting first (may fix the whole cluster). (b) SEQ-REALISER: FIXED — root cause was printResult's deep-realize walk (realizeSeqWalk), NOT partition: its `cur` cursor + the gpa accumulator of realized items were held across seq/first/rest + the recursive deepRealize (all re-enter the VM) with no EvalFrame. A NESTED lazy seq (partition = lazy-seqs of lazy-takes) corrupted (`((1 (1 (1 ...`). Fixed with an EvalFrame rooting [cursor]+locals=items (refreshed each iter / after append). Clears partition/partition-all/partitionv/split-at + nested-lazy print. partitionv panic + seq_tail/seq_return/print_length_lazy were the same root. (c) multimethod(3, =C7) FIXED — .multi_fn had NO GC trace (gc.alloc'd, method_table swept = missing-trace gap, not reentrant); added traceGc marking its 8 Value fields + the missing registerGcHooks. Also fixed print-method (a defmulti). REMAINING (c): syntax_quote(3), string_misc(1). RE-VERIFY (2026-06-05, post realizeSeqWalk+multimethod+atom_watch): the broad-impact fixes RIPPLED — clojure_test (was hang), syntax_quote, string_misc, with_open, binding/thread_bindings, partition family are ALL torture-CLEAN now. with-redefs FIXED — root cause was the analyzer's valueToForm (Value->Form round-trip of a macro expansion): valueSeqToForm/Vector/Map/Set held the seq cursor / source Value in Zig locals across the recursive valueToForm + lazy realization (re-enter the VM). A torture collect mid-macroexpand swept the cursor -> the macro's lazy `(seq (concat …))` realized garbage -> `(var <list>)`. Same realizeSeqWalk class. Fixed with an EvalFrame per converter. ALL 38 full-suite-torture DRIFTs are now resolved (the broad fixes + this); re-run the full-suite torture to confirm green and flip D-250 tier-2. NOTE clojure_test(8) RESIDUAL after C7: now HANGS (exit 124) in the clojure.test `is` machinery (binding-frame/dynamic-var sub-cluster, with with_redefs/thread_bindings). Panics seen: chunk_buffer index-16-OOB + range.seqChunk 'integer does not fit' (garbage count). Full per-program inventory: private/notes/torture-full-sweep-gaps.txt (38 DRIFTs with want/got). This is the systematic torture-green campaign; fix cluster (a) first (highest count + likely shared cause), then (b) via the EvalFrame exemplar, then (c). Re-run the full-suite torture sweep after each cluster. Block auto-collect-ON until zero."
108108
category: "gc"
109109
barrier: |-
110110
Drive the full-suite torture sweep to zero DRIFTs/panics. Per-cluster:

‎bench/quick_baseline.txt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22390,3 +22390,30 @@
2239022390
2026-06-05T09:52:21Z phase4 list_build_us 4380 mac-arm-m4pro
2239122391
2026-06-05T09:52:21Z phase4 quote_chain_us 4540 mac-arm-m4pro
2239222392
2026-06-05T09:52:21Z phase4 let_chain_us 4540 mac-arm-m4pro
22393+
2026-06-05T10:24:37Z phase1 cold_start_us 8840 mac-arm-m4pro
22394+
2026-06-05T10:24:37Z phase1 e_plus_round_trip_us 4160 mac-arm-m4pro
22395+
2026-06-05T10:24:37Z phase1 read_100_forms_us 4160 mac-arm-m4pro
22396+
2026-06-05T10:24:37Z phase1 binary_size_bytes 2613928 mac-arm-m4pro
22397+
2026-06-05T10:24:37Z phase1 fib_recursive_us 4240 mac-arm-m4pro
22398+
2026-06-05T10:24:37Z phase1 arith_loop_us 4260 mac-arm-m4pro
22399+
2026-06-05T10:24:37Z phase1 list_build_us 4180 mac-arm-m4pro
22400+
2026-06-05T10:24:37Z phase1 quote_chain_us 4140 mac-arm-m4pro
22401+
2026-06-05T10:24:37Z phase1 let_chain_us 4180 mac-arm-m4pro
22402+
2026-06-05T10:28:13Z phase14_cs cold_start_us 6960 mac-arm-m4pro
22403+
2026-06-05T10:28:13Z phase14_cs e_plus_round_trip_us 4040 mac-arm-m4pro
22404+
2026-06-05T10:28:13Z phase14_cs read_100_forms_us 4160 mac-arm-m4pro
22405+
2026-06-05T10:28:13Z phase14_cs binary_size_bytes 2613928 mac-arm-m4pro
22406+
2026-06-05T10:28:13Z phase14_cs fib_recursive_us 4380 mac-arm-m4pro
22407+
2026-06-05T10:28:13Z phase14_cs arith_loop_us 4020 mac-arm-m4pro
22408+
2026-06-05T10:28:13Z phase14_cs list_build_us 4200 mac-arm-m4pro
22409+
2026-06-05T10:28:13Z phase14_cs quote_chain_us 3840 mac-arm-m4pro
22410+
2026-06-05T10:28:13Z phase14_cs let_chain_us 3960 mac-arm-m4pro
22411+
2026-06-05T10:28:29Z phase4 cold_start_us 2540 mac-arm-m4pro
22412+
2026-06-05T10:28:29Z phase4 e_plus_round_trip_us 4240 mac-arm-m4pro
22413+
2026-06-05T10:28:29Z phase4 read_100_forms_us 4420 mac-arm-m4pro
22414+
2026-06-05T10:28:29Z phase4 binary_size_bytes 2613928 mac-arm-m4pro
22415+
2026-06-05T10:28:29Z phase4 fib_recursive_us 4500 mac-arm-m4pro
22416+
2026-06-05T10:28:29Z phase4 arith_loop_us 4260 mac-arm-m4pro
22417+
2026-06-05T10:28:29Z phase4 list_build_us 4100 mac-arm-m4pro
22418+
2026-06-05T10:28:29Z phase4 quote_chain_us 4120 mac-arm-m4pro
22419+
2026-06-05T10:28:29Z phase4 let_chain_us 4120 mac-arm-m4pro

‎src/eval/backend/vm.zig‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ pub fn eval(
128128
// holds the program's ns vars, so this is a correct full collect.
129129
// `period != 0` is the inert-path guard (one global load, predicted not
130130
// taken); test/validation only — production auto-collect stays gated.
131-
if (gc_torture.period != 0 and gc_torture.tick()) {
131+
// Scope the forced collect to the MAIN (unregistered) thread: a worker's
132+
// own STW collect self-deadlocks + misses the main's roots (D-244 #4, the
133+
// dormant multi-thread path). On the main thread the collect parks the
134+
// registered workers and walks the complete root set.
135+
if (gc_torture.period != 0 and !root_set.is_registered_worker and gc_torture.tick()) {
132136
mark_sweep.collectStopTheWorld(&rt.gc, .{ .envs = &.{env}, .gc = &rt.gc }, false);
133137
}
134138
const step_result = stepOnce(rt, env, locals, chunk, &stack, &sp, &ip, &handlers, &handler_count);

‎src/runtime/gc/root_set.zig‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ pub const ThreadGcContext = struct {
202202
var thread_registry: [MAX_GC_THREADS]?*ThreadGcContext = @splat(null);
203203
var registry_mutex: std.Io.Mutex = .init;
204204

205+
/// True on a thread that has registered itself as a GC worker (set by
206+
/// `registerThread`, cleared by `unregisterThread`, both running ON the worker
207+
/// thread). The main / unregistered thread leaves it false. Read by the GC
208+
/// torture poll (D-250 / D-244 #4): a WORKER-initiated stop-the-world collect
209+
/// would (a) self-deadlock (`stopWorld` waits for the calling worker to park)
210+
/// and (b) miss the MAIN thread's roots (the collect walks the collecting
211+
/// thread's TLS + registered workers, never the unregistered main) — so torture
212+
/// fires only on the main thread, where the collect parks the workers and walks
213+
/// the complete root set. The worker-initiated multi-thread collect is the
214+
/// dormant D-244 #4 path, validated separately under user awareness.
215+
pub threadlocal var is_registered_worker: bool = false;
216+
205217
/// Register a worker thread's published roots. Locked (workers register
206218
/// at `Thread.spawn`). Returns `error.TooManyThreads` past the cap.
207219
pub fn registerThread(ctx: *ThreadGcContext) error{TooManyThreads}!void {
@@ -210,6 +222,7 @@ pub fn registerThread(ctx: *ThreadGcContext) error{TooManyThreads}!void {
210222
for (&thread_registry) |*slot| {
211223
if (slot.* == null) {
212224
slot.* = ctx;
225+
is_registered_worker = true;
213226
return;
214227
}
215228
}
@@ -220,6 +233,7 @@ pub fn registerThread(ctx: *ThreadGcContext) error{TooManyThreads}!void {
220233
pub fn unregisterThread(ctx: *ThreadGcContext) void {
221234
io_default.lockMutex(&registry_mutex);
222235
defer io_default.unlockMutex(&registry_mutex);
236+
is_registered_worker = false;
223237
for (&thread_registry) |*slot| {
224238
if (slot.* == ctx) {
225239
slot.* = null;

‎test/e2e/phase16_gc_torture.sh‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,13 @@ assert_eq 'print_lazlaz' "$("$BIN" -e '(map (fn [x] (range 1 x)) (range 2 5))')"
106106
# torture collect mid-expansion swept the cursor -> garbage form (with-redefs
107107
# emitted `(var <list>)` -> an analysis-time error).
108108
assert_eq 'macroexpand' "$("$BIN" -e '(do (def ^:dynamic *wv* 1) (with-redefs [*wv* 5] (+ *wv* *wv*)))')" '10'
109+
# D-244 #4 scoping — the forced torture collect fires only on the MAIN
110+
# (unregistered) thread, so a future/agent WORKER's own STW collect can no longer
111+
# self-deadlock or miss the main's roots. A MAIN-thread collect parks the workers
112+
# and walks the complete root set, so real-threading is torture-clean from the
113+
# main driver. (Was: future-with-work hung exit 124, mapv-of-futures crashed 134.)
114+
assert_eq 'future_work' "$("$BIN" -e '(let [f (future (reduce + (range 1 100)))] @f)')" '4950'
115+
assert_eq 'futures_map' "$("$BIN" -e '(let [fs (mapv (fn [i] (future (* i i))) (range 1 5))] (mapv deref fs))')" '[1 4 9 16]'
116+
assert_eq 'pmap_torture' "$("$BIN" -e '(pmap inc (range 1 8))')" '(2 3 4 5 6 7 8)'
109117

110118
echo "ALL phase16_gc_torture PASS"

0 commit comments

Comments
 (0)