fix(trie): isolate SHARED_COMMITTER init from the global rayon pool - #148
Conversation
…146) The shared committer's one-time initialization ran bases.par_iter() on the global rayon pool while waiters busy-spun on a spin::Lazy. A global-pool worker blocked at an init join point can steal a pending job that dereferences SHARED_COMMITTER; the stolen job then waits for the very initialization frozen beneath it on the same stack, deadlocking the whole process at high libtest concurrency. - run the table build in a dedicated short-lived pool, driven from a fresh OS thread joined by the initializer, so neither the init work nor the wait can interact with global-pool work stealing (a global worker blocking on another pool via install() steals from its own pool, which reopens the same cycle) - use std::sync::LazyLock (parking waiters) under std and keep spin::Lazy only for no_std targets; PRECOMPUTED_WEIGHTS switched likewise - force the committer before rebuild()'s parallel region so global-pool jobs are never the first touch - add a single-test regression binary exercising concurrent first touch from pool jobs and OS threads, with a hang watchdog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing shared_committer_init test only exercises first touch from inside pool jobs; its flood jobs dereference the static so eagerly that every worker freezes into a waiter before any can help the build, so the initialization runs inline on the winner and the original deadlock channel never fires (verified: pre-fix main passes it 3/3). Give an OS thread a head start into the initializer instead: its parallel build is injected into the global pool, workers helping it steal flood jobs at join points, and unfinished build fragments freeze beneath them. This shape deadlocks pre-fix main 5/5 on a 14-core host and passes 21/21 with the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Performance Benchmark ComparisonCompared Detailed Comparison
|
Mutation testing - PASSNothing to test: no viable mutants were generated (2 unviable, 0 timed out). |
…committer-init-deadlock
… shift The SHARED_COMMITTER init isolation added lines above create_node_aligned_chunks, orphaning the four line-pinned timeout suppressions (852/858/861/863 -> 885/891/894/896 after merging main). Verified like CI: cargo mutants --list + mutation_gate.py orphans reports all 38 suppressions matching a live mutant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…up comment Extract the shared hang-guard into tests/common/run_guarded: the body runs on a worker thread and the test thread waits with recv_timeout, so a regression surfaces as a normal libtest panic. This replaces the DONE static + raw-stderr + process::exit watchdog that was copy-pasted verbatim across both regression binaries (and distinguishes a hang from a body panic via the Disconnected arm). Verified against pre-fix main: the deadlock now reports as a clean FAILED with the diagnostic message. Also trim each regression's module doc to its own channel, reword the rebuild() force comment as the optional warm-up it is — the dedicated-pool isolation makes worker-first-touch safe, so the previous wording asserted a false invariant that invited cargo-cult force calls — and collapse the join match into unwrap_or_else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ification The unwrap_or_else collapse in build_shared_committer removed one line above create_node_aligned_chunks, shifting the four pinned timeout suppressions again (885/891/894/896 -> 884/890/893/895). Verified with cargo mutants --list + mutation_gate.py orphans: all 38 match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 061e7b30d5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
Independent A/B validation on the same 32-logical-CPU x86_64 host where issue #146 was naturally reproduced: PASS.
All A/B runs used default libtest concurrency, with no The two new dedicated regression-test binaries also passed repeated execution:
For first-initialization cost, 20 alternating fresh-process control/PR pairs produced a paired median delta of +0.669%, below the predeclared practical-significance threshold; no perceptible regression was observed. Each trial ran in an independent process group. Timed-out trials were cleaned up with SIGTERM followed by SIGKILL where needed, with zero residual PIDs. The complete raw record set (298 subprocess records plus a SHA-256 manifest) has been archived and is available on request. |
Review feedback on #148 (Troublor): the two .expect()s ran inside the LazyLock initializer, and pool/thread creation can fail transiently (EAGAIN under cgroup pid limits). An initializer panic permanently poisons the lazy — every later SHARED_COMMITTER deref then panics with a cause-less "previously poisoned" message, and a consumer that catches the unwind (tokio task boundaries under panic = "unwind") is left half-alive with every state-root computation failing. Print the diagnostic and abort instead: uncatchable, SIGABRT crash signature, and supervised restarts recover cleanly. Panics from Committer::new itself still propagate via resume_unwind. Also re-pin the four create_node_aligned_chunks suppression lines shifted by this change (884/890/893/895 -> 889/895/898/900); verified with cargo mutants --list + mutation_gate.py orphans: all 38 match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Troublor
left a comment
There was a problem hiding this comment.
Fix verified across both deadlock channels (dedicated-pool isolation + parking waiters), and the abort-on-spawn-failure follow-up in ce6cf2e resolves the LazyLock-poisoning concern. Remaining notes are non-blocking hardening: prefer a non-panicking let _ = writeln!(stderr, ...) before abort() (eprintln! panics on an EPIPE stderr and would reinstate the poisoning path under panic=unwind), and the lib.rs alias comment's "no_std targets have no threads" wording is refuted by the no-default-features CI job on hosted runners. Happy to see these picked up in a follow-up.
… note Follow-up hardening from the #148 review (Troublor): - eprintln! panics when stderr is unwritable (EPIPE), and a panic in the LazyLock initializer poisons the lazy — the exact failure mode aborting there exists to avoid. Route the diagnostic through a non-panicking `let _ = writeln!(stderr, ...)` in a shared abort_init helper. Verified empirically: with stderr on a closed pipe, eprintln! panics and the writeln! form does not. - The Lazy alias comment claimed no_std targets have "no threads to contend with"; the --no-default-features CI job runs libtest with multiple threads on a hosted runner. State the actual reason spin is safe there: parallel implies std, so the no_std initializer never fans out onto a pool and always completes on its own thread. Re-pin the four create_node_aligned_chunks suppression lines shifted by this change (889/895/898/900 -> 895/901/904/906); orphans gate: all 38 suppressions match a live mutant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Fixes issue #146.
cargo test --features test-bucket-resizecould permanently deadlock at high libtest concurrency (32-core hosts, default--test-threads). The hang is not the spin-waiter starvation hypothesized in the issue but a work-stealing re-entrancy deadlock inSHARED_COMMITTER's one-time initialization; the same convoy also affects production cold start (bounded, all cores pegged until init completes).Root cause
SHARED_COMMITTER's initializer runsCommitter::new, whose table build is apar_iterover 256 bases on the global rayon pool (banderwagon/src/salt_committer.rs:165via theiter!macro). Rayon workers blocked at a join point steal other pending jobs on top of their stack.StateRoot::rebuild's parallel closures callStateRoot::new— which dereferencesSHARED_COMMITTER— from inside pool workers, so a worker executing an init subtask could steal such a job; that job then waits for the very initialization frozen beneath it on the same stack. Circular wait, no progress ever.spin::Lazy(introduced by the no_std change #120, replacingonce_cell) additionally makes every waiter busy-spin at 100% CPU, which produced the GDB signature in the issue, but the cycle exists with any lazy primitive — bounding--test-threadsonly shrinks the race window.Fix
Two independent changes, plus a warm-up:
salt/src/trie/trie.rs:61): the table build runs in a dedicated short-lived pool, driven from a fresh OS thread that the initializer joins. The join matters: a global-pool worker blocking on another pool viainstall()steals from its own pool while waiting (in_worker_cross) and can re-enter the lazy on its own stack — an intermediate version of this fix deadlocked exactly there, caught by the new regression test. This isolation alone makes first touch safe from any thread, including pool workers. Pool/thread creation failure aborts the process with a diagnostic (abort_init,salt/src/trie/trie.rs:91) rather than panicking or falling back to the caller's pool: an initializer panic would poison theLazyLockso every later deref fails without the original cause, and an inline fallback would reopen both deadlock channels. The diagnostic goes throughlet _ = writeln!(stderr, …)rather thaneprintln!, which panics on an unwritable stderr and would reinstate that poisoning path.std(salt/src/lib.rs:14):Lazy<T>isstd::sync::LazyLockunderstdandspin::Lazyonly for no_std targets, whereparallel(and therefore rayon) is off, so the initializer never fans out onto a pool and always completes on its own thread.PRECOMPUTED_WEIGHTS(salt/src/proof/prover.rs:42) switched likewise; its initializer is sequential, so parking alone suffices there.rebuild's parallel region (salt/src/trie/trie.rs:1007): not required for correctness — the isolation above already makes in-pool first touch safe — but it keeps the first wave of chunk jobs from all parking on the one-time initialization.Testing
Two single-test regression binaries, one per deadlock channel, sharing a
run_guardedhelper (salt/tests/common/mod.rs) that runs the test body on a worker thread and fails the test viarecv_timeoutif it does not finish within 120 s — a hang surfaces as a normal libtest panic with a diagnostic message.salt/tests/shared_committer_init_os_winner.rs— original channel: OS thread wins init, its parallel build reaches the global pool, flood jobs get stolen by workers helping the build. Deadlocks pre-fixmain5/5 on a 14-core host (and the final harness was re-validated against pre-fixmain: reports a clean FAILED after 120 s); passes 30+ consecutive runs with the fix.salt/tests/shared_committer_init.rs— initiator-steal channel: first touch inside pool jobs racing OS threads. Caught the broken intermediate version of this fix; passes 60+ consecutive runs with the fix.Full matrix green: the issue's repro config (
NUM_DATA_BUCKETS=2 BUCKET_RESIZE_LOAD_FACTOR_PCT=1, 196 passed / 2 ignored after merging main), default features,--no-default-features(±test-bucket-resize),riscv64imac-unknown-none-elfno_std check, fmt, clippy.Notes
--test-threads=4CI mitigation is unnecessary after this fix, and switching to a parking lazy alone would not have fixed the deadlock — the cycle is independent of how waiters wait.mutants/suppressions.tomlis touched twice to re-pin four line-anchored timeout suppressions forcreate_node_aligned_chunks— the new code above that function shifts its lines, and the pins are deliberate (they disambiguate same-text mutant twins), so every edit above it must re-pin. Verified withcargo mutants --list+mutation_gate.py orphans: all 38 suppressions match.PRECOMPUTED_WEIGHTSispub, so its type changes fromspin::Lazytostd::sync::LazyLockunderstd. Deref-only usage (all known consumers, incl. stateless-validator) is unaffected.enable-hugepages+paralleldoes not compile on Linux (banderwagon/src/salt_committer.rs:107, missingIntoParallelRefIteratorimport; combination not covered by CI). Worth a follow-up issue.🤖 Generated with Claude Code