Skip to content

fix(ep-cpu): bound the SPMD pool teardown join instead of hanging on a wedged worker - #2126

Merged
justinchuby merged 2 commits into
mainfrom
resch/spmd-bounded-shutdown-join
Aug 25, 2026
Merged

justinchuby merged 2 commits into
mainfrom
resch/spmd-bounded-shutdown-join

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Closes #2123.

The defect

SpmdDecodePools::shutdown ended in an unbounded join:

for handle in handles {
    let _ = handle.join();
}

join has no timeout, so one worker that never leaves its loop hangs teardown permanently. Drop for SpmdDecodePools calls shutdown(), and so does shutdown_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:

Deliberately not joined -- a build that failed this way may have a wedged worker, and blocking on it here would reintroduce exactly the unbounded wait this backstop exists to remove.

begin_shutdown bounds its quiesce wait at SHUTDOWN_DISPATCH_QUIESCE and proceeds loudly. The worker wait path is bounded by a blocktime ramp into a futex park. So shutdown reasoned 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_exited is incremented by an ExitCount drop guard that fires however a worker leaves, including a panic unwind, and it fires before the thread's epilogue. So:

  1. Bounded wait for 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.
  2. If reached, join every handle. Each thread has only its epilogue left, so the wait is bounded in fact, not by hope.
  3. If the deadline passes, do not join. That is precisely the case that hangs; joining it would reinstate the defect at the moment it is diagnosed. Report loudly and detach.

Detaching is memory-safe: worker_loop(shared: Arc<SharedState>, …) — each worker owns an Arc, 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_TIMEOUT is 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 as SpmdDecodePools::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_shutdown wedges one worker and asserts both that shutdown returns 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_loop while ExitCount is 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_worker is the other direction — abandoned == 0 and every worker counted out.

Both were falsified by mutation, not assumed:

mutation result
restore the unconditional join() test hangs; killed at 120s
force the deadline to 0 so the backstop always fires healthy test FAILED

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-cpu full lib suite: 1816 passed, 0 failed, 76.9s
  • cargo clippy -p onnx-runtime-ep-cpu --lib --tests --all-features: clean (covers the tracing arm)
  • cargo fmt --check: clean
  • Both mutations above

All 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 --exact decode-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 current main, 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 on main, at teardown rather than at build. The report was right even though the processes were not the proof.

resch and others added 2 commits August 25, 2026 15:59
…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
justinchuby enabled auto-merge (squash) August 25, 2026 16:51
@codecov

codecov Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.15254% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.18%. Comparing base (272bd94) to head (d9d7153).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-cpu/src/decode_spmd.rs 99.15% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.10% <ø> (ø)
mlas 86.02% <ø> (?)
offline 81.32% <99.15%> (+0.60%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-cpu/src/decode_spmd.rs 90.42% <99.15%> (+0.54%) ⬆️

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@justinchuby
justinchuby merged commit e76fd48 into main Aug 25, 2026
21 of 22 checks passed
@justinchuby
justinchuby deleted the resch/spmd-bounded-shutdown-join branch August 25, 2026 18:11
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>
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.

SPMD decode pool teardown joins workers with no timeout: one wedged worker hangs shutdown forever while burning cores

2 participants