Skip to content

Commit f94a88e

Browse files
committed
docs(adr): ADR-0175 — worker registration is a GC safepoint (spawn-to-register STW race)
Smell-audited: 3: new ADR accepted inline with the DA fork's alternatives embedded; D-548 updated with measured post-fix results (incl. the honest negative on exposure (a)); D-566 opened for the deferred alloc-side assert with its survey recorded Root cause of the 2026-07-28 x86_64-linux nightly e2e_phase16_gc_torture SIGABRT (run 30333558512): stopWorld's rendezvous target misses a spawned-but-not-yet-registered worker, so the collector walks thread roots while the newborn agent drainer registers and mutates its frames. Reproduced 3/221 + 4/600 on a 4-vCPU-pinned x86_64 host (GP fault in nextThreadRoots, symbolized via -Dprofile); Mac 200/200 clean.
1 parent ac387e6 commit f94a88e

4 files changed

Lines changed: 232 additions & 8 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@
2424
# md-table-align no longer pads it to ~800 KB of whitespace.
2525

2626
active:
27+
- id: "D-566"
28+
status: |-
29+
open (2026-07-29, ADR-0175 deferred-hardening row). Alloc-side
30+
unregistered-mutator assert: `gc_heap.alloc` (and `pushAnalysisRoot`)
31+
should assert `root_set.is_registered_worker or <is the heap's owning
32+
main thread>` so the next pre-register (or cap-fallthrough) mutator is a
33+
caught bug instead of a ~1.5% GP-fault flake. Survey done 2026-07-29:
34+
PRODUCTION is assert-clean (only main + the three registered worker
35+
families allocate; nrepl/http serve serially on the calling thread), but
36+
in-tree unit tests alloc from unregistered test threads (lazy_seq force-
37+
race workers at lazy_seq.zig:461, plus the gc_heap/root_set churn tests
38+
— each site needs registration or an exemption), and "owning main
39+
thread" needs a per-GcHeap `main_thread_id` stamped at init.
40+
category: "concurrency"
41+
barrier: |-
42+
Discharges by: the test-audit sweep (register or exempt every
43+
unregistered-alloc test thread) + the per-heap main-thread-id stamp +
44+
the assert itself, landed together. Cross-ref ADR-0175 § Consequences.
45+
last_reviewed: "2026-07-29"
2746
- id: "D-565"
2847
status: |-
2948
open (2026-07-28, external-contributor reproducibility audit — Discussion
@@ -1163,12 +1182,36 @@ active:
11631182
parallel full gate; standalone 3/3 green immediately after on the same
11641183
tree; CI (the release platform) green on every arc commit. The (a)
11651184
signature unchanged — load-sensitive, not a release regression.
1185+
RECURRENCE + NEW ROOT CAUSE FOUND 2026-07-29: the scheduled x86_64-linux
1186+
full gate (run 30333558512) SIGABRT'd e2e_phase16_gc_torture at
1187+
`agent_alloc 'drain'` (exit 134; same SHA green 6 prior nights). Root-
1188+
caused + FIXED as ADR-0175: the SPAWN-TO-REGISTER window — stopWorld's
1189+
rendezvous target misses a spawned-but-not-yet-registered worker, so the
1190+
collector walks/sweeps while the newborn drainer registers and mutates
1191+
(GP fault in nextThreadRoots, reproduced 3/221 + 4/600 on a 4-vCPU-pinned
1192+
x86_64 host, symbolized via -Dprofile; Mac 200/200 clean). Registration
1193+
is now a safepoint + seq_cst handshake; the TooManyThreads run-
1194+
unregistered fallthrough in future/thread workers is closed in the same
1195+
arc. Exposure (a) protocol RUN post-ADR-0175 (2026-07-29, 8-core x86_64
1196+
linux, 4 rounds of 8× parallel phase14_future_promise_delay): 6/32
1197+
exit-134, roaming case — rate ≈ the recorded pre-fix baselines (3/8
1198+
2026-06-25, 1/8 2026-07-14), so ADR-0175 neither fixes NOR worsens (a).
1199+
CONCLUSION: (a) is a DISTINCT race (not the spawn-to-register window)
1200+
and STAYS OPEN with its signature unchanged. Post-fix verification of
1201+
the gc_torture leg itself: drain-case stress 600/600 clean on the
1202+
4-vCPU-pinned x86_64 host (2026-07-29; pre-fix the same harness crashed
1203+
3/221 and 4/600).
11661204
category: "runtime"
11671205
barrier: |-
1168-
needs a low-core repro environment (3-vCPU VM or cpuset-limited linux host) +
1169-
a thread-race investigation of the delay realise lock / future spawn path;
1170-
not tractable on the 10-core dev Mac where 60/60 iterations pass clean.
1171-
last_reviewed: "2026-07-02"
1206+
REPRO NOW IN HAND (2026-07-29, barrier partially dissolved): 8× parallel
1207+
`bash test/e2e/phase14_future_promise_delay.sh` (CLJW_SKIP_BUILD=1) on
1208+
ubuntunote reproduces (a) at ~6/32 per campaign — roaming exit-134, the
1209+
failing case varies per run, stderr swallowed by the per-case capture
1210+
(instrument the script or wrap the binary to capture the abort trace;
1211+
build with -Dprofile=true for symbolized Zig traces). Remaining work =
1212+
the thread-race investigation itself (delay realise lock / future spawn
1213+
path) using that harness.
1214+
last_reviewed: "2026-07-29"
11721215
- id: "D-550"
11731216
status: |-
11741217
open (2026-07-02, user-directed direction — NOT a priority raise). Clojars/Maven
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# ADR-0175 — Worker registration is a GC safepoint (spawn-to-register window)
2+
3+
- **Status**: **ACCEPTED 2026-07-29.**
4+
- **Deciders**: autonomous loop + one Devil's-advocate fork (general-purpose,
5+
fresh context, F-006/F-002/F-011/F-009 envelope; output reflected verbatim
6+
in Alternatives considered).
7+
- **Supersedes / relates**: ADR-0090 (the STW safepoint rendezvous this
8+
extends), ADR-0150 + am1 (fabrication region / park semantics — sibling
9+
park-site rule), D-244 #4, D-548 (the load-flake ledger this explains a leg
10+
of), D-418/D-559 (the two previously-fixed agent-path GC races).
11+
12+
## Context
13+
14+
The scheduled full-gate CI on x86_64-linux (run 30333558512, 2026-07-28) failed
15+
`e2e_phase16_gc_torture` with SIGABRT (exit 134) in the alloc-torture agent
16+
block (`agent_alloc 'drain'`), on a commit whose previous six nightly runs were
17+
green — an intermittent race, not a regression. Reproduced on a 4-vCPU-pinned
18+
x86_64 host at ~1.5%/run; a `-Dprofile` build captured the crash four times
19+
with one identical signature:
20+
21+
> General protection exception in `root_set.nextThreadRoots`
22+
> (the thread-roots walk), on the MAIN thread, inside the STW collect injected
23+
> by `agent.tortureCollectInWindow` during `send`.
24+
25+
Root cause: **`safepoint.stopWorld` computes its park-rendezvous target from
26+
`registered_count`, but a worker thread that has been SPAWNED and not yet
27+
REGISTERED is invisible to it.** The agent drain case churns drainers (each
28+
queue-empty exit + next send spawns a fresh one) while the alloc-torture
29+
injects a collect at every enqueue, so this interleaving fires readily on a
30+
slow/loaded runner:
31+
32+
1. `send` #k: queue empty → spawn drainer D. `std.Thread.spawn` returns; main
33+
continues while D is still starting up (OS scheduling delay — the reason
34+
this is a 4-vCPU-Linux flake and a 10-core-Mac never).
35+
2. `send` #k+1: the injected collect runs `stopWorld` → `registered_count` is
36+
0 (D not yet registered) → target 0 → returns immediately → the collector
37+
begins the root walk + sweep.
38+
3. D comes to life, `registerThread` succeeds (it never looked at
39+
`gc_requested`), D pops an action and runs it — pushing binding/eval frames
40+
that the collector's `nextThreadRoots` is concurrently reading through the
41+
published TLS slot pointers (torn frame chain → GP fault, the observed
42+
crash), while the popped action itself has left the queue's traceGc range
43+
(swept-in-use → the drainer-side UAF variant, also observed as a heap-address
44+
SIGSEGV).
45+
46+
The walk's stated safety justification — "the safepoint guarantees no
47+
concurrent register/unregister during collect" — was false for exactly this
48+
window. Every prior fix in this family (D-418 enqueue-window rooting, D-559
49+
park-honors-fabrication) assumed the rendezvous itself was sound; this is the
50+
rendezvous's own hole.
51+
52+
## Decision
53+
54+
**Registration is itself a GC safepoint, and the registration handshake is
55+
seq_cst.** Concretely (all landed in this arc):
56+
57+
1. **`registerThread` parks the newborn** (root_set.zig): after publishing its
58+
slot + count (under `registry_mutex`) and releasing the mutex, the worker
59+
loads `gc_requested` (seq_cst) and calls `safepoint.park()` if set — so a
60+
worker born while a collect is arming/running quiesces before its first
61+
mutator step. A freshly registered ctx publishes only empty TLS roots, so a
62+
collector already mid-walk reading its slot sees an empty contribution.
63+
2. **Seq_cst Dekker pairing**: `stopWorld`'s `gc_requested.store(true)` + its
64+
`registeredCountLockFree()` read, and `registerThread`'s
65+
`registered_count.fetchAdd` + its `gc_requested` load, are all seq_cst.
66+
The count add sits under `registry_mutex` and the collector's count read
67+
under `sp_mutex` — no common lock, so without seq_cst the both-sides-miss
68+
interleaving (store-buffer/Dekker) is real. Under seq_cst either the
69+
collector waits for the newborn's park, or the newborn parks at
70+
registration — never both-miss. **Do not relax these four orderings** —
71+
both sides carry comments naming the pair.
72+
3. **Atomic registry-slot access**: `threadContextAt` / `markRegisteredTxs`
73+
read slots with `@atomicLoad(.acquire)`, register/unregister write with
74+
`@atomicStore(.release)`. A newborn may legally publish its slot mid-walk
75+
(it then parks); mid-collect slots only transition null→ctx (unregistration
76+
is impossible mid-collect — every already-registered worker is parked or
77+
blocked-counted for the rendezvous to have completed).
78+
4. **The `TooManyThreads` fallthrough is closed** (the DA fork's finding — the
79+
same corruption class, deterministically reachable at the 64-worker cap,
80+
e.g. `(dotimes [i 100] (future …))`): `future.worker` and `thread.worker`
81+
previously ran their thunk UNREGISTERED when registration failed. Now: a
82+
future realises as `realised_error` with nil cached (no allocation — an
83+
unregistered thread may not touch the GC heap; `errorValue`'s new nil-guard
84+
routes `deref` to the generic `future_thunk_failed` raise), and a Thread
85+
reports one non-allocating stderr line + falls through to its done-epilogue.
86+
The agent drainer already bailed correctly (releases the drainer slot; the
87+
queued actions wait for the next send-spawned drainer).
88+
5. **Shared worker-context builder**: `root_set.workerContext(tx_slot)` builds
89+
the standard 5-slot ctx from the calling thread's own TLS; the three worker
90+
families (agent drainer / future / Thread) use it, so a future worker family
91+
cannot silently drop a slot.
92+
93+
### Invariants this ADR makes explicit (previously unwritten)
94+
95+
- **Pre-registration code touches only spawner-pinned objects + pure bit-ops.**
96+
Every spawn site `gc.pin`s the object the worker will address before
97+
`Thread.spawn` (agent/future/thread all do); under F-006 non-moving GC that
98+
makes the newborn's pre-register reads safe. A new spawn site must keep this.
99+
- **No lock may be held at a `registerThread` call site** (the registration
100+
park would strand it). True at all sites today; documented as a contract on
101+
`registerThread`.
102+
- **On `TooManyThreads` the worker must NOT run its mutator body**, and its
103+
failure report must not allocate on the GC heap.
104+
105+
## Verification
106+
107+
- New deterministic unit test (safepoint.zig, "registerThread during an active
108+
STW collect parks the newborn until resumeWorld"): red pre-fix (the newborn
109+
observably runs between stopWorld-return and resumeWorld), green post-fix.
110+
- The seq_cst half is not deterministically testable; the statistical guard is
111+
the e2e repro: pre-fix `agent_alloc 'drain'` crashed 3/221 and 4/600 (two
112+
campaigns) on a 4-vCPU-pinned x86_64 host; post-fix the same harness must run
113+
clean (recorded in D-548 on landing).
114+
- The existing `phase16_gc_torture` agent block remains the standing e2e guard
115+
(it is what caught this on CI).
116+
117+
## Alternatives considered (DA fork output, condensed faithfully)
118+
119+
The fork's leading finding: **no alternative avoids the core mechanism** — every
120+
shape ends up needing the newborn to check `gc_requested` at its first
121+
GC-visible act; the alternatives only vary the fence spelling and walk
122+
protection. No F-NNN conflict exists in any shape.
123+
124+
1. **Smallest-diff — collector-held `registry_mutex` + rendezvous
125+
re-validation**: collector re-reads the count under `registry_mutex` after
126+
the rendezvous, re-waits if it grew, and holds the mutex across walk+sweep;
127+
registration simply blocks. Better: one synchronization vocabulary (no
128+
seq_cst reasoning), the old quiescence comments become true again. Breaks:
129+
the re-validation loop bounces between `sp_mutex` and `registry_mutex`
130+
(choreography arguably worse), creates a latent hold-order hazard against
131+
`noteWorkerLeft`, and does nothing for the `TooManyThreads` hole. The naive
132+
no-re-validation variant deadlocks (a tiny-action worker blocks in
133+
`unregisterThread` on the held lock) — rejected.
134+
2. **Finished-form-clean (ADOPTED)**: the mechanism above + fallthrough
135+
closure + shared ctx builder + documented invariants. The fork also
136+
recommended a safe-build assert in `gc_heap.alloc` ("allocating thread is
137+
main or registered") — deferred to **D-566**: the survey found in-tree unit
138+
tests (e.g. lazy_seq's force-race workers) that alloc from unregistered
139+
test threads, so the assert needs a test-audit sweep first; nrepl/http are
140+
serial on the calling thread, so production is assert-clean today.
141+
3. **Wildcard — spawner-side accounting**: (a) a pending-registration counter
142+
`stopWorld` waits on self-deadlocks when the collector IS the spawner
143+
inside the window (exactly the reproduced topology: the injected collect
144+
fires between counter-increment and `Thread.spawn`); (b) register-before-
145+
spawn is structurally impossible (the ctx holds pointers to the newborn's
146+
threadlocals) and its placeholder-adopt repair re-derives the registration
147+
park plus extra machinery. Both rejected; the salvageable boilerplate-
148+
collapse instinct became the shared ctx builder.
149+
150+
## Consequences
151+
152+
- STW pause acquires no new waits: the collector never waits for unborn
153+
threads (they cannot touch the heap); it only refuses to let them START
154+
mid-collect.
155+
- `registeredCountRelaxed` renamed `registeredCountLockFree` (it is seq_cst
156+
now; the old name lied).
157+
- D-548 exposure (a) — the future/promise SIGABRT residual — was a plausible
158+
beneficiary, but the post-fix protocol run (4 rounds of 8× parallel
159+
`phase14_future_promise_delay` on 8-core linux) still reproduced 6/32 at
160+
the pre-fix baseline rate: **(a) is a distinct race and stays open**,
161+
signature unchanged (recorded in the row).
162+
- Deferred hardening: **D-566** (alloc-side unregistered-mutator assert after
163+
a test-audit sweep). The dormant worker-initiated-collect gap (main thread
164+
unregistered — root_set.zig `is_registered_worker` doc) is pre-existing and
165+
out of scope here.
166+
167+
## Affected files
168+
169+
- `src/runtime/gc/root_set.zig` — registration safepoint + contract docs +
170+
`workerContext` + atomic slots + walk-safety comment rewrite + rename.
171+
- `src/runtime/concurrency/safepoint.zig` — seq_cst arm + doc + the new
172+
deterministic unit test.
173+
- `src/runtime/future.zig` — fallthrough closure + `errorValue` nil-guard +
174+
ctx builder adoption.
175+
- `src/runtime/thread.zig` — fallthrough closure + ctx builder adoption.
176+
- `src/runtime/agent.zig` — ctx builder adoption.
177+
- `.dev/gc_rooting.md` — E3/E4 rows note the registration safepoint.
178+
- `.dev/debt.yaml` — D-548 recurrence note; new D-566.

‎.dev/gc_rooting.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ A moving GC must keep `trackHeap`'d objects pinned (non-moving region) OR upgrad
173173
|----|----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
174174
| E1 | `lock_tx.current_tx` + `markRoots` (self, `mark_sweep.collect`) | in-txn `vals` cache + pending `commutes` Values (#4a') |
175175
| E2 | `markRegisteredTxs` → worker `tx_slot` | each parked worker's `current_tx` (future/agent point `tx_slot` at theirs) |
176-
| E3 | `ThreadGcContext` registry (`registerThread`/`unregisterThread`, fixed `[64]`) | union of every live worker's `current_frame`+`eval_frame_head`+`gc_self_guard`+`analysis_frame_head`+`tx_slot` (registrants: future.worker, agent.drainer) |
177-
| E4 | safepoint STW (`gc_requested`/`park`/`stopWorld`, target recomputed each wake) | mechanism (not a source) ensuring workers quiescent during collect; `stopWorld` recomputes the park target from the lock-free `registered_count` each wake + a leaving worker calls `noteWorkerLeft`, so a tiny-action drainer that unregisters before it ever parks cannot hang the collector (D-244 #4) |
176+
| E3 | `ThreadGcContext` registry (`registerThread`/`unregisterThread`, fixed `[64]`) | union of every live worker's `current_frame`+`eval_frame_head`+`gc_self_guard`+`analysis_frame_head`+`tx_slot` (registrants: future.worker, agent.drainer, thread.worker — all via `root_set.workerContext`). Slot reads/writes are atomic acquire/release; **registration is itself a safepoint** (ADR-0175): the newborn parks after publishing if `gc_requested` is set, and on `TooManyThreads` the worker must NOT run its mutator body. Pre-register worker code touches only spawner-pinned objects |
177+
| E4 | safepoint STW (`gc_requested`/`park`/`stopWorld`, target recomputed each wake) | mechanism (not a source) ensuring workers quiescent during collect; `stopWorld` recomputes the park target from the lock-free `registered_count` each wake + a leaving worker calls `noteWorkerLeft`, so a tiny-action drainer that unregisters before it ever parks cannot hang the collector (D-244 #4). The flag-store/count-read vs count-add/flag-load pairs are seq_cst (ADR-0175 spawn-to-register Dekker handshake — do not relax) |
178178
| E5 | `object_monitor` `held[]` | **NOT a GC root** (locked object already rooted via the operand-stack walk; held-set only counts re-entries) |
179179
| E6 | safepoint blocking-safepoint (`enterBlocked`/`exitBlocked` via `lockMutexAtSafepoint`) | mechanism (not a source): a registered WORKER blocking on a lock held ACROSS a collect counts as parked so the STW rendezvous proceeds; its published EvalFrame is walked while it blocks. Applied at the `delay` once-lock — the only site that runs arbitrary eval (the thunk) under a lock, so the COLLECTING main holds it across a collect (D-244 #4 delay-once deadlock) |
180180

‎.dev/handover.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
the AOT wire), computed def-meta (D-316) — incl. `:tag` uniform-eval
2727
(`^String`→Class, `^Foo`→name error; the bare-symbol workaround
2828
retired), deserializer GC-rooting fix, Character getName/codePointOf
29-
name table (D-561), default-data-readers, deftest file:line. Next
30-
release owns the CHANGELOG entry.
29+
name table (D-561), default-data-readers, deftest file:line,
30+
**ADR-0175 spawn-to-register GC-safepoint fix** (the 2026-07-28
31+
x86_64-linux nightly gc_torture SIGABRT root-caused: registration is
32+
now a safepoint; TooManyThreads run-unregistered fallthrough closed;
33+
D-548 note + D-566 opened). Next release owns the CHANGELOG entry.
3134
- **Forbidden this session**: bare `zig build test` WITHOUT `-Dwasm`;
3235
bare `zig build` for a probe (use ReleaseSafe). **The FULL gate MUST
3336
run `--serial-e2e`, ALONE** (D-548 (a) future/promise SIGABRT + (b)

0 commit comments

Comments
 (0)