Skip to content

fix(trie): isolate SHARED_COMMITTER init from the global rayon pool - #148

Merged
flyq merged 8 commits into
mainfrom
fix/issue-146-shared-committer-init-deadlock
Jul 25, 2026
Merged

fix(trie): isolate SHARED_COMMITTER init from the global rayon pool#148
flyq merged 8 commits into
mainfrom
fix/issue-146-shared-committer-init-deadlock

Conversation

@flyq

@flyq flyq commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Fixes issue #146. cargo test --features test-bucket-resize could 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 in SHARED_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 runs Committer::new, whose table build is a par_iter over 256 bases on the global rayon pool (banderwagon/src/salt_committer.rs:165 via the iter! macro). Rayon workers blocked at a join point steal other pending jobs on top of their stack. StateRoot::rebuild's parallel closures call StateRoot::new — which dereferences SHARED_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, replacing once_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-threads only shrinks the race window.

Fix

Two independent changes, plus a warm-up:

  • Init isolated from the global pool (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 via install() 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 the LazyLock so every later deref fails without the original cause, and an inline fallback would reopen both deadlock channels. The diagnostic goes through let _ = writeln!(stderr, …) rather than eprintln!, which panics on an unwritable stderr and would reinstate that poisoning path.
  • Parking waiters under std (salt/src/lib.rs:14): Lazy<T> is std::sync::LazyLock under std and spin::Lazy only for no_std targets, where parallel (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.
  • Warm-up before 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_guarded helper (salt/tests/common/mod.rs) that runs the test body on a worker thread and fails the test via recv_timeout if 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-fix main 5/5 on a 14-core host (and the final harness was re-validated against pre-fix main: 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-featurestest-bucket-resize), riscv64imac-unknown-none-elf no_std check, fmt, clippy.

Notes

  • The issue's suggested --test-threads=4 CI 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.
  • Bundled churn: mutants/suppressions.toml is touched twice to re-pin four line-anchored timeout suppressions for create_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 with cargo mutants --list + mutation_gate.py orphans: all 38 suppressions match.
  • Visible API nuance: PRECOMPUTED_WEIGHTS is pub, so its type changes from spin::Lazy to std::sync::LazyLock under std. Deref-only usage (all known consumers, incl. stateless-validator) is unaffected.
  • Pre-existing, unrelated: enable-hugepages + parallel does not compile on Linux (banderwagon/src/salt_committer.rs:107, missing IntoParallelRefIterator import; combination not covered by CI). Worth a follow-up issue.
  • Production cold start previously risked N−1 threads busy-spinning through a ~150 ms–1.3 s init on first block validation; with parking waiters plus the dedicated pool this becomes a clean one-time wait.
  • The diff-scoped mutation gate cannot structurally exercise the threading fix (cargo-mutants has no "remove the thread wrapper" mutant; the only viable in-diff mutant is killed by existing root assertions) — the regression binaries are the coverage for the fix itself, and they run under the mutation harness with a timeout below the harness minimum, so a reintroduced deadlock reports as a caught failure.

🤖 Generated with Claude Code

flyq and others added 2 commits July 22, 2026 19:50
…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>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Performance Benchmark Comparison

Compared 5 benchmark(s) against the latest main baseline.

Detailed Comparison
Benchmark Baseline Throughput (Kelem/s) New Throughput (Kelem/s) Change
update 10000 KVs/1 threads 73.06 79.00 +8.14%
update 10000 KVs/2 threads 139.15 145.66 +4.68%
update 10000 KVs/4 threads 258.45 269.66 +4.34%
update 10000 KVs/8 threads 432.99 454.76 +5.03%
update 10000 KVs/16 threads 579.69 603.57 +4.12%

@github-actions

Copy link
Copy Markdown

Mutation testing - PASS

Nothing to test: no viable mutants were generated (2 unviable, 0 timed out).

flyq and others added 4 commits July 23, 2026 00:00
… 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread mutants/suppressions.toml Outdated
@mega-putin

mega-putin Bot commented Jul 23, 2026

Copy link
Copy Markdown

Independent A/B validation on the same 32-logical-CPU x86_64 host where issue #146 was naturally reproduced: PASS.

Revision / configuration Result
Control (19419f4, before this fix), ordinary cargo test 3/5 hung; each classified by the 300 s watchdog
Control (19419f4, before this fix), NUM_DATA_BUCKETS=2 resize configuration 3/5 hung; each classified by the 300 s watchdog
PR head (ff8442f), ordinary cargo test 15/15 terminated normally
PR head (ff8442f), NUM_DATA_BUCKETS=2 resize configuration 15/15 terminated normally

All A/B runs used default libtest concurrency, with no --test-threads restriction.

The two new dedicated regression-test binaries also passed repeated execution:

  • shared_committer_init: 50/50 PASS
  • shared_committer_init_os_winner: 50/50 PASS

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.

Comment thread salt/src/trie/trie.rs Outdated
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>
@flyq
flyq requested a review from Troublor July 23, 2026 06:12

@Troublor Troublor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@flyq
flyq merged commit cc426a9 into main Jul 25, 2026
13 checks passed
@flyq
flyq deleted the fix/issue-146-shared-committer-init-deadlock branch July 25, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants