Repository navigation
[Bug] CI: Linux test shards intermittently hang ~15 minutes and are killed, leaving an orphan bun process #1302
Description
Client or integration
GitHub Actions — Cross-platform CI (ci.yml), Linux test N/4 shards
Area
Service lifecycle
Summary
Linux test shards intermittently stop producing output mid-run and are killed at the 15-minute job timeout. The run reports cancelled, not failure, so it reads as infrastructure noise — but it is reproducible enough across unrelated branches to be worth tracking rather than rerunning past.
Filing this because I hit it five times today while doing maintainer triage and kept treating it as flake. That was the wrong call: the hang is consistent in shape, and cancelled runs are easy to rerun into green without anyone noticing the underlying problem.
Observed pattern
Every occurrence looks the same:
- the shard runs normally for ~30-90 seconds and passes hundreds of tests;
- output stops abruptly right after a test that starts a proxy server;
- nothing further is logged for ~14 minutes;
- the runner cancels at the 15-minute timeout;
- cleanup reports
Terminate orphan process: pid (NNNN) (bun).
Across four runs on four unrelated branches, the affected shard varies, which argues against a single bad test:
| Run | Branch | Shard | Result |
|---|---|---|---|
| 31263738953 | codex/260808-1185-windows-shard-assertion |
test 4/4 |
cancelled (twice, incl. rerun) |
| 31255199569 | fix/windows-powershell-popup |
test 2/4 |
cancelled, green on retry |
| 31258815611 | codex/260808-1195-unbound-quota-unknown |
test 3/4 |
cancelled, green on retry |
| 31152916419 | agent/test-windows-ci-shard-command |
test 3/4 |
failure — EEXIST: epoll_ctl in a Bun WriteStream |
It also occurred on dev itself with no PR involved (runs 31259450263, 31259447622), so it is not contributor-branch specific.
The one non-cancelled instance is the useful clue
Run 31152916419 did not hang — it crashed outright while loading tests/autostart-health.test.ts:
# Unhandled error between tests
error: EEXIST: file already exists, epoll_ctl
at new WriteStream (internal:fs/streams:244:58)
# collateral:
error: Cannot call describe() after the test run has completed
EEXIST from epoll_ctl means a file descriptor was registered with the event loop twice. That is the same class of resource-lifecycle problem a hang would produce if the registration deadlocked instead of erroring, which suggests the hang and the crash are one bug with two outcomes rather than two problems.
Last log line before the stall
In 31263738953 (test 4/4), the final output before 14 minutes of silence:
[web-search-loop] done — 1 search, 2 iterations, 13ms
[web-search-loop] cancelled — 1 real searches, 0 placeholders, 13ms
(pass) routed Claude requests give OpenAI sidecars main auth without leaking it to the routed provider
That test is in tests/claude-messages-endpoint.test.ts, and the stall is after it passes — so the suspect is teardown or the next file's setup, not the assertion. In the other runs the stall also follows a test that had just started a proxy listener.
Not reproducible locally
bun test tests/claude-messages-endpoint.test.ts → 38 pass / 0 fail in 2.7s on macOS. Full bun run test → 10009 pass / 0 fail. So this needs the sharded Linux runner environment; a single-file local run will not show it.
Why this matters beyond the noise
cancelled is not failure, and the natural response is to rerun. That works — three of the four went green on retry. The risk is that a genuine hang introduced by a future change is indistinguishable from this, and gets rerun into green by whoever is on triage that day.
Suggested starting points
- The orphan is always
bun, and the stall always follows a proxy-server test, so an un-awaited server close or an fd registered twice across test-file boundaries is the first place to look. --isolateis already used; whether shard boundaries share any process-global listener state is worth checking.- Raising the timeout would hide it. A shard that stops emitting for 14 minutes has already failed.
Version
dev at 14e948525.
Operating system
ubuntu-latest GitHub-hosted runners.
Activity
github-actions commentedon Aug 8, 2026
Automated translation bookkeeping — detected language: unknown.
lidge-jun commentedon Aug 8, 2026
Two more occurrences today, and one of them is the first case where two shards hung in the same run.
Run 31249781400 on PR #1256:
Both hit the 15-minute job timeout to the second, which is the same signature as every prior instance. Also #1301's run 31263738953,
test 4/4, 15:28:02Z → 15:43:17Z — likewise 15m15s.Updated tally: eight occurrences across six branches plus
devitself, with the affected shard varying between 2/4, 3/4 and 4/4. That two shards can hang in a single run makes a single bad test even less likely as the explanation.The 15m15s figure is worth noting on its own: the job timeout is 15 minutes, so these are not tests that run long and finish late. The shard stops producing output and the runner kills it at the deadline — consistent with the orphaned
bunprocess reported in cleanup rather than with slow work.Practical effect on triage today: I approved and reran a batch of contributor PRs, and this is now the main source of non-green results among them. Six came back green; #1256 needed a rerun purely for this. Anyone doing PR triage will keep hitting it, and the risk stays what it was — a genuine hang from a real change is currently indistinguishable from this one, and reruns make both go away.
lidge-jun commentedon Aug 8, 2026
A repeat at the same file, which narrows this considerably.
Run 31274685166 on PR #1301,
test 3/4, 19:32:43Z → 19:47:59Z (15m16s). The last output before the silence:That is the second time the stall lands at
tests/cli-restart-health.test.ts, immediately aftercli-native-profileand immediately after that file logskilled 1 dangling process. The first was run 31152916419 (test 3/4, PR #1185's original head), which showed the identical sequence — including the samekilled 1 dangling processline as the last thing emitted.Earlier occurrences stalled after other proxy-starting tests, which is why I described the shard as varying. But the file repeating twice with the same preceding line is a stronger signal than the shard number varying, and it suggests a more specific place to look than "some proxy test":
cli-native-profilespawns login children and cancels staging;cli-restart-healththen kills a dangling process at startup.bun.EEXIST: epoll_ctlin run 31152916419 was a descriptor registered twice — plausibly the same lifecycle fault when it errors rather than deadlocks.Suggested next step for whoever picks this up: run
bun test --isolate tests/cli-native-profile.test.ts tests/cli-restart-health.test.tsin that order under Linux, repeatedly, and watch whether a child from the former survives into the latter's dangling-process kill.Tally is now nine occurrences. Not rerunning #1301 past it — I would rather this one stay red as evidence than be retried into green.
lidge-jun commentedon Aug 8, 2026
Retracting the file-pair narrowing in my previous comment. It was wrong.
I claimed run
31152916419showed the samecli-native-profile → cli-restart-health → killed 1 dangling processsequence as31274685166. It does not. Checking its log again:31152916419hitEEXIST: ... epoll_ctlwhile loadingtests/autostart-health.test.tsat 07:05:53, continued running, and finished as1 fail / 2 errorsacross 2143 tests in 85 seconds. It contains nokilled 1 dangling processline and was never cancelled. It is a failure, not a hang.31274685166(test(ci): bind the Windows shard assertion to an executable command (#1185) #1301) reports the same Bunepoll_ctlerror while loadingtests/api-storage.test.ts, then reaches the CLI pair and stalls there.31263738953, also test(ci): bind the Windows shard assertion to an executable command (#1185) #1301) stalled aftertests/claude-messages-endpoint.test.ts.So three runs, three different first-affected files. The pair I pointed at is one observation, not a repeated signature, and I built a hypothesis on a comparison I had not actually made.
What survives, stated at the strength the evidence supports:
EEXISTonepoll_ctl, a descriptor registered with the event loop twice — appearing across unrelated files;bun;That is weaker than what I posted, and the earlier comment should be read as retracted rather than refined. My "the shard varies, so it is not one bad test" framing was also imprecise for a different reason — sharding distributes files differently per run, so a varying shard number never argued against a single file.
Apologies for the noise; better to correct it here than leave a wrong lead in the issue for whoever picks it up.
lidge-jun commentedon Aug 8, 2026
Correcting my retraction — I named the wrong file for one run while retracting a claim about files, and overstated causation while correcting an overstatement.
Two different questions, which I had collapsed into one column. Where the
EEXISTfirst appears is not where the output stops:EEXISTunder31152916419tests/autostart-health.test.ts31263738953tests/baseten-provider.test.tstests/claude-messages-endpoint.test.ts31274685166tests/api-storage.test.tstests/cli-restart-health.test.tsMy retraction above said
claude-messages-endpointwas the first affected file in31263738953. It is the last file before the silence; the firstEEXISTin that run is underbaseten-provider. Neither column repeats across the three runs.And the causal claim was still too strong. I wrote that the
EEXISThas "two outcomes — error and continue, or deadlock and get killed". The logs show co-occurrence, not causation: the error appears in all three runs, one of which completed normally as a failure. Whether it causes the hang, shares a cause with it, or is incidental is not established.The honest residue, third attempt:
EEXISTonepoll_ctl— a descriptor registered with the event loop twice — appears in all three runs, under a different file each time.bun.Three passes to state three sentences accurately. Leaving the whole chain visible rather than editing it down, since the corrections are the useful part of the record.
docs(devlog): record WP18, and retract a false narrowing on the CI ha…
docs(devlog): record WP19, the merges that arrived after the campaign…
docs(devlog): plan the seven paired bug-fix landings
test(catalog): give the convergence suite the timeout its runtime needs
Merge pull request #1461 from lidge-jun/agent/fix-1453-sync-transaction
Wibias commentedon Aug 12, 2026
Backlog triage update: keeping this open, but treating it as CI/service-lifecycle work rather than something we can mark fixed from adjacent changes.
The correction chain above matters: the evidence establishes repeated Linux shard hangs and repeated Bun
EEXIST: epoll_ctlobservations, but it does not establish a single test-file culprit or prove that theepoll_ctlerror causes the hang.Before closing this issue, re-run the sharded Linux suite on current
devand check whether the 15-minute silent-stall/orphan-bunsignature still occurs. If it no longer reproduces across repeated runs, close with the exact fixing change or runtime update. If it still occurs, keep the investigation at the process/fd lifecycle level rather than targeting whichever test happened to be last in one run.lidge-jun commentedon Aug 12, 2026
Live reproduction on a Linux host, with the hang state captured before the kill
While running campaign CI on a Linux box (Bun 1.3.14, Ubuntu, 8 cores) I ended up with two orphaned
bun testprocesses from earlier shard runs, still alive at 4h34m and 2h56m of accumulated CPU. That is the same failure this issue describes, except nothing killed them at 15 minutes, so I could inspect them instead of reading a truncated log.What the stuck process actually looks like
Both are spinning, not blocked.
wchanis0and the state isR, so the "hang" is not a process waiting on a syscall that never returns — it is a busy loop burning a full core. That distinction matters for the diagnosis in this thread: a deadlock on a lock or a never-resolving promise would park the thread inS/Dwith a nonzerowchan.Per-thread breakdown is identical in both:
So exactly one thread spins and the whole 20-thread runtime is otherwise idle.
File descriptors at the moment of the hang
Two things worth naming.
The
eventpoll/eventfd/timerfdcounts grow with elapsed time — 4/4/6 on the younger-by-CPU process versus 14/14/16 on the other. Epoll instances accumulating rather than being reclaimed is consistent with theEEXIST: epoll_ctlobservations reported earlier in this thread, and it points at the loop being inside the event-loop/timer machinery rather than inside any one test's assertion.The still-open SQLite owner file and its journal in a per-test temp dir (
ocx-xai-401-codex-*, fromtests/server-xai-oauth-401-replay.test.ts) means the process died mid-transaction from the database's point of view. An open-journalis an uncommitted transaction. This is a native-main owner lock, which lines up with thecli-native-profile → cli-restart-healthneighbourhood named earlier — but note it is an open fd, not proof that this test is the culprit. A later test can inherit a leaked handle from an earlier one.Why the CI signal is misleading
The correction chain above is right that no single test file has been proven at fault, and I am not claiming one. What this evidence does establish:
bun testwas competing with two spinning orphans for cores, and the load average sat above 3 on an otherwise idle box. Any timing-sensitive test running in that window is measuring contention, not correctness. That alone would explain a chunk of the intermittency in this thread.Suggested next step, without guessing at a culprit
Rather than keep bisecting test files, make the runner report instead of vanish: wrap the shard in a hard wall-clock timeout that, on expiry, dumps
/proc/<pid>/task/*/stack, the fd table, and the per-thread states beforeSIGKILL, and exits non-zero so the shard fails rather than cancels. The evidence above took about thirty seconds to collect once the process was still around to inspect; the reason this issue has been hard to close is that CI has been throwing that evidence away.I did not kill these with
SIGQUITfirst, which would have been the better move — a Bun crash dump would have named the spinning frame. Next time one appears I will try that before reclaiming the host.docs(devlog): record WP11 security review of #1540 and #1541
lidge-jun commentedon Aug 12, 2026
Same hang on macOS, this time with a call graph
Follow-up to my previous comment. The Linux orphans there were
State: Rwithwchan0, which told us the process was spinning rather than blocked but could not say where. I have now hit the same thing on macOS during a release preflight —bun test --isolate testsat 55 minutes of CPU, one process, nothing else competing — and macOSsamplegives the frame the Linux/procroute could not.The call graph
The whole 55 minutes sits on the main thread, in one stack:
Two things this adds to the thread.
The repeated identical frame.
0x328b324appears seven times consecutively in a single stack. That is a recursive or trampolining call, and it is the same address every time — not a deep-but-varied stack. Whatever loop this is, it is re-entering itself.The bottom is
kevent64, not a lock. Sampling for 3 seconds and counting syscall leaves:So it is not parked in a futex or a rwlock — it is cycling the kqueue event loop and doing
reads, over and over, making no progress. That is consistent with the Linux side, where theeventpoll/eventfd/timerfddescriptor counts grew with elapsed time. Same shape on both platforms: the event loop is being driven forever rather than draining.This also rules out one plausible theory: it is not a test awaiting a promise that never settles. That would idle the process. This burns a full core.
Practical notes for whoever picks this up
--isolatedoes not avoid it. My run wasbun test --isolate tests, so the per-file isolation the release helper relies on is not a workaround.It is not load-related. The Linux occurrence had three competing processes and I wondered if contention was involved; this macOS one had the machine to itself.
The same run completes in about 7 minutes on the Linux CI host. So this is intermittent rather than a straightforward "the suite got slow" — same commit, same command, 7 minutes there versus 55+ minutes and counting here.
sample <pid> 3 -mayDieis the cheap way to capture this on macOS and does not disturb the process. On Linux,SIGQUITshould produce a Bun dump; that remains untried because I killed the earlier orphans before thinking of it, and this macOS process I sampled instead.Still not proposing a culprit file. But the target is now narrower than "a test hangs": something re-enters
bun + 0x328b324and spins the kqueue loop, on both platforms, intermittently.lidge-jun commentedon Aug 18, 2026
Resolved at the CI level on dev: 9ef7df8 (2026-08-11) added scripts/ci/run-bun-test-batches.sh, wired at ci.yml:301 — Linux shards now run fresh-process batches bounded by GNU timeout (120s, kill-after 15s), and is_bun_runtime_crash matches the exact EEXIST epoll_ctl / WriteStream internal:fs/streams crash signature from this report for bounded retry. The tracked failure shape (14-minute silent hang, shard cancelled, orphan bun process) is structurally eliminated: a stuck batch dies within ~135s and retries. Follow-up watchdog scaling in 053a034 / 12c4fe6. The Bun-runtime root cause remains upstream, but the CI defect this issue tracks is gone. (260818 campaign resolved-issue sweep, verified against origin/dev e446607.)
docs(devlog): record WP18, and retract a false narrowing on the CI ha…
docs(devlog): record WP19, the merges that arrived after the campaign…
docs(devlog): plan the seven paired bug-fix landings
test(catalog): give the convergence suite the timeout its runtime needs
Merge pull request lidge-jun#1461 from lidge-jun/agent/fix-1453-sync-…
docs(devlog): record WP11 security review of lidge-jun#1540 and lidge…
docs(devlog): archive the 48 units whose work landed on dev