Skip to content

Commit bd401b4

Browse files
committed
fix(gc): D-559 — the safepoint park honors the fabrication region (ADR-0150 am1)
ADR-0150's contract ("a mid-builder stop-the-world collect is DEFERRED") was only implemented for the SAME-thread torture/auto collect: the alloc-prologue safepoint park ran before the fabrication_depth gate, so a peer's stopWorld parked a worker MID-BRACKET — its in-progress builder nodes (raw *TailNode/*HamtNode/*Cons in unrooted Zig locals) were invisible to the collect and swept; the resumed builder's memcpy aliased recycled memory (the nested-cross-agent `@memcpy arguments alias` crash, exit 134, ~3-7% under alloc-torture + the D-418 window injection). Fix (JVM-GCLocker shape, web-grounded; Boehm per-worker stack scan rejected as wasm-unportable — DA fork verbatim in ADR-0150 am1 + private/notes/D559-da-critique.md): skip the park while fabrication_depth > 0; the thread parks at its next safepoint OUTSIDE any bracket. Bracket-boundary parks are unsound at BOTH entry (fold partial accumulators) and exit (unpublished result) — recorded so nobody optimizes one in. No deadlock: a waiting stopWorld holds no lock a bracket needs. DA-mandated hardening, all landed: fabrication_depth moved to root_set.zig; safepoint.park + enterBlocked assert depth==0 (the chokepoint for all 6 park sites and the enterBlocked side door); safepoint.max_stopworld_wait_ns makes the fold-chain rendezvous delay observable (the accepted cost, tracked as D-560 with the publish-roots flip as its trigger-gated discharge shape); mark_sweep's false "bracket-covered" comment rewritten. Verified: pre-fix ~3-7% crash → post-fix 100/100 clean (50+50 runs, both repros); gc_torture 81/81 incl. the new deterministic alloc-agent/nested_xagent guard; clj oracle confirms [1 2 2]. D-559 DISCHARGED; D-560 opened. Smell-audited: 2: ADR-0150 amendment 1 (DA fork verbatim in Alternatives; Alt-3 publish-roots recorded as D-560's discharge shape rather than pre-emptively re-opening the Accepted F-006 premise).
1 parent b86dd03 commit bd401b4

7 files changed

Lines changed: 187 additions & 60 deletions

File tree

‎.dev/debt.yaml‎

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

2626
active:
27-
- id: "D-559"
27+
- id: "D-560"
2828
status: |-
29-
open (2026-07-13, surfaced while building the D-418 deterministic guard). A
30-
NESTED CROSS-AGENT send corrupts under alloc-torture: a watch on agent A that
31-
`(send B …)`s, then `(await A) (await B)`, panics `@memcpy arguments alias`
32-
(exit 134, ~1/10 under CLJW_GC_TORTURE_ALLOC=1). Repro:
33-
`CLJW_GC_TORTURE_ALLOC=1 cljw -e '(let [log (agent []) a (agent 0)] (add-watch a :k (fn [k r o n] (send log conj n))) (send a inc) (send a inc) (await a) (await log) @log)'`
34-
(clj-correct value is [1 2 2] — the await barrier's no-op watch fires once more).
35-
DISTINCT from D-418 (that was the send/await fabrication window, now fixed +
36-
the 4 single-agent cases are 10/10 clean): this is the TWO-DRAINER +
37-
nested_pending path — a nested send built/held on drainer A while it runs, and
38-
the log agent's own `conj` builder, interact with the main-thread window collect.
39-
`@memcpy arguments alias` = a vector op memcpy'd overlapping regions → a
40-
swept/recycled tail buffer aliasing. ROOT CAUSE CONFIRMED (code read
41-
2026-07-13) = the D-556 DISCHARGE RESIDUAL, verbatim: "registered-WORKER-thread
42-
stacks are not conservatively scanned". mark_sweep.conservativeStackScan runs
43-
on the COLLECTING (main) thread's stack ONLY (`@frameAddress()` → the collector
44-
frame up to `conservative_stack_top`). When MAIN triggers an STW collect (here
45-
the D-418 window injection; under real load a main threshold-collect) while the
46-
log-agent DRAINER is parked mid-`conj` builder, that drainer's in-progress tail
47-
node is (a) NOT bracket-covered — the ADR-0150 fabrication no-collect region
48-
only DEFERS the worker's OWN torture collect, not a main-triggered STW that
49-
parks it at the alloc prologue (gc_heap.zig:391 parks BEFORE the fabrication_depth
50-
check at :408) — and (b) NOT conservatively scanned (the scan skips parked
51-
workers). So the intermediate is swept → the resumed builder's memcpy aliases.
52-
This is a general worker-builder hazard (agents/futures/pmap), exposed by the
53-
two-agent nested case because it maximises "a worker is mid-builder while main
54-
collects".
29+
open (2026-07-13, ADR-0150 am1 accepted-cost row). The D-559 fix (the
30+
alloc-prologue safepoint park honors the fabrication region) makes the
31+
stopWorld rendezvous delay UNBOUNDED IN FOLD-CHAIN LENGTH: a worker running
32+
back-to-back fabrication brackets with no poll between (e.g. a large `into`'s
33+
per-element builders — ADR-0150 guarantees "no alloc between builder calls",
34+
so no park can fire until the whole fold ends) defers the collector's
35+
rendezvous for the fold's full duration. Not a correctness bug (no deadlock:
36+
the waiting collector holds no lock a bracket needs); a pause-time cliff.
37+
Observable via `safepoint.max_stopworld_wait_ns` (monotonic max, added am1).
38+
DISCHARGE SHAPE (named per the DA fork, private/notes/D559-da-critique.md
39+
Alt 3): flip brackets from suppress-collect to PUBLISH-ROOTS — `gc.alloc`
40+
auto-appends in-bracket allocations to a threadlocal scratch root array the
41+
collector marks (raw header addresses); parks become safe anywhere, the
42+
fabrication knowledge leaves the safepoint layer, the same-thread torture
43+
gates become deletable, wasm-portable. Requires an ADR-0150 supersede
44+
(depth 3).
5545
category: "concurrency"
56-
barrier: "STRUCTURAL (GC collector core) — the fix is to cover a PARKED worker's native stack: either conservatively scan each parked worker's stack (the setjmp register-spill must run ON the worker before it parks, so it needs a per-worker spill buffer captured at the park point + the worker's stack bounds), OR make the ADR-0150 fabrication region hold off the STW park until the builder completes (deadlock-risk: the collector waits for the worker — needs a bounded 'finish builder then park' handshake). Deterministically reproducible (~1/10 under alloc-torture; the D-418 window-collect injection surfaces it). NOT ncpu-gated away (per user: a wrong-result/crash race is a bug, not an accepted flake). Cross-ref: D-556 discharge residual, D-418 (sibling, DISCHARGED)."
46+
barrier: "blocked-by: MEASURED pause-time harm (a real workload where max_stopworld_wait_ns shows the fold-chain cliff hurting), OR an F-006 amendment (user-owned). ADR-0150 am1's ruling: under F-006 non-moving the suppress-region remains the finished form; the publish-roots flip is not taken pre-emptively. Do NOT drain this row on ease — it re-opens a settled ADR premise without its trigger."
5747
last_reviewed: "2026-07-13"
5848
- id: "D-522"
5949
status: |-
@@ -2334,6 +2324,11 @@ standing:
23342324
barrier: "open after the D-440 arc completes (R4 + R5 must first rewrite the bulk of 'Phase N' citations to gap-area terms). Premature adoption = ~70 dangling citations with no 1:1 redirect. Trigger: D-440 R4/R5 done + a user nod to re-architect the planning axis (it is depth-4, the project's organizing metaphor)."
23352325
last_reviewed: "2026-06-15"
23362326
discharged:
2327+
- id: "D-559"
2328+
status: "DISCHARGED 2026-07-13 (ADR-0150 amendment 1). The nested-cross-agent `@memcpy arguments alias` corruption (exit 134, ~3-7% under alloc-torture + the D-418 window injection). ROOT CAUSE = the D-556 residual, precisely: the alloc-prologue safepoint park (gc_heap.alloc) fired BEFORE the `fabrication_depth` gate, so a peer's stopWorld parked a worker MID-BRACKET — its in-progress builder nodes (raw *TailNode/*HamtNode/*Cons in unrooted Zig locals) were invisible to the collect and swept; the resumed builder's memcpy aliased recycled memory. FIX (JVM-GCLocker shape; web-grounded vs Boehm per-thread-scan, rejected as wasm-unportable — DA fork in private/notes/D559-da-critique.md): the park HONORS the fabrication region (skip while depth>0; park at the next NON-bracket safepoint — bracket-boundary parks are unsound at both entry and exit, see ADR-0150 am1). HARDENING (DA Alt-1 items, all landed): `fabrication_depth` moved to root_set.zig; `safepoint.park` + `enterBlocked` assert depth==0 (chokepoint for all 6 park sites + the enterBlocked side door); `safepoint.max_stopworld_wait_ns` makes the fold-chain rendezvous delay observable; mark_sweep's false 'bracket-covered' comment rewritten. Accepted cost = D-560 (rendezvous delay unbounded in fold-chain length; discharge shape = the publish-roots flip, trigger-gated). VERIFIED: pre-fix ~3-7% crash → post-fix 100/100 clean (50 orig + 50 heavy); gc_torture 81/81 incl. the new deterministic `alloc-agent/nested_xagent` guard; clj oracle confirms [1 2 2]."
2329+
category: "concurrency"
2330+
barrier: "none — DISCHARGED. Guard: phase16_gc_torture.sh alloc-agent/nested_xagent."
2331+
last_reviewed: "2026-07-13"
23372332
- id: "D-418"
23382333
status: "DISCHARGED 2026-07-13 (user-directed — 'race だから失敗して良い性質ではない'). The send/await FABRICATION-WINDOW race — `agent_conj` intermittently returned `[2 nil]` / `[#<promise> 2]` / `[#<fn> [#<promise>]]` (the leaked object varied) under low-core / parallel-gate scheduling. ROOT CAUSE (confirmed by the Agent.java survey, private/notes/D418-agent-race-survey.md): on the JVM a constructed Action is automatically GC-reachable, so there is NO enqueue window; cljw's manual mark-sweep had one — `sendFn` built the `[f & args]` action vector (and `awaitFn` the completion promise) as a Zig LOCAL that was unrooted until `enqueueDirect` appended it to the off-heap queue (its traceGc root). A collect in that window swept the freshly-built vector, so the drainer read recycled memory. FIX (primitive/agent.zig): sendFn + awaitFn now publish the action / promise on an EvalFrame across the whole enqueue [.dev/gc_rooting.md §C]. REPRODUCER (the 'def→cgc→use' fault-injection idiom, USENIX Sec'21 use-after-compacting-GC): agent.zig `tortureCollectInWindow` injects a STW collect into the exact enqueue window under CLJW_GC_TORTURE_ALLOC — so the cross-thread race is now a SINGLE-THREAD DETERMINISTIC UAF (pre-fix `agent_conj` HUNG on a swept promise, exit 124; post-fix 10/10 [1 2]). Mechanised guard: e2e phase16_gc_torture.sh agent block — the ncpu>=4 gate is REMOVED (workaround retired), + a new deterministic alloc-torture agent block (send/conj/drain/sendoff). The nested-CROSS-agent-send case surfaced a SECOND, distinct corruption (`@memcpy arguments alias`, exit 134) → new row D-559. Serialization itself (single-drainer + `draining` flag) was NEVER the bug — the survey confirms it is the correct design for a manual-GC runtime."
23392334
category: "concurrency"

‎.dev/decisions/0150_fabrication_no_collect_region.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,85 @@ window (lazy-realization/reduce) is the genuinely-different D-244 #4b scope.
207207
- `.dev/debt.yaml` — D-244 #4 discharged (synchronous builders) + the D-244 #4b
208208
(reentrant realization) follow-on + the forward (moving-GC) row.
209209

210+
## Amendment 1 (2026-07-13, D-559) — the region also defers the safepoint PARK
211+
212+
**Problem.** This ADR's contract says "a mid-builder stop-the-world collect is
213+
DEFERRED", but the implementation deferred only the SAME-thread torture / future
214+
auto-collect. The alloc-prologue safepoint park (`gc_heap.alloc`, ADR-0090) ran
215+
BEFORE the `fabrication_depth` gate, so a PEER's `stopWorld` parked a worker
216+
mid-bracket — its in-progress builder nodes (raw `*TailNode`/`*HamtNode`/`*Cons`
217+
in unrooted Zig locals) were invisible to the collect and got swept; the resumed
218+
builder then read recycled memory. Surfaced as D-559: a nested-cross-agent send
219+
(`add-watch` on agent A sending to agent B) crashing `@memcpy arguments alias`
220+
(exit 134, ~3-7% under `CLJW_GC_TORTURE_ALLOC=1` with the D-418 window-collect
221+
injection; the same signature this ADR's Context recorded for the original
222+
unbracketed `conj`). `mark_sweep.zig`'s "workers are parked at their
223+
alloc-prologue, where their intermediates are bracket-covered" comment was
224+
false — D-559 is its disproof.
225+
226+
**Decision.** The alloc-prologue park HONORS the region: skip the park while
227+
`fabrication_depth > 0`; the thread parks at its next safepoint OUTSIDE any
228+
bracket (VM back-edge poll, non-bracket alloc, blocking-lock safepoint, or
229+
unregister). This is the JVM-GCLocker shape (JNI critical regions delay GC;
230+
threads in a region run to completion; the collector waits). No deadlock: a
231+
waiting `stopWorld` holds no lock a bracket needs (brackets are bounded pure
232+
builders — no eval, no locks; their allocs take only `gc_mutex`, which the
233+
collector takes only AFTER the rendezvous).
234+
235+
- **No park at bracket ENTRY or EXIT** — both are unsound: at exit the just-built
236+
result is an unpublished Zig local; at entry a fold's partial accumulator
237+
(`acc = consHeap(rt, x, acc)`) is an unpublished raw node between brackets.
238+
There is NO safe bracket-boundary park without new rooting machinery; do not
239+
"optimize" one in.
240+
- **Enforcement**: `fabrication_depth` moved to `root_set.zig` (the shared lower
241+
layer); `safepoint.park` AND `safepoint.enterBlocked` now
242+
`assert(fabrication_depth == 0)` — the chokepoint guarding every other park
243+
site (back-edge, lazy_seq, lock_tx, monitor) and the enterBlocked side door
244+
against a future bracket that reaches one.
245+
- **Accepted cost (observable)**: the rendezvous delay is **unbounded in
246+
fold-chain length** — back-to-back brackets with no poll between (a large
247+
`into`'s per-element builders) defer the park until the fold ends. Tracked as
248+
**D-560** with the publish-roots redesign (DA Alt 3 below) as the named
249+
discharge shape; `safepoint.max_stopworld_wait_ns` records the worst wait so
250+
the cliff is observable, not anecdotal.
251+
- **Scope**: this does NOT close the D-556 residual (tree_walk eval ON A WORKER
252+
holds C-stack Value intermediates; the conservative scan remains
253+
collecting-thread-only). At the sanctioned park sites a VM worker's live
254+
values are published (operand stack / eval frames / bindings); the same claim
255+
for tree_walk-on-worker is NOT made.
256+
257+
**Alternatives considered (Devil's-advocate fork, fresh context — summary;
258+
full text `private/notes/D559-da-critique.md`):**
259+
260+
- **Alt 1 (smallest-diff, taken)**: the park-deferral + asserts + honest wording
261+
+ wait stat. DA verdict: sound (a registered in-bracket worker never counts as
262+
parked, so `collect` can never overlap a bracket); inherits the structural
263+
smell that the fabrication region now couples into the safepoint protocol.
264+
- **Alt 2 (Boehm per-parked-worker conservative stack scan)**: closes D-559 AND
265+
the D-556 residual in one mechanism, no latency cliff. Rejected as
266+
finished-form-HOSTILE on this project's horizon: wasm value stacks are
267+
unscannable, so gap area II (Wasm-edge-native) cannot port it, and it deepens
268+
platform coupling.
269+
- **Alt 3 (wildcard, DA-recommended per F-002)**: flip brackets from
270+
suppress-collect to PUBLISH-ROOTS — `gc.alloc` auto-appends in-bracket
271+
allocations to a threadlocal scratch root array the collector marks; parks
272+
become safe anywhere, no rendezvous delay, no fabrication knowledge in the
273+
safepoint layer, torture gates deletable, wasm-portable. Costs: per-in-bracket-
274+
alloc append, a new clear-point invariant, an ADR-0150 supersede (depth 3).
275+
**Main-loop ruling**: this ADR's accepted premise (F-006 non-moving → the
276+
no-collect region IS the finished form; publish-roots was Fork 2's rejected
277+
gold-plating) still stands — D-559 was an implementation gap in the region's
278+
contract, not evidence the regime is wrong. Alt 3 becomes the named discharge
279+
shape of D-560, triggered by MEASURED pause-time harm (or an F-006 amendment,
280+
user-owned), not pre-emptively.
281+
210282
## Revision history
211283

284+
- 2026-07-13 **Amendment 1** (D-559): the region also defers the alloc-prologue
285+
safepoint park (a peer-STW mid-bracket park corrupted worker builders);
286+
`fabrication_depth` moved to `root_set.zig`; park/enterBlocked asserts; the
287+
fold-chain rendezvous-delay cost recorded as D-560 with the publish-roots
288+
redesign (DA Alt 3) as its discharge shape.
212289
- 2026-06-16 issued + **REJECTED same day**, then **REVERSED to ACCEPTED same day**.
213290
The design (Alt A no-collect region) was DA-forked (Fork 1, recommended Alt A). It
214291
was then rejected on the belief that Alt A contradicts ADR-0090's "never suppress,

‎src/runtime/concurrency/safepoint.zig‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
const std = @import("std");
2929
const io_default = @import("io_default.zig");
3030
const root_set = @import("../gc/root_set.zig");
31+
const clock = @import("../clock.zig");
32+
33+
/// Diagnostic (ADR-0150 am1 / D-560): the longest single `stopWorld` rendezvous
34+
/// wait observed this process, in ns. The am1 park-deferral makes the wait
35+
/// unbounded in fold-chain length (a worker running back-to-back fabrication
36+
/// brackets parks only at its next non-bracket safepoint), so the delay must be
37+
/// OBSERVABLE — if this grows into real pause-time trouble, D-560 names the
38+
/// publish-roots redesign that removes the cliff. Monotonic max; read on demand
39+
/// (no print path wired — a probe reads it via a debugger / future stats dump).
40+
pub var max_stopworld_wait_ns: std.atomic.Value(i64) = .init(0);
3141

3242
/// Armed by a collecting thread (`stopWorld`); read by the VM back-edge poll +
3343
/// the `alloc`-boundary check. A worker that observes this set at a safe point
@@ -53,6 +63,14 @@ var resume_cond: std.Io.Condition = .init;
5363
/// count before this thread parks. The resume guard tests the FLAG, not the
5464
/// count, so a spurious `Condition` wakeup re-parks correctly.
5565
pub fn park() void {
66+
// ADR-0150 am1 / D-559: a thread must NEVER park inside a fabrication
67+
// no-collect region — its in-progress builder nodes are unrooted Zig
68+
// locals the collector cannot see, so a park here hands the peer's
69+
// collect a stack it will corrupt. The alloc-prologue gate (gc_heap)
70+
// skips the park mid-bracket; this assert is the chokepoint guarding
71+
// every OTHER park site (back-edge poll, lazy_seq, lock_tx, monitor)
72+
// against a future bracket that reaches one of them.
73+
std.debug.assert(root_set.fabrication_depth == 0);
5674
io_default.lockMutex(&sp_mutex);
5775
defer io_default.unlockMutex(&sp_mutex);
5876
parked_count += 1;
@@ -72,6 +90,12 @@ pub fn park() void {
7290
/// from the registry BEFORE arming so a worker arriving late still parks (the
7391
/// flag is armed first); a parked-count overshoot only relaxes the `<` wait.
7492
pub fn stopWorld(self_registered: bool) void {
93+
const t0 = clock.nanoTime(io_default.get());
94+
defer {
95+
const waited = clock.nanoTime(io_default.get()) - t0;
96+
if (waited > max_stopworld_wait_ns.load(.monotonic))
97+
max_stopworld_wait_ns.store(waited, .monotonic);
98+
}
7599
gc_requested.store(true, .release);
76100
io_default.lockMutex(&sp_mutex);
77101
defer io_default.unlockMutex(&sp_mutex);
@@ -109,6 +133,12 @@ pub fn noteWorkerLeft() void {
109133
/// torture deadlock: `force` runs the thunk under the once-lock, so the COLLECTING
110134
/// main thread holds it across a collect while a worker blocks on it).
111135
pub fn enterBlocked() void {
136+
// ADR-0150 am1 / D-559: same invariant as `park` — counting a mid-bracket
137+
// thread as parked would let the collector proceed over its unrooted
138+
// builder intermediates (the side door the alloc-prologue gate does not
139+
// cover). Brackets are pure builders that take no locks, so this cannot
140+
// fire today; it guards the invariant against future bracket bodies.
141+
std.debug.assert(root_set.fabrication_depth == 0);
112142
io_default.lockMutex(&sp_mutex);
113143
defer io_default.unlockMutex(&sp_mutex);
114144
parked_count += 1;

0 commit comments

Comments
 (0)