Repository navigation
fix(ep-cpu): bound the SPMD pool teardown join instead of hanging on a wedged worker - #2126
Merged
Merged
Conversation
…a wedged worker `SpmdDecodePools::shutdown` ended in `handle.join()` per worker, and `join` has no timeout. One worker that never left its loop hung teardown forever -- reachable from `Drop`, `shutdown_pools`, and so from ordinary process exit. This is the readiness barrier defect (#2027) at the other end of the pool's life, and it fails the same way: the pool's other workers keep spinning out their blocktime window while the joiner blocks, so the process pins cores and never exits. A hang that burns the machine is worse than one that blocks, because nothing distinguishes it from work. Wait instead on `workers_exited`, which has a bound we can impose and whose `ExitCount` guard fires however a worker leaves, including a panic unwind. Once the count is reached each `join` has only the thread epilogue left, so the wait is bounded in fact. If the deadline passes we do not join: that is the case that hangs, and joining it would reinstate the defect at the moment it is diagnosed. The unjoined workers are detached and reported, which is safe because each owns an `Arc<SharedState>`. Records the count in `workers_abandoned` so the give-up branch is assertable in both directions rather than only logged. Closes #2123 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hat is invisible Review found a real gap: the wait target is `handles.len()`, and the only count that differs from it is `total_workers`, which includes the inline dispatcher shard -- a participant that never runs `worker_loop` and so can never be counted out. Retargeting to `total_workers` would abandon exactly one worker on every real shutdown, and with only a `dispatcher_shard: false` arm the two counts are equal, so every teardown test in the file stays green while production leaks a thread per shutdown. Sweep both arms with an anti-vacuity guard that the inline-shard regime was actually reached. Mutation-checked: retargeting to `total_workers` now fails, and fails naming the arm. Also from review: release the wedge from a drop guard so a failed assertion cannot leave the injected worker sleeping in the pool for the life of the test process, and widen the wedge deadline from 250ms to 2s so a healthy sibling descheduled on a contended host cannot be mistaken for the wedge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby
enabled auto-merge (squash)
August 25, 2026 16:51
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2126 +/- ##
==========================================
+ Coverage 80.50% 81.18% +0.67%
==========================================
Files 427 429 +2
Lines 210487 216013 +5526
Branches 210487 216013 +5526
==========================================
+ Hits 169450 175361 +5911
+ Misses 35343 34876 -467
- Partials 5694 5776 +82
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
justinchuby
added a commit
that referenced
this pull request
Aug 25, 2026
…ging (#2140) Every hang regression test in `decode_spmd.rs` had the defect it exists to prohibit. Each called the operation under test **inline** and asserted the bound **afterwards**: ```rust let started = Instant::now(); pools.shutdown(); assert!(started.elapsed() < Duration::from_secs(30)); ``` That assertion is unreachable in the only case it was written for. If the wait under test loses its bound — the exact regression — `shutdown` never returns, `elapsed()` is never evaluated, and the test does not go red: **it hangs, at full occupancy, indistinguishable from work.** The red state of a hang regression test was a hang. The comments on those asserts said so in as many words — *"an unbounded join fails this by never returning at all rather than by returning late"* — and asserted anyway. That is how a known limitation becomes a shipped one. ## How it surfaced Gaff observed three of these on the shared host, at **40, 50 and 64 minutes elapsed**, ~1.9 cores aggregate, one new instance every ~12 minutes with none of the earlier ones exiting. The binary was a TDD-red dev build of the readiness backstop from before #2027 landed — so the test was doing exactly what it was designed to do, in its failing state, and its failing state was to burn the box. What noticed was `ps`, not CI. His framing is the argument for this PR: > A test that can only fail by hanging is not a regression test for a hang; it is a second instance of it. ## The fix A bound cannot live on the thread whose blocking it bounds. `fails_loud_rather_than_hanging` runs the operation on a spawned thread and waits on a channel, which expires on schedule whether or not the operation ever returns. Panics inside are caught, carried across the channel and resumed on the test thread, so assertions keep their own messages and libtest reports them exactly as before. Applied to all five tests: the readiness backstop's falsifier and its null, the stride test, and both shutdown-join tests. Two consequences are documented at the helper rather than left to be rediscovered: - **The fault-injection knobs are thread-local.** The operation must set them itself; hoisting them into the test body would configure the harness thread and silently disarm the injection — a test that passes because nothing was broken. - **An expired bound leaks the spawned thread**, because a thread wedged in an unbounded wait cannot be reclaimed. That is one thread for the remainder of a suite, in exchange for converting an unbounded CI hang into a named red test in seconds. Stated as the price, not hidden. One further detail: on expiry the harness calls `std::panic::take_hook()` before panicking. Several of these tests silence the panic hook around their own injected faults and restore it on the way out; an operation that hung never reached its restore, so without this the harness's own diagnostic — the only explanation anyone would get — would be swallowed. ## Mutation results Both of these previously hung. Both now produce a named red test in 30s: | mutation | before | after | |---|---|---| | remove the deadline from `join_workers_bounded` | hung, killed at 120s | **RED at 30s** | | neuter the readiness barrier's give-up | the 40–64 minute processes above | **RED at 30s** | Each names the test and says why: ``` shutdown, with a worker wedged before it can be counted out did not return within 30s. This is a regression test for an unbounded wait, so a wait that never ends has to fail it here rather than reproduce the hang the test exists to catch. ``` ## Validation - `cargo test -p onnx-runtime-ep-cpu --lib` — **1827 passed, 0 failed** - `decode_spmd::tests` at `--test-threads=4` — 111 passed, **2.05s** - `cargo clippy --all-features --all-targets` clean; `cargo fmt --check` clean Follow-up to #2126 / #2123 and to #2027. Reported by Gaff. --------- Co-authored-by: resch <resch@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2123.
The defect
SpmdDecodePools::shutdownended in an unbounded join:joinhas no timeout, so one worker that never leaves its loop hangs teardown permanently.Drop for SpmdDecodePoolscallsshutdown(), and so doesshutdown_pools(), so this is reachable from ordinary teardown — including the implicit drop at the end of a test, a session close, or process exit.This is the same defect family as #2027, relocated to the other end of the pool's life. #2027 removed an unbounded wait entering the pool; this removes the unbounded wait leaving it.
It fails the same way, too, and that is the part worth stating plainly: the wedged worker is not the only thread involved. The others are woken and exit, but any worker still spinning out its blocktime window keeps burning a full core while the joiner blocks. The process sits at high CPU and never exits — indistinguishable from work, which is exactly why the 5h40m aarch64 hang behind #2027 went unnoticed until someone read
/proc.The rest of the file already reasons correctly about this. The constructor's failure path, added by #2027, explicitly refuses to join:
begin_shutdownbounds its quiesce wait atSHUTDOWN_DISPATCH_QUIESCEand proceeds loudly. The worker wait path is bounded by a blocktime ramp into a futex park. Soshutdownreasoned about every wait it performed except its last one, which had no bound at all.The fix
Wait on the counter, not the handles.
workers_exitedis incremented by anExitCountdrop guard that fires however a worker leaves, including a panic unwind, and it fires before the thread's epilogue. So:workers_exited >= handles.len(), yielding rather than spinning — spinning here starves the very workers whose departure is the exit condition, the same argument the readiness barrier makes.Detaching is memory-safe:
worker_loop(shared: Arc<SharedState>, …)— each worker owns anArc, so an abandoned worker cannot outlive the state it reads.There is one re-read after the loop, deliberately: a worker can count out between the last load and the deadline check, and abandoning a pool that just became joinable would report a fault that no longer exists. That mirrors the same re-read the readiness barrier does on its own break path.
SHUTDOWN_JOIN_TIMEOUTis 30s — generous on purpose. A healthy worker leaves within microseconds of the stop flag, so a teardown that reaches the deadline is reporting a broken pool, not a slow one. It is a liveness backstop, not a performance bound.Making the branch assertable
The give-up path records
workers_abandoned, exposed asSpmdDecodePools::workers_abandoned(). A branch reported only by a log line is one nobody can prove was not taken, and the healthy direction is half the contract here: a backstop that fires on a correct pool converts every clean shutdown into a leaked thread and a false bug report, which is worse than the hang it guards.Tests, and the mutations that justify them
a_worker_that_never_exits_is_abandoned_instead_of_hanging_shutdownwedges one worker and asserts both thatshutdownreturns bounded and that it reports the worker it gave up on. Neither assertion implies the other: the elapsed bound alone passes on an implementation where the wedge never fired, and the count alone passes on one that reported correctly after hanging for an hour.The wedge is injected inside
worker_loopwhileExitCountis still alive, via a guard declared after it (drop order is reverse of declaration), so it covers every exit path including a panic unwind. That placement is the whole point: wedging after the count would prove nothing, because teardown would see its target reached and join a live thread, which no production path does. Selection is thread-local and latched on the builder thread, per this file's existing doctrine about global fault injection reaching unrelated pools; only the release flag is global, and only a worker already selected by the thread-scoped knob ever reads it.a_healthy_teardown_never_abandons_a_workeris the other direction —abandoned == 0and every worker counted out.Both were falsified by mutation, not assumed:
join()The first is also the cleanest available demonstration that the defect is real on
main: with the fix reverted, the new test does not fail, it never returns.Validation
onnx-runtime-ep-cpufull lib suite: 1816 passed, 0 failed, 76.9scargo clippy -p onnx-runtime-ep-cpu --lib --tests --all-features: clean (covers thetracingarm)cargo fmt --check: cleanAll runs under
scripts/hostlock.sh run(#1806),taskset -c 24-31.Provenance
Found while auditing four processes @holden reported from my worktree holding the box at loadavg 12.44 — three generations of one
--exactdecode-pool test at 38–61 minutes each. Those specific processes are dead and their binary predates #2027 by ~40h, so they are not evidence about currentmain, and the test they named passes 8/8 in 0.26s here. But the shape Holden identified — "worth attaching a bounded wait so a hang fails loudly instead of pinning a core indefinitely" — was a real unbounded wait still onmain, at teardown rather than at build. The report was right even though the processes were not the proof.