ci: add job-level timeout backstop and pytest-timeout - #309
Merged
Merged
Conversation
PR #308 hung ~55 minutes with nothing to stop it; add a 20-minute job-level timeout plus a 120s per-test pytest-timeout (thread method, since SIGALRM can't interrupt a C-level lock).
jayhesselberth
force-pushed
the
ci-pytest-timeout
branch
from
September 14, 2026 00:21
7cd1b95 to
0fadffc
Compare
… races pytest-timeout was declared under [dependency-groups].dev, which CI's install step never installs (it runs `uv pip install -e ".[test,rust,onnx]"`, not `uv sync`) -- the whole per-test timeout was a silent no-op in CI. Move it to the `test` extra instead, verified by simulating CI's exact install command in a fresh venv. Also mark the three existing hang-regression tests that carry their own subprocess.run(timeout=120) (test_inference.py's test_parallel_path_alone_writes_tags and test_a_fork_after_a_waited_shutdown_completes, test_parallel_prep.py's test_python_backend_real_pool_matches_rust_chunk_set) with @pytest.mark.timeout(150), so their own internal timeout fires and reports cleanly instead of racing the new global 120s timeout. And distinguish "pytest finished with nothing to report" from "pytest was killed mid-run" in the job-summary action, so a future timeout kill doesn't get a falsely reassuring "no skipped/xfailed tests" summary.
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.
Summary
timeout-minutestoci.yml'stestjob as a hard backstop behind pytest-timeout's per-test enforcement — it covers hangs during collection or the pre-test install/Rust-build step that pytest itself can't catch.pytest-timeout(in thetestextra, so CI'suv pip install -e ".[test,rust,onnx]"actually installs it — see "Code review findings" below) with a 120s per-test cap,timeout_method = "thread"since the platform-defaultsignalmethod (SIGALRM) can't reliably interrupt a hang stuck in a C-level lock — exactly the shape of the fix: unify predict's config resolution and chunk extraction (#269, #268) #308 deadlock.Prompted by PR #308's "Run tests" job hanging for ~55 minutes with nothing to stop it until a human noticed and cancelled it (
gh run cancel). Root cause there was a fork-safety hazard (amultiprocessing.Poolforked after libtorch's thread pool had already initialized) that's already fixed elsewhere — this PR is the systemic safeguard so a different cause with the same symptom gets caught automatically instead of wasting up to an hour of CI compute before a human notices.How the 120s value was picked
Ran the full suite with
--durations=10on a dedicated 8-core allocation, against the same extras CI installs (test,rust,onnx):The slowest real test is ~22s (the two-rank DDP checkpoint/gradient-parity tests in
test_distributed_training.py, which spin up a genuine 2-process process group). 120s leaves >5x headroom for a slower/more contended CI runner while still failing well inside the 20-minute job backstop.timeout_method: left as the explicit"thread"rather than the per-platform default, because on Linux (CI) that default is actually"signal"— which only interrupts the interpreter at the next bytecode boundary, and would not reliably catch a hang stuck in a C-level lock the way #308's fork deadlock was.Verification
Temporarily added a test with
@pytest.mark.timeout(5)+time.sleep(9999), confirmed pytest-timeout'sthreadmethod killed it (dumped every thread's stack, exited 1) well before the sleep could complete, then removed the test before committing — it is not part of this diff.Code review findings (fixed before landing)
/code-reviewon the diff caught two real bugs the initial version had:pytest-timeoutwas first added viauv add --dev(a uv dependency-group), but CI's install step runsuv pip install -e ".[test,rust,onnx]"— plain PEP 621 extras, which never pull in[dependency-groups]. The whole per-test timeout would have been a silent no-op in CI (just aPytestConfigWarning: Unknown config option), leaving only the job-level backstop. Fixed by moving it to[project.optional-dependencies].test; verified by installing into a fresh venv with CI's exact command and confirmingpytest's startup banner lists the plugin (timeout: 120.0s,timeout method: thread).test_inference.py::test_parallel_path_alone_writes_tags,test_inference.py::test_a_fork_after_a_waited_shutdown_completes, andtest_parallel_prep.py::test_python_backend_real_pool_matches_rust_chunk_seteach carry their ownsubprocess.run(..., timeout=120)as a deliberate hang guard for known fork-deadlock classes (one of them is literally the fix: unify predict's config resolution and chunk extraction (#269, #268) #308 regression test). With the new global 120s timeout, a real recurrence would have the whole pytest process killed viaos._exit()at the same 120s mark instead of letting the test's ownsubprocess.TimeoutExpiredproduce its purpose-built diagnostic. Marked all three@pytest.mark.timeout(150), documented inline.Also fixed a smaller finding:
.github/actions/pytest-skip-summarycouldn't distinguish "pytest finished, nothing skipped" from "pytest was killed mid-run" — athread-method kill would have shown a falsely reassuring "no skipped/xfailed tests" job summary. It now checks for pytest's finalN passed/failed... in X.XXsline before drawing that conclusion.Full suite re-run after all fixes: 1707 passed, 45 skipped (unchanged), 285.77s.
release.yml
release.yml'stestjob already carriestimeout-minutes: 45(added previously, alongside its wheel-build jobs). Left it as-is rather than tightening it to 20: it mirrorsci.yml's job but runs far less often (only on version tags), so it's more likely to hit a genuinely coldSwatinem/rust-cacheand pay full Rust-compile time on top of the test run, unlikeci.yml's job which runs on every PR push and almost always has a warm cache. It's already a bounded hard backstop rather than the unbounded default GitHub Actions timeout, which was the actual gap this PR closes.Changelog
changelog.d/309.changed.md(towncrier fragment, not a directCHANGELOG.mdedit — this repo just migrated to towncrier specifically to avoidCHANGELOG.mdmerge collisions across concurrent PRs).🤖 Generated with Claude Code
https://claude.ai/code/session_01JJssLRQzDyJFFnruK7SoDu