ci: bound apt-get so a stalled mirror can't burn a 6h job - #6211
ci: bound apt-get so a stalled mirror can't burn a 6h job#6211prql-bot wants to merge 6 commits into
Conversation
`apt-get update` has no wall-clock bound, so a connection that opens and then trickles keeps it waiting indefinitely. On 2026-08-18 the musl `build-prqlc-c` job on main sat in it for 359 minutes and was killed at GitHub's 360-minute default; the same shape is live on two more runs today. A job killed at the cap reports `cancelled` rather than `failure`, which is indistinguishable from a superseded concurrency-group run, so a hung build doesn't read as a broken default branch. Route the five apt call sites through a new `apt_install.sh` that bounds and retries `apt-get update`, and cap the two `build-prqlc*` jobs at 30 minutes (slowest observed leg: 11 min) as a backstop.
The first run of this on CI showed 120s is too tight for the degraded path: `azure.archive.ubuntu.com` was unreachable, and apt spent ~40s retrying it before failing over to `archive.ubuntu.com` and starting on the package indices. All three attempts were killed mid-download. 5 minutes covers that comfortably while still bounding the pathological case at ~15 minutes rather than 6 hours.
prql-bot
left a comment
There was a problem hiding this comment.
The diagnosis holds up independently — I confirmed the 360-minute cancelled leg in the jobs API, and sampled 151 successful build-prqlc* legs across the last 40 tests runs: the slowest is 10 minutes (macos-15), musl legs 0–1 minutes, so timeout-minutes: 30 has real headroom and won't turn green builds red. The relative-path invocation is sound at all five call sites — set_version.sh already runs that way from both composite actions, and every consumer (tests.yaml, release.yaml) checks out to the workspace root with no working-directory override. No apt-get call site was missed; the only other one in the repo is the advisory msg: string in Taskfile.yaml.
One substantive gap, inline: timeout without --kill-after doesn't actually guarantee a bound. It sends SIGTERM and then waits indefinitely for the child to exit — I verified this locally, where timeout 3 against a TERM-ignoring child never returned and ran out a 2-minute budget. That matters here because the bound is the feature: the new comment claims the pathological case is bounded "at ~15 minutes rather than 6 hours", and apt defers termination signals while dpkg is working, so the install call is the likeliest one to sit through its TERM. The consequence isn't symmetric across call sites either — build-prqlc* have the timeout-minutes: 30 backstop to catch it, but test-rust.yaml deliberately has none, so a TERM-ignoring apt there is back to burning the 6h cap and reporting cancelled.
Two smaller notes, neither blocking:
release.yaml'sbuild-prqlcandbuild-prqlc-ccarry the same unbounded-job exposure and didn't get the backstop. They share the composite actions, so they do get the apt fix — but a hang from any other cause still runs out the 6h default there. I'm not proposing a number, since those runprofile: releaseacross five targets and I don't have duration samples for them.- The PR description still says the script runs
apt-get updateundersudo timeout 120;5dc802eraised it to 300. Worth syncing so the description and the code agree.
How the numbers were checked
Durations, from repos/PRQL/prql/actions/runs/<id>/jobs over the 40 most recent completed tests runs, filtered to conclusion == "success" — 151 samples. Max per leg:
10 build-prqlc (macos-15, aarch64-apple-darwin, default,test-dbs)
9 build-prqlc-c (macos-15, aarch64-apple-darwin, default,test-dbs)
6 build-prqlc-c (windows-latest, x86_64-pc-windows-msvc, default)
5 build-prqlc (windows-latest, x86_64-pc-windows-msvc, default)
1 build-prqlc (ubuntu-24.04, x86_64-unknown-linux-musl, default)
1 build-prqlc-c (ubuntu-24.04, x86_64-unknown-linux-musl, default)
The same query surfaced the hang: build-prqlc-c (ubuntu-24.04, x86_64-unknown-linux-musl, default) at 360 minutes, cancelled.
Worst case against the 30-minute cap, after 5dc802e, on the leg that actually runs apt (musl): 3 × 300s update + 2 × 10s sleep + 300s install ≈ 20.3 min, plus a ~1 min build. Fits — but that's the arithmetic -k is what makes true.
The timeout check: a script doing trap '' TERM; sleep 600, run as timeout 3 ./ignore_term.sh, produced no exit line and consumed the full 2-minute command budget. timeout -k 2 3 is the form that terminates.
prql-bot
left a comment
There was a problem hiding this comment.
-k 30 closes the gap from the last review — both call sites now have a hard bound, and I re-checked the mechanism independently: timeout -k 2 3 against a trap '' TERM child returns at 5s with exit 137, where plain timeout 3 never returns. Threads resolved. shellcheck and bash -n on the updated script are clean, and the worst case still fits the 30-minute job cap (3 × 330s update + 2 × 10s sleep + 330s install ≈ 22 min).
The release.yaml note from the earlier review stands as a follow-up rather than a blocker — it's a separate concern from what this PR sets out to fix.
Commenting rather than approving because I authored this PR; a maintainer's call on merging. CI is still in flight at this head (52 checks pending, none red yet).
The three-attempt update failure is now a warning; only the install decides the step's exit status. The ubuntu-24.04 image ships populated apt lists, so musl-tools and gcc-aarch64-linux-gnu both resolve with no update at all.
prql-bot
left a comment
There was a problem hiding this comment.
The warn-don't-fail turn is the right call for this failure mode, and the stubbed-PATH replay covers all three paths. One design point, plus some drift the switch left behind.
The retry budget now sits on the call that can't fail. After this commit the update — which no longer decides the step's exit status — gets three attempts and a sleep between them, while the install, which does decide it, gets one. But the install fetches the .deb from the same mirror under the same 300s bound, so a stall there is an immediate red with no retry: the retry logic ended up protecting the call whose failure is now cosmetic. Two attempts each would fit the cap (2 × 330 + 10 + 2 × 330 + 10 ≈ 22 min). I'm not pushing that, because it moves the arithmetic against the timeout-minutes: 30 a reviewer would be signing off on, and there's a fair argument the other way — a mirror that won't serve InRelease probably won't serve the package either, so a second install attempt may just spend five more minutes reaching the same red. Worth a maintainer's call rather than mine.
Inline, four things in the header comment and the exit path. The "failing fast keeps that signal honest" paragraph now argues against the paragraph this commit added below it; ~17 minutes is the update loop's bound rather than the script's (~22, which is what the description says); and "resolve from the baked-in indices with no network at all" describes resolution only — the install still needs the mirror, which is the boundary that decides whether this change actually converts a red into a green.
I've synced the description, which promised an ::error:: naming the cause — this commit removed the script's only one, so an install failure now surfaces as GitHub's generic Process completed with exit code 1. That's the illegible-failure shape this PR exists to fix, so the inline suggestion restores an annotation rather than dropping the claim.
Commenting rather than approving because I authored this PR. CI at this head: 19 checks pending, none red. build-prqlc (ubuntu-24.04, x86_64-unknown-linux-musl) went green in under two minutes, so the mirror has recovered and the warn path isn't being exercised on this run — it's covered by the replay, not by a real stall. The release.yaml note from the first review still stands as a follow-up, not a blocker.
Restores an ::error:: on the one path that now fails the build, carrying apt's exit status through (124/137 means the install hit its own bound; anything else is apt refusing the packages). Corrects three claims in the header comment left behind by the warn-don't-fail switch: the honest-signal paragraph, the ~17-minute bound (the whole script is ~22), and "no network at all" (the install still fetches from the mirror).
prql-bot
left a comment
There was a problem hiding this comment.
feee3437 applies all four points from the last review correctly. I re-replayed the exit path against a stubbed sudo/timeout/apt-get on PATH across all six combinations of update-rc × install-rc: update-ok/install-ok exits 0 silently; update-fail/install-ok emits 4 ::warning:: lines and still exits 0; install-fail emits ::error::apt-get install failed (exit N) for: musl-tools and carries N (1 and 124 both) through to the script's exit. The || status=$? form is the right idiom here — if ! cmd would have clobbered $? to 0 inside the branch — and the trailing if with a false condition exits 0, so the happy path is unaffected.
One new finding, which I've now pushed a fix for rather than leaving as a follow-up. It supersedes the release.yaml note I filed as non-blocking on the first review — the evidence turns out to be much stronger than I framed it then.
The timeout-minutes: 30 backstop doesn't cover two of the three jobs that hung in the incident this PR cites. I pulled the job list for run 32160030161 rather than reading the description's summary of it, and the three cancelled jobs are:
| job | defined in | capped by this PR? |
|---|---|---|
build-prqlc-c (ubuntu-24.04, x86_64-unknown-linux-musl, default) |
tests.yaml |
yes |
nightly / nightly-release / build-prqlc (ubuntu-24.04, aarch64-unknown-linux-musl) |
release.yaml |
no |
nightly / nightly-release / build-prqlc-c (ubuntu-24.04, aarch64-unknown-linux-musl) |
release.yaml |
no |
The two aarch64 legs aren't a separate workflow that happens to share code — tests.yaml calls nightly.yaml on main, which calls release.yaml as nightly-release, so they run inside the same tests run on every main push. tests.yaml's own build-prqlc matrix has aarch64-unknown-linux-musl commented out ("They run on release.yaml regardless"), which is why the cap as written can't reach them. They get the apt bound, since they go through the same composite actions — but the job-level backstop for a hang from any other cause is exactly what they don't get, and they're where two-thirds of the observed 6-hour damage happened.
I picked timeout-minutes: 90 rather than 30, because two things make these jobs genuinely slower than the tests.yaml ones:
- They build
profile: releaseacross five targets. Sampling successfulnightly-release / build-prqlc*legs across the last 40testsruns, the slowest is 24 min (macos-15-intel, x86_64-apple-darwin), then 16 (windows-latest), 13, 12. A 30-minute cap would sit 25% above a routinely-observed duration and start turning green release builds red. - On the
aarch64-unknown-linux-musllegs,apt_install.shruns twice per job — once from therunner.os == 'Linux'step formusl-tools, then again from theinputs.target == 'aarch64-unknown-linux-musl'step forgcc-aarch64-linux-gnu. Both conditions hold on that leg, so the script's ~22 min worst case is ~45 min for the job, plus a ~12 min build ≈ 57 min. Any cap below that would convert a fully-stalled mirror straight back into acancelled, which is the outcome this PR exists to eliminate. 90 clears it, and still turns a 6-hour hang into a 1.5-hour one.
Worth noting the script's header comment says "~22 minutes" — that's per invocation and correct as written, but it's not the per-job bound on those two legs. I haven't touched the comment, since the script is the right place for the script's own bound.
Commenting rather than approving because I authored this PR — a maintainer's call on merging. CI at feee3437: 23 checks pending, none red.
How the durations were sampled
Over the 40 most recent completed tests runs, from repos/PRQL/prql/actions/runs/<id>/jobs, filtered to conclusion == "success" and job names matching nightly-release / build-prqlc. Max per leg, in minutes:
24 build-prqlc (macos-15-intel, x86_64-apple-darwin, default,test-dbs)
16 build-prqlc (windows-latest, x86_64-pc-windows-msvc)
13 build-prqlc-c (windows-latest, x86_64-pc-windows-msvc)
13 build-prqlc (macos-15, aarch64-apple-darwin)
12 build-prqlc (ubuntu-24.04, aarch64-unknown-linux-musl)
10 build-prqlc-c (macos-15, aarch64-apple-darwin)
9 build-prqlc-c (ubuntu-24.04, aarch64-unknown-linux-musl)
4 build-prqlc (ubuntu-24.04, x86_64-unknown-linux-musl)
3 build-prqlc-c (ubuntu-24.04, x86_64-unknown-linux-musl)
The macOS legs aren't cache-assisted here — save-if in both composite actions is (github.ref == 'refs/heads/main') && contains(inputs.target, 'musl') — so 24 min is already close to a cold-cache figure rather than a warm-cache one.
The exit-path replay, six cases, against stubs on PATH:
update_rc=0 install_rc=0 -> exit=0 0 warnings no error
update_rc=0 install_rc=1 -> exit=1 0 warnings ::error::apt-get install failed (exit 1) for: musl-tools
update_rc=0 install_rc=124 -> exit=124 0 warnings ::error::apt-get install failed (exit 124) for: musl-tools
update_rc=1 install_rc=0 -> exit=0 4 warnings no error
update_rc=1 install_rc=1 -> exit=1 4 warnings ::error::apt-get install failed (exit 1) for: musl-tools
update_rc=1 install_rc=124 -> exit=124 4 warnings ::error::apt-get install failed (exit 124) for: musl-tools
| # The slowest of these legs takes ~11 minutes; without a cap, a hung step | ||
| # runs out GitHub's 6h default and reports `cancelled` rather than | ||
| # `failure`. | ||
| timeout-minutes: 30 |
There was a problem hiding this comment.
This cap is right for the jobs it lands on, but it can't reach the ones that produced two of the three hangs in the run the PR cites. nightly / nightly-release / build-prqlc (ubuntu-24.04, aarch64-unknown-linux-musl) and its build-prqlc-c sibling are defined in release.yaml, reached from this same workflow via nightly.yaml -> nightly-release on every main push — and the matrix comment 14 lines below ("They run on release.yaml regardless") is exactly why tests.yaml has no aarch64 leg to cap.
I've pushed timeout-minutes: 90 onto both release.yaml build jobs rather than 30: their slowest successful leg is 24 min (macos-15-intel), and the aarch64 legs invoke apt_install.sh twice — the runner.os == 'Linux' step and the target-gated step both fire — so their per-job apt worst case is ~45 min, not ~22. Details in the review body.
The two `aarch64-unknown-linux-musl` legs that ran out the 6h cap in run 32160030161 are `release.yaml`'s `build-prqlc`/`build-prqlc-c`, reached from `tests.yaml` via `nightly.yaml` on every `main` push — not the `tests.yaml` jobs the previous commit capped. 90 rather than 30: slowest observed leg is 24 min, and the `aarch64` legs invoke `apt_install.sh` twice.
|
On the one point from that review that wasn't inline — the retry budget sitting on the The rebalance is arithmetic-neutral (2 × 330 + 10 twice ≈ 22 min, same as 3 update attempts plus one install), so cost isn't the objection. What's missing is evidence. Every stall observed so far — the 359-minute leg on The asymmetry is also less lopsided than it reads. The If a real CI at |
|
Confirming the
The useful part is that the two legs the incident actually hung on ran green under the new cap on this head. In run 32240625139, Every gating check on I haven't touched the Checks at
|
|
this is way too complicated relative to the benefit @tend-agent we should have a much higher bar for changes that only affect CI runner time; looks like this only happened once? please adjust our guidance |
|
Agreed, and the guidance is adjusted: max-sixty/tend#1015.
One finding here that is separable from the over-build, noted in that PR but not fixed by it: a job killed at the 6-hour platform cap reports |
|
Fair close — I authored this, and the bar in max-sixty/tend#1015 would have stopped it at the first commit. I'd drafted a PRQL-side overlay saying much the same thing before that landed; closed it as redundant (#6213), since the bundled skill is the right home. One correction on the facts, since you asked directly. It's happened more than once — though not often enough to change the verdict. Scanning
The last two are ~14 hours apart and are one incident, so that's three windows, roughly monthly. Each self-corrects on a re-run and costs runner minutes only — so it still doesn't earn a retry script across six workflow files, which is the part I got wrong. Worth recording only because it means the next sweep that finds this will find real recurrence evidence, and the bar it needs to fail on is proportionality, not novelty. Method, and one caveat
Caveat: the apt-stall cause is confirmed only for the August window. The June runs match on job shape, duration and conclusion, but I didn't pull their logs — so a common cause there is inferred, not verified. |
apt-get updatehas no wall-clock bound, and on 2026-08-18 a musl build job onmainstalled inside it for 359 minutes before GitHub killed it at the 6-hour cap. This routes the fiveapt-getcall sites through a newapt_install.shthat bounds each call and retries it, and caps all fourbuild-prqlc*jobs — two intests.yaml, two inrelease.yaml— as a backstop. Anupdatethat can't complete is a warning rather than a build failure, because the packages we install resolve from the runner image's baked-in lists anyway. The stall reproduced twice on this PR's own CI, which is what set both the timeout value and that last decision — see Verification.What happened
On 2026-08-18 at 16:27 UTC,
build-prqlc-c (ubuntu-24.04, x86_64-unknown-linux-musl, default)onmainreachedGet:5 https://archive.ubuntu.com/ubuntu noble-security InReleaseinsidesudo apt-get updateand never came back. The next line in its log isThe operation was canceled— 359 minutes later, at the 360-minute default job timeout (run 32160030161). Two siblingaarch64-unknown-linux-musllegs in the same run died the same way — those two arerelease.yaml'sbuild-prqlc/build-prqlc-c, reached fromtests.yamlvianightly.yaml→nightly-releaseon everymainpush, which is why the cap has to cover both files. That job normally takes under a minute: every other completed run of it across the last 25testsruns finished in 0–3 minutes.It was still happening while this was written —
build-prqlc-con run 32223676248 (themainpush for #6208) andbuild-prqlcon run 32227330677 (#6209) were each over an hour into the same step.Why it matters beyond the wasted runner hours
A job killed at the timeout cap reports
cancelled, notfailure.cancelledis also what a superseded concurrency-group run reports, so nothing watching for a red default branch can tell the two apart —tend-ci-fixgates onconclusion == 'failure'and skipped it. The practical effect: no push-triggeredtestsrun onmainhas concluded successfully since 2026-08-17T08:49Z. The four commits merged on 08-18 (#6202, #6204, #6205, #6207) have never had a greentestsonmain, and nothing surfaced that.The change
.github/workflows/scripts/apt_install.sh(new) — runsapt-get updateundersudo timeout -k 30 300, retrying up to 3 times, then installs the same way.timeoutis invoked undersudorather than the reverse so it signalsapt-getdirectly, and-k 30is what makes the budget a bound rather than a request: plaintimeoutsends SIGTERM and then waits for the child indefinitely, and apt defers termination signals while dpkg is working. Worst case is ~22 minutes, inside the 30-minute job cap.updatethat never completes is a::warning::, not an error — only the install decides the step's exit status, and it annotates its own failure. Theubuntu-24.04image ships with/var/lib/apt/listspopulated, so a mirror that won't serveInReleaseshouldn't fail a step that can resolve without it. Refreshing is still attempted first, since a stale index can 404 at download time. The install still fetches the.debfrom that same mirror under the same bound, so a full outage is still red — just in minutes rather than hours.musl-toolsandgcc-aarch64-linux-gnuinstalls inbuild-prqlcandbuild-prqlc-c, and themusl-toolsinstall intest-rust.yaml. Relative-path invocation from a composite action is the same patternset_version.shalready uses in these two files.timeout-minuteson all fourbuild-prqlc*jobs, as a backstop for a hang from any other cause.tests.yamlgets 30 — its slowest successful leg is 11 minutes (macOS), so roughly 3× headroom, and it matches the valuetest-devcontaineralready uses.release.yamlgets 90: those legs buildprofile: release, their slowest successful leg is 24 minutes (macos-15-intel), and on theaarch64legsapt_install.shruns twice per job (therunner.os == 'Linux'step formusl-tools, then the target-gated step forgcc-aarch64-linux-gnu), so the bounded worst case there is ~45 minutes of apt plus a ~12 minute build. A 30-minute cap on those would turn routine green release builds red.test-rustgets theaptfix but no job-level cap — it's a much longer and more variable job and I don't have evidence for a safe value.What this does and doesn't fix
It doesn't make a stalled Ubuntu mirror work. It bounds the damage: instead of holding a runner for six hours and reporting
cancelled, the job now spends at most ~22 minutes perapt_install.shinvocation (~45 on the twoaarch64legs, which call it twice), and finishes green if the packages are installable from the indices already on the image. It goes red — with an::error::naming the failing packages and apt's exit status (124/137 means the install hit its own bound, anything else is apt refusing the packages) — only when the packages genuinely can't be installed. The underlying flakiness is on GitHub's side.Verification
The stall reproduced twice on this PR's own CI, which is what set both the timeout value and the warn-don't-fail behaviour.
At
7f73bddathe budget was 120s/attempt, andbuild-prqlc (ubuntu-24.04, x86_64-unknown-linux-musl)failed after all three attempts — correctly, and in 6m20s rather than 6h. Reading that job's log showed why 120s was wrong:azure.archive.ubuntu.comwas unreachable (every indexIgn:), apt spent ~40s retrying it before failing over toarchive.ubuntu.com, and was then killed mid-download of the package indices. So the degraded-but-working path needs meaningfully more than two minutes.5dc802e9raises it to 300s, and9bd6e53cadds-k 30so the bound holds even against a process that ignores SIGTERM (verified against atrap '' TERMchild:timeout 3never returns,timeout -k 2 3returns at 5s with exit 137).At
9bd6e53cit reproduced again, and this time 300s wasn't the issue — the mirror was down for the whole 15 minutes. Both musl legs went red (build-prqlc, build-prqlc-c). All three attempts got as far asGet:5 https://archive.ubuntu.com/ubuntu noble-security InReleaseand then sat there until the budget ran out — the exact signature frommain, so the bound worked, but a red build is still the wrong outcome for a step that didn't need the network.7729df04makes theupdatebest-effort.The premise for that, checked on a stock
ubuntu-24.04runner with noapt-get updaterun first:apt-get install -s -yplans a complete install for bothmusl-toolsandgcc-aarch64-linux-gnufrom those baked-in lists, and/var/lib/apt/listsholds 22_Packagesindices on a fresh image. The failing run's own log agrees:Hit:2 https://archive.ubuntu.com/ubuntu noble InReleasemeans apt already had a cached index for the pocket the packages come from.Also checked:
sudo/timeout/apt-getonPATH, all four paths: update-ok + install-ok runs oneupdatethen the install and exits 0; update-always-fails + install-ok emits three::warning::attempt lines plus the summary warning, then installs and exits 0; update-fails + install-fails emits::error::apt-get install failed (exit 1)and exits 1; update-ok + install killed at its bound exits 124, with the status carried through to the annotation.shellcheckandbash -non the script — clean.prettier --checkandyaml.safe_loadon all four edited YAML files — clean.testsruns via the jobs API (151 successfulbuild-prqlc*legs): slowest is 10 min (macos-15), musl legs 0–1 min.