ci(unit-tests): raise pytest job timeouts from 30 to 45 minutes - #716
Conversation
`test`, `integration` and `gate` share one wall, `timeout-minutes: 30`, against measured worst *passing* runs of 27.6, 29.4 and ~27.2 minutes -- a 36-second margin on `integration`. Six jobs were killed by the ceiling in one day, five on required contexts (`Integration Python 3.10`-`3.14`, five of ruleset 23497679's fifteen required checks), each needing a manual re-run before its PR could merge. Two of the killed jobs re-ran clean under a drained Actions queue, so the suite does fit in 30 minutes when runners are free -- the kills are contention pushing a 27-29 minute job past the wall, not an impossible ceiling. 45 is ~1.55x the observed maximum: enough that queue contention alone cannot kill a job, tight enough that a genuine runaway is still bounded well short of an hour. - `.github/workflows/unit-tests.yml:54` (`test`, check `Python 3.1x`): 30 -> 45 - `.github/workflows/unit-tests.yml:96` (`integration`, check `Integration Python 3.1x`): 30 -> 45 - `.github/workflows/unit-tests.yml:207` (`gate`, check `Gate (full suite, Python 3.14)`): 30 -> 45 `changelog` (`:179`, 5 min, completes in 6-10s) and `lint.yml:72` (30 min, completes in 4.0 min) are untouched: ample margin, never killed. This is insurance against contention, not a speedup, and not a substitute for #715's job-count reduction; revert to 30 once that lands and the jobs run shorter on their own. No test added: a `timeout-minutes` value has no runtime behaviour to assert on, and CONTRIBUTING.md's own rule calls for a test case only when a change "fixes a bug or adds new functionality" -- this is neither. Checked tests/test_tier_guard.py and tests/_tiers.py, the only tests/ modules that reference unit-tests.yml; neither pins timeout-minutes or job counts. Fixes #713
|
✅ GOOD TO MERGE — cross-review on Opus (author was Sonnet): diff is exactly the right 3 lines, both exclusions verified safe from real data ( |
Cross-review write-up — #716 @
|
| job key | line | timeout on branch |
|---|---|---|
test: @43 |
:54 |
45 |
integration: @91 |
:96 |
45 |
changelog: @176 |
:179 |
5 (untouched) |
gate: @203 |
:207 |
45 |
The file contains exactly four timeout-minutes, so 3-of-4 changed and the fourth is the one deliberately excluded. No adjacent key was hit by mistake.
A completeness check the PR doesn't make, which strengthens it: ruleset 23497679 requires 15 contexts — Python 3.10–3.14 (the test job), Integration Python 3.10–3.14 (the integration job), and Compat Python 3.10–3.14. The first ten are raised here. The other five come from python-compatibility.yml, which contains zero timeout-minutes and so defaults to GitHub's 360-minute cap — it cannot be timeout-killed. No required context is left exposed to a 30-minute wall. Repo-wide, only lint.yml and unit-tests.yml carry a job timeout at all.
2. The two exclusions are untouched and genuinely safe — CONFIRMED, with one small correction.
lint.yml does not appear in the diff at all; lint.yml:72 is still timeout-minutes: 30 and unit-tests.yml:179 still 5. I did not take the stated durations on trust:
| job | n (instances that got a runner) | min | median | p95 | max | margin to own wall |
|---|---|---|---|---|---|---|
lint |
80 | 3.27 | 4.92 | 5.17 | 5.22 | 5.7× |
changelog |
270 | 0.07 | 0.10 | 0.15 | 0.68 | 7.4× |
My own separate 18-run sample of today's runs independently gave lint max-success 5.2 / median 5.0 and changelog median 0.1 — consistent. Neither has ever been killed; lint's two cancelled instances are 3.98 and 5.02 min, i.e. cancel-in-progress, not a timeout. Excluding both is correct. lint is also not among the 15 required contexts, so even a lint kill would not block a merge.
Correction: the body says lint "completes in 4.0 min". Measured median is 4.92 and max 5.22. Immaterial to the decision, but 4.0 is the floor of the distribution rather than its centre.
3. 45 is defensible — CONFIRMED, though the body understates its own case and omits the trend.
The table's figures are all too low. Verified individually:
integrationworst passing: 29.92 min, not 29.4 — job106872271661(Integration Python 3.12),started_at 2026-09-22T19:30:11Z→completed_at 2026-09-22T20:00:06Z,conclusion: success, 9 steps, attempt 1. It cleared the 30-minute wall by 5 seconds, not 36.testworst passing: 29.42 min, not 27.6 — job107020071289(Python 3.10),03:13:53Z→03:43:18Z, success, 8 steps.gate: the column is headed "measured worst passing run", but nogatejob has ever executed — 25 of 25 sampled instances areskipped, sinceif: inputs.gate-only == trueis false on push/PR. So~27.2 minis a proxy, not a measurement.
On that last point I went looking for a real defect and found the PR is sound. gate runs bare python -m pytest -q with no --ignore flags, so I suspected it was heavier than integration and that 45 might be too tight for it. It isn't: integration runs the same bare python -m pytest -q, the same .[test,Scapy] install and the same sample-regeneration step. gate is step-for-step one leg of the integration matrix pinned to 3.14, so integration's distribution is a legitimate proxy and 45 is exactly as right for gate as for integration. create-release.yml:85-87 calls this workflow with gate-only: true, so it is live config, not dead. Only the table's "measured" label is wrong.
Two things worth putting on the record:
- Against the true worst case,
45 / 29.92= 1.50×, not 1.55×. Same conclusion, slightly less headroom than advertised. - Zero jobs in the entire measured history exceeded 31 minutes of on-runner execution. There are no runaway or hung jobs in this repo, so the "tight enough that a genuine runaway is still bounded" half of the rationale is doing almost no work. The ceiling's real function is absorbing contention-inflated runs, which is the stated diagnosis — the rationale is just broader than the evidence needs it to be.
The omission that actually matters. Success-only execution time is rising monotonically, and the body's "~1.55× the observed maximum" is a static framing of a moving number:
| day | test p50 |
integration p50 |
integration p95 |
|---|---|---|---|
| 09-13 | 2.62 | 3.67 | 3.92 |
| 09-18 | 10.80 | 13.02 | 16.00 |
| 09-20 | 13.47 | 16.05 | 19.12 |
| 09-22 | 17.93 | 20.68 | 24.18 |
| 09-23 | 20.92 | 24.78 | 28.43 |
≈ +2.00 min/day (test) and +2.31 min/day (integration) over nine days. 45 − 28.43 (today's p95) at +2.31/day is ≈ 7 days. On 2026-09-23, 91.5% of successful integration jobs exceeded 20 min and 43.0% exceeded 25 min. Stating the observed rate, not projecting that it holds — but the reader should know 45 is a ~1-week ceiling rather than an open-ended one. That is an argument for urgency on #715, not against this PR.
One methodological trap for anyone re-deriving these numbers: completed_at - started_at is not execution time. A job cancelled before it gets a runner reports started_at == created_at with steps: [], so the span is queue wait — that manufactures a "78-minute" run of Changelog drift, a job with a 5-minute ceiling. 2,133 of 11,837 non-skipped instances never got a runner. My own first pass hit this (lint showed a 21.8-min "max" that was pure queue wait). Filter on the presence of steps. Relatedly, conclusion == "timed_out" appears zero times; a real kill is cancelled with duration ≈30 min, of which I confirm 10 (9 integration, 1 test) — and all ten land on required contexts, so "five of six" understates that too.
4. No test added, and the justification holds — CONFIRMED.
grep -rn "timeout-minutes" tests/ → zero hits, re-run myself. Extending it repo-wide across *.md, *.rst, *.txt, *.cfg, *.toml, *.ini, *.py for timeout-minutes or a "30 min" budget → zero hits, so no document states the value either and nothing else needed updating.
tests/test_tier_guard.py:92-109 does parse this exact file, and is genuinely unaffected. It reads the whole text but matches only:
globs = set(re.findall(r"--ignore-glob='\*([^']+)'", text))
directories = set(re.findall(r'--ignore=tests/(\S+)', text))and compares those to _tiers.FIXTURE_TIER_SUFFIXES / FIXTURE_TIER_DIRS. Neither regex can see a timeout-minutes line. No other assertion in that module touches the workflow, and SuiteIsCleanTests walks tests/, not .github/.
A further check the body doesn't claim but which the change depends on: nothing else caps these jobs. pytest-timeout is not a dependency anywhere, and [tool.pytest.ini_options] (pyproject.toml:278) sets only testpaths and python_files — no addopts, no faulthandler_timeout. The job-level timeout-minutes is the sole ceiling, so raising it genuinely takes effect rather than being defeated by a second limit. CONTRIBUTING.md's quoted rule is verbatim accurate (it sits at lines 27-28).
One inaccuracy: the body says test_tier_guard.py and _tiers.py are "the only modules that reference unit-tests.yml". There is a third — tests/protocols/misc/pcap/test_header_frame_unit.py:417 — but it is prose inside a docstring and the module never opens the file, so the material claim (nothing asserts on timeout-minutes) stands.
5. The body is honest about what this does not do — CONFIRMED, verbatim and unsoftened.
Both required statements are present in bold and neither is hedged: "This does not make CI faster and is not the fix.", with #715 named and its ~285 min / 31→178 min basis given; and "Counter-argument, stated plainly: a longer ceiling lets a contended job hold a runner slot longer, which can deepen the backlog behind it. That is a real cost, and the reason this is meant to be temporary", followed by the explicit revert condition. #713 is OPEN with labels bug+ci exactly matching this PR's; #715 is OPEN and is the structural work. Closes #713 present.
On the run-level figures: ~285 min of execution per run is real but is the recent peak, not typical — across 601 fully-executed successful runs the median summed execution is 139.5 min, p95 245.0, max 292.0, and the five largest are all 2026-09-23. Wall-clock median is 23.5 min with max 411.5; the "~178 min busy" figure is exactly run 35867536092. So the true swing is wider than 31→178 in both directions, and "~31 min quiet" sits near p60 rather than at the quiet end. This affects #715's framing, not this PR's correctness.
Net positive or net negative? — net positive, by a wide margin
The arithmetic the body leaves implicit decides it. A timeout kill is the worst possible outcome per unit of useful work: it burns the full 30 runner-minutes and produces nothing, then needs a manual re-run that burns ~25-30 more to reach the same answer — ~55-60 runner-minutes to get one required check green, plus a human noticing. Letting that same job run to 31-33 minutes costs 1-3 extra runner-minutes and yields a usable result.
On measured numbers (9 integration kills on 2026-09-23), raising the ceiling removes on the order of 270 wasted runner-minutes/day of re-run work. The contention cost the body honestly raises is bounded at 15 extra minutes per job that genuinely needs 30-45 — and it only materialises for jobs that actually use the extra time. The decisive measurement is that nothing in the entire history has exceeded 31 minutes of execution: there are no hangs for the longer ceiling to indulge. So the cost side of the counter-argument is near-hypothetical while the benefit side is measured, and the load falls on integration, which supplies 5 of 15 required contexts and therefore blocks merges until a human intervenes.
I looked for the case that it is net negative and could not build one from this data. It would need either runaway jobs (none exist) or jobs that will exceed 45 and be killed anyway — and the growth trend is the honest version of that worry, which argues for #715 landing inside a week rather than against the tourniquet.
Non-blocking recommendations
- Label the
gaterow a proxy from theintegration3.14 leg rather than a "measured worst passing run" — the jobs are step-for-step identical, so the number is defensible, but nogatejob has ever run. - Correct the table:
test27.6 → 29.42,integration29.4 → 29.92; the real margin was 5 seconds, and "~1.55×" is 1.50×. All in the direction of a stronger case. - Add one line that success-only execution is growing ~+2.3 min/day, so 45 is a ~1-week ceiling. Without it, a reader in a fortnight will reasonably wonder why the "1.55× headroom" stopped working.
Could not establish
- No direct
gatemeasurement exists, so the 45-minute ceiling forgaterests on the code-level argument that it is identical to oneintegrationleg. I verified that identity from the workflow source, but not from a gate execution. - Whether the ~+2.3 min/day growth is suite growth or contention inflating on-runner time. The magnitude (
testp50 2.62 → 20.92 min in ten days) points to the suite itself getting heavier, but I did not attribute it. - I could not reconstruct the exact "six jobs killed in one day" snapshot the author saw; by my measurement the count reached 10 (9
integration, 1test), all on required contexts.
My worktree is clean — no edits, no commits, no pushes. Scratch analysis scripts live in /tmp, outside the repo.
What
Three lines in
.github/workflows/unit-tests.yml,timeout-minutes: 30->45::54testPython 3.1x:96integrationIntegration Python 3.1x:207gateGate (full suite, Python 3.14)changelog(:179, 5 min, completes in 6-10s) andlint.yml:72(30 min, completes in 4.0 min) are untouched — both have ample margin and neither has ever been killed.Why
The margin on
integrationis 36 seconds — 29.4 min measured against a 30-minute wall. Six jobs were killed by the ceiling in one day, five of them on required contexts (Integration Python 3.10-3.14are five of the fifteen required by ruleset23497679), and each kill needs a manual re-run before its PR can merge.Two of the killed jobs were re-run under a drained Actions queue and both passed, which confirms the suite fits in 30 minutes when runners are free — the kills are contention pushing a 27-29 minute job past a 30-minute wall, not the suite being unable to finish.
45is chosen as ~1.55x the observed maximum: enough headroom that contention alone cannot kill a job, tight enough that a genuine runaway is still bounded rather than burning a full hour.This does not make CI faster and is not the fix. The structural work — cutting job count and duplicated execution — is #715, which measured that the same commit takes ~285 minutes of execution regardless of queue state, while wall-clock to green swings from 31 min (quiet queue) to 178 min (busy queue). This PR only stops required checks from dying while that structural work lands.
Counter-argument, stated plainly: a longer ceiling lets a contended job hold a runner slot longer, which can deepen the backlog behind it. That is a real cost, and the reason this is meant to be temporary — once #715's items land, job count and contention both fall, the jobs themselves run shorter, and this should be reverted to 30.
Closes #713
Tests
No test added. A
timeout-minutesvalue has no runtime behaviour to assert on — there is nothing to run and observe, only a number to restate.CONTRIBUTING.md's own contribution-flow rule calls for a test case "if the change fixes a bug or adds new functionality"; this is neither, it's a CI-budget number.Checked whether anything under
tests/project/(or elsewhere intests/) asserts on workflow contents, since a change like this could silently break a test that pins the file:tests/test_tier_guard.pyandtests/_tiers.pyare the only modules that referenceunit-tests.yml, and both check only the--ignore/--ignore-globselection thetestjob uses (WorkflowAgreementTests.test_ignore_flags_match_the_fixture_tier_constants), nottimeout-minutesor any job-count/shape assertion. Nothing needed updating.Changelog
Not added here per house convention (changelog text goes to #657's branch). Proposed line for whoever maintains that: