Skip to content

ci: detect stalled test batches by silence; fix worker panic under --bail - #36232

Merged
Jarred-Sumner merged 4 commits into
mainfrom
claude/parallel-batch-followups
Jul 29, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
claude/parallel-batch-followups

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Follow-up to #36175, from the first day of runs.

What went wrong

A darwin shard died at the Buildkite job timeout inside its batch (build 84092, darwin-14-x64, 019fa900): darwin buckets ~800 files into one bun test --parallel batch, and the runner's batch cap was max(10 min, 5 s × files) ≈ 68 min — longer than the 45-minute job. So when the batch wedged, the interrupt → name-the-hung-file → solo-retry net never fired; the job was simply killed with no report.

Panic under --bail=N: the coordinator decided whether the panic path or --bail had set bailed by reading it after account_crash — which can itself set it via bail_out(). When the Nth failure was a worker panic, the panic banner was skipped and in-flight siblings kept running.

Fixes

Stall detection by silence spawnSafe gains an idleTimeout, re-armed on every stdout/stderr chunk. A wedged batch is silent, so the batch now aborts gracefully after 4 minutes of no output, naming the hung file — a bound that doesn't grow with batch size.
Cap bounded by the job's life the wall-clock backstop is min(5 s × files, job remaining − 5 min) via BUILDKITE_TIMEOUT, so it can never outlive the job either.
Panic under --bail snapshot bailed before accounting; run abort_on_worker_panic on every panic (its own contract: "runs even if --bail already set bailed").

Distinct annotation title for the new case: a silence stop reads stalled, a wall-clock one timeout.

Verified

Drove the runner over a bucket with one worker-only silent hang and a 10 s idle window: the guard fired at 13 s, the coordinator named the hung file, the seven finished files kept their results, and the hanger was retried alone — 22 s total instead of running to the cap. parallel.test.ts crash/bail/junit cases pass.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 368b86e8-3790-4b0a-be4e-e82ee162d2da

📥 Commits

Reviewing files that changed from the base of the PR and between 99c69bf and f4dc8d3.

📒 Files selected for processing (1)
  • test/cli/test/parallel.test.ts

Walkthrough

Changes

The runner now applies job-budget-aware and output-idle timeouts to parallel Bun processes. Worker reaping preserves crash accounting during bail handling, with regression coverage for parallel worker crashes and abort reporting.

Parallel execution reliability

Layer / File(s) Summary
Budget-aware runner timeouts
scripts/runner.node.mjs
Parallel buckets use remaining job time, configurable idle timeouts, output-based timer resets, and separate stalled-process classification.
Panic-aware worker reaping
src/runtime/cli/test/parallel/Coordinator.rs
Worker reaping records panics as crashes even after bail handling begins.
Parallel bail regression coverage
test/cli/test/parallel.test.ts
Adds coverage for panic reporting, sibling abortion, non-zero exit status, and ASAN/debug timeout adjustments.

Possibly related PRs

  • oven-sh/bun#36175: Modifies the same subprocess-spawn infrastructure with graceful timeout handling.

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: silence-based stalled-batch detection and the worker panic fix under --bail.
Description check ✅ Passed The description covers the PR purpose and verification, though it uses custom section headings instead of the template's exact wording.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I also checked: jobBudgetMs() with BUILDKITE_TIMEOUT unset or already exceeded — the Infinity fallback and outer Math.max(60_000, …) floor keep the batch timeout sane; and the Coordinator change still routes SIGTERM'd siblings (from a prior panic's kill loop) through account_unfinished, since SIGTERM isn't in is_panic_status.

Extended reasoning...

The reported finding is a harmless orphaned timer; the runner exits via process.exit() so it can't hold anything open. I traced a few adjacent edges: (1) when BUILDKITE_TIMEOUT is unset, parseInt("") → NaN → jobBudgetMs() returns Infinity, and Math.min(X, Infinity - 300000) = X, so behavior matches the old cap; when the budget is already negative the outer 60 s floor applies. (2) On the spawnSafe EBUSY retry path, the "spawn" event never fired so idleTimer was never armed — no leak across the recursive retry. (3) In Coordinator.rs, workers killed by abort_on_worker_panic's SIGTERM still hit was_bailed && !panicked → account_unfinished, matching the old behavior; only genuinely panicked workers now go through account_crash + banner even when bailed was already set, which is the stated intent.

Comment thread scripts/runner.node.mjs
Comment on lines +1521 to +1531
timer = setTimeout(expire, timeout);
if (options.idleTimeout) {
armIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
idledOut = true;
expire();
}, options.idleTimeout);
};
armIdleTimer();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When idleTimer fires and calls expire(), the graceful branch reassigns timer = setTimeout(SIGKILL, 15s) without first clearing the original timer = setTimeout(expire, timeout) armed just below — that wall-clock timer (potentially tens of minutes for a large batch) is orphaned and fires later against the already-dead subprocess. Harmless in practice since the runner ends with an explicit process.exit() and kill() on a dead child is a no-op, but a clearTimeout(timer) at the top of expire() (alongside clearTimeout(idleTimer)) makes the invariant hold for both entry points.

Extended reasoning...

What changed the invariant

Before this PR, the timeout callback body was only ever reached as the wall-clock timer's own callback — by the time it ran, timer had already fired, so reassigning timer = setTimeout(SIGKILL, 15s) in the graceful branch was safe: there was nothing armed under the old handle.

This PR extracts that body into expire() and adds a second entry point: idleTimer's callback now also invokes expire(). On that path, the original timer = setTimeout(expire, timeout) at scripts/runner.node.mjs:1521 is still armed when expire() runs. expire() clears idleTimer but not timer, then overwrites the timer variable with the 15 s SIGKILL escalation handle — orphaning the original wall-clock timeout.

Step-by-step

  1. Batch spawns; timer = setTimeout(expire, timeout) arms with, say, a 40-minute cap. idleTimer arms with 4 minutes.
  2. The batch wedges silently. After 4 minutes idleTimer fires → idledOut = true → expire().
  3. expire() runs clearTimeout(idleTimer), then (since the batch always sets gracefulTimeout: true and this is POSIX) sends SIGTERM and does timer = setTimeout(SIGKILL, 15_000). The 40-minute handle is now unreferenced but still scheduled.
  4. Subprocess exits on SIGTERM → done() runs clearTimeout(timer) — but timer now points at the 15 s escalation timer, so only that is cleared. The 40-minute timer remains armed.
  5. ~36 minutes later (if the runner is still alive) the orphaned timer fires → expire() again → SIGTERM to a dead PID (no-op, kill() returns false), arms yet another 15 s timer, and resolve() is a no-op on the already-settled promise.

Why nothing else catches it

done() is the only place that clears timer, and it clears whatever handle the variable currently holds. Once expire() reassigns it, nothing retains a reference to the original timeout. The retry loop and the outer promise are already resolved by the time the orphan fires, so it can't corrupt results — it's purely a leaked timer.

Impact

Low. runner.node.mjs ends with an explicit process.exit(), so the orphan can't hold the process open, and the late expire() call has no observable effect (dead subprocess, settled promise). The only downside is a leaked timer per stalled batch and potentially confusing noise if someone is debugging with per-timer logging — a spurious SIGTERM against a long-dead subprocess minutes after the batch was reported.

Fix

One line — add clearTimeout(timer); at the top of expire() next to clearTimeout(idleTimer):

const expire = () => {
  timedOut = true;
  clearTimeout(timer);
  clearTimeout(idleTimer);
  if (options.gracefulTimeout && !isWindows) {
    ...

That makes expire() idempotent regardless of which timer invoked it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/runner.node.mjs (1)

1508-1516: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear the original wall-clock timer before installing the grace timer.

An idle expiry calls expire() before the wall-clock timeout. The original timer is then overwritten, so done() only clears the 15-second grace timer; the original timer keeps the runner alive and later calls expire() again.

Proposed fix
 const expire = () => {
   timedOut = true;
+  clearTimeout(timer);
   clearTimeout(idleTimer);
   if (options.gracefulTimeout && !isWindows) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/runner.node.mjs` around lines 1508 - 1516, Update the expire function
in the timeout handling flow to clear the original wall-clock timer before
replacing it with the 15-second graceful-shutdown timer. Preserve the existing
idle-timer cleanup and graceful termination behavior while ensuring the original
timer cannot fire again after expire() runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/runner.node.mjs`:
- Line 989: Update the idleTimeout configuration in the runner options to parse
BUN_RUNNER_BATCH_IDLE_MS with Number rather than parseInt, rejecting empty,
non-integer, and negative values while intentionally preserving an explicit
zero; only use the four-minute default when the environment value is absent or
invalid.
- Around line 985-988: Update the timeout calculation in the batch runner around
jobBudgetMs() so it never exceeds the remaining Buildkite budget after applying
the reserve. Remove or adjust the outer 60-second minimum, and skip the batch
when the remaining budget cannot support that minimum timeout.

In `@test/cli/test/parallel.test.ts`:
- Around line 203-216: Update the parallel bail panic fixture around the
Bun.spawn command to disable core dumps before launching the test process on
POSIX, using the existing issue-30205 approach where applicable. Preserve the
panic and sibling-worker behavior, and skip the core-dump suppression or provide
an equivalent Windows-specific setup so the test remains hermetic across
platforms.

---

Outside diff comments:
In `@scripts/runner.node.mjs`:
- Around line 1508-1516: Update the expire function in the timeout handling flow
to clear the original wall-clock timer before replacing it with the 15-second
graceful-shutdown timer. Preserve the existing idle-timer cleanup and graceful
termination behavior while ensuring the original timer cannot fire again after
expire() runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8da39f67-8122-491d-8a5f-361622a20f07

📥 Commits

Reviewing files that changed from the base of the PR and between 789be97 and c426d1d.

📒 Files selected for processing (3)
  • scripts/runner.node.mjs
  • src/runtime/cli/test/parallel/Coordinator.rs
  • test/cli/test/parallel.test.ts

Comment thread scripts/runner.node.mjs
Comment on lines +985 to +988
timeout: Math.max(
60_000,
Math.min(Math.max(10 * 60_000, bucketFiles.length * 5_000), jobBudgetMs() - 5 * 60_000),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not exceed the remaining Buildkite budget.

With 30 seconds remaining, this computes a 60-second timeout because the outer Math.max(60_000, ...) applies after the budget cap. Skip the batch when the reserve is exhausted, or cap the final timeout by the remaining budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/runner.node.mjs` around lines 985 - 988, Update the timeout
calculation in the batch runner around jobBudgetMs() so it never exceeds the
remaining Buildkite budget after applying the reserve. Remove or adjust the
outer 60-second minimum, and skip the batch when the remaining budget cannot
support that minimum timeout.

Comment thread scripts/runner.node.mjs
60_000,
Math.min(Math.max(10 * 60_000, bucketFiles.length * 5_000), jobBudgetMs() - 5 * 60_000),
),
idleTimeout: parseInt(process.env.BUN_RUNNER_BATCH_IDLE_MS || "", 10) || 4 * 60_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Parse BUN_RUNNER_BATCH_IDLE_MS strictly.

"0" silently becomes the four-minute default, while values such as "100ms" are accepted as 100. Parse the present value with Number, reject empty/non-integer/negative values, and handle an explicit zero intentionally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/runner.node.mjs` at line 989, Update the idleTimeout configuration in
the runner options to parse BUN_RUNNER_BATCH_IDLE_MS with Number rather than
parseInt, rejecting empty, non-integer, and negative values while intentionally
preserving an explicit zero; only use the four-minute default when the
environment value is absent or invalid.

Comment thread test/cli/test/parallel.test.ts Outdated
Comment on lines +203 to +216
test(
"--parallel --bail: a worker panic still prints the panic banner and stops sibling workers",
async () => {
using dir = tempDir("parallel-bail-panic", {
"a-hang.test.js": `import {test} from "bun:test"; test("hang", async () => { await new Promise(() => {}); }, 999999);`,
"b-panic.test.js": `import {test} from "bun:test"; test("panic", () => { process.kill(process.pid, "SIGSEGV"); });`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--parallel=2", "--bail=1"],
env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" },
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Suppress deliberate crash core dumps in this fixture.

Unlike test/regression/issue/30205.test.ts:153-181, this SIGSEGV fixture leaves core dumps enabled. In --coredump-upload lanes, scripts/runner.node.mjs will detect the worker-produced core and fail this enclosing test. On POSIX, wrap the command with ulimit -c 0 && exec "$@"; skip or provide a Windows-specific fixture.

As per coding guidelines, tests must be hermetic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/test/parallel.test.ts` around lines 203 - 216, Update the parallel
bail panic fixture around the Bun.spawn command to disable core dumps before
launching the test process on POSIX, using the existing issue-30205 approach
where applicable. Preserve the panic and sibling-worker behavior, and skip the
core-dump suppression or provide an equivalent Windows-specific setup so the
test remains hermetic across platforms.

Source: Coding guidelines

Comment thread test/cli/test/parallel.test.ts Outdated
Comment thread scripts/runner.node.mjs
Comment on lines +1700 to +1701
if (timedOut && (!error || error === signalCode || /^code \d+$/.test(error)))
error = idledOut ? "stalled" : "timeout";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new "stalled" error label never reaches any output: the only caller passing idleTimeout (the parallel batch in runParallelBucket) destructures error at line 973 and never references it again, and spawnBun only reads result.error to decide whether to overwrite it with "core dumped". So the PR description's "a silence stop reads stalled, a wall-clock one timeout" isn't actually delivered — either surface error in the retry log / annotation when !ok, or drop the idledOut distinction.

Extended reasoning...

What the PR added vs. what is consumed

spawnSafe now sets error = idledOut ? "stalled" : "timeout" at scripts/runner.node.mjs:1700-1701, and the PR description promises a "distinct annotation title for the new case: a silence stop reads stalled, a wall-clock one timeout."

idledOut can only become true when options.idleTimeout is set. Grepping the file, idleTimeout is passed at exactly one call site — the parallel batch spawn inside runParallelBucket (via spawnBun → spawnSafe). No other spawnSafe caller sets it, so the "stalled" value can only flow back to that one destructure.

The consumer drops it on the floor

At scripts/runner.node.mjs:973 the batch caller does:

const { ok, error, stdout, crashes } = await startGroup(...)

Reading runParallelBucket end to end (lines 973–1120), error is never referenced again after that destructure. The recovery path branches on ok, suites.size, and the parsed stdout/junit; the retry log at ~line 1096 ("parallel bucket: retrying … / no junit and no streamed evidence …") does not include error; the per-file BuildKite annotation at ~line 1111 does not include it either. startGroup (utils.mjs) simply returns the callback's promise value without printing it, and spawnBun (lines 1800–1821) reads result.error only to decide whether to overwrite it with "core dumped" in the coredump branch — it never emits it.

Step-by-step trace

  1. Batch wedges silently → after 4 min idleTimer fires → idledOut = true → expire() → SIGTERM → subprocess exits.
  2. spawnSafe computes error = "stalled" and returns {ok: false, error: "stalled", stdout, …}.
  3. spawnBun receives it, checks if (isLinux && result.exitCode…) for coredumps (not taken), returns the result unchanged.
  4. startGroup returns it unchanged.
  5. runParallelBucket destructures {ok, error, stdout, crashes} and then executes the !ok recovery: parses junit / streamed interrupt report, computes failed / incomplete, logs the yellow retry line, re-runs solo. error is not read at any of those steps.

The wall-clock-timeout path (error = "timeout") reaches the exact same code with the exact same observable output. There is no annotation, log line, or group title that distinguishes the two.

Why this matters (and why it's only a nit)

Per REVIEW.md: "trace new state to an actual consumer — parsed-but-never-read is a red flag" and "Delete dead code in the same PR that makes it dead." The idledOut boolean and the "stalled" branch are new state with no consumer, and the PR description overstates what shipped.

That said, the stall-detection mechanism itself works: the idle timer fires, SIGTERM triggers the coordinator's interrupt report (which names the hung file), and runParallelBucket retries it solo. Nothing breaks if this merges as-is; the only casualty is the promised observability distinction. Hence nit, not blocking.

Fix

Either wire it up — e.g. include error in the yellow retry log when !ok:

console.log(`${getAnsi("yellow")}parallel bucket ${error ? `(${error})` : ""}: ${evidence ? …}`);

— or, if the distinction isn't worth keeping, drop idledOut and the "stalled" branch and update the PR description.

Comment thread scripts/runner.node.mjs
Comment on lines 1550 to 1560
subprocess.stdout.on("data", chunk => {
armIdleTimer?.();
const text = chunk.toString("utf-8");
stdout?.(text);
buffer += text;
});
subprocess.stderr.on("data", chunk => {
armIdleTimer?.();
const text = chunk.toString("utf-8");
stderr?.(text);
buffer += text;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Addendum to the orphaned-timer nit above: on the exitCode === 0 path, done() clears idleTimer but leaves armIdleTimer callable and does not destroy the streams — since Node's "exit" can fire before stdio drains, a trailing "data" event then re-arms a fresh 4-minute idleTimer that nothing ever clears (the clean-exit branch bypassed beforeDone's counter, so the subsequent "end" won't call done() again). Same harmless class (runner ends via process.exit(), late expire() on a dead subprocess is a no-op), but the clearTimeout(timer)-in-expire() fix from the earlier comment doesn't cover this path — also set armIdleTimer = undefined in done().

Extended reasoning...

Relation to the earlier comment

The earlier nit on this PR is about the wall-clock timer handle being orphaned when idleTimer → expire() reassigns timer without clearing the original. This is a distinct leak on a different path: here it's idleTimer itself being re-armed after done() on the clean-exit path, and the fix suggested there (clearTimeout(timer) at the top of expire()) does not address it.

The path

On exitCode === 0, the "exit" handler calls done(resolve) directly (runner.node.mjs:1544), bypassing beforeDone(). done():

  • clears idleTimer (line 1466) ✅
  • unrefs stdout/stderr but does not destroy them (the destroy branch at 1470–1476 requires exitCode === undefined)
  • does not null out armIdleTimer

Per Node's child_process contract, "exit" can fire while stdio streams still have buffered data — this file's own beforeDone() machinery exists precisely for that ordering, but the exitCode === 0 branch bypasses it. So a trailing stdout/stderr "data" event can arrive after done() and each one calls armIdleTimer?.() (lines 1551/1557), arming a fresh 4-minute idleTimer.

The subsequent stdout.on("end") → beforeDone() only increments doneCalls from 0 to 1 (the clean-exit path called done() directly, so the counter is still 0; 0 === 1 is false), so done() does not run again and nothing ever clears the re-armed idleTimer.

Step-by-step

  1. Parallel batch spawns with idleTimeout: 4 * 60_000. armIdleTimer is defined and arms idleTimer.
  2. Batch runs to completion, coordinator exits with code 0. Node fires "exit" with (0, null).
  3. "exit" handler takes the else branch → done(resolve). done() clears timer and idleTimer, unrefs (but does not destroy) stdout/stderr, resolves the promise. doneCalls is still 0.
  4. A last buffered stdout chunk (e.g. the tail of the summary) is delivered → "data" handler runs armIdleTimer?.() → idleTimer = setTimeout(expire, 240_000).
  5. stdout emits "end" → beforeDone() → doneCalls++ === 1? 0 === 1 → false. done() is not called; idleTimer is not cleared.
  6. 4 minutes later (if the runner is still alive) the orphaned idleTimer fires → idledOut = true → expire() → SIGTERM to a dead PID (no-op), arms a 15 s SIGKILL timer, and resolve() on the settled promise is a no-op.

The parallel-batch spawn is the only spawnSafe caller that sets idleTimeout, and it produces voluminous output ending on stdout, so this ordering is realistic there.

Why the earlier fix doesn't cover it

Adding clearTimeout(timer) at the top of expire() makes expire() idempotent for whichever timer invoked it. It does nothing here: the leak is idleTimer being re-armed after done() on a path that never reaches expire() again until the orphan fires. The re-arm happens in the "data" handler via armIdleTimer, which done() leaves callable.

Impact

Same harmless class as the earlier nit:

  • runner.node.mjs ends with an explicit process.exit(), so an orphaned timer cannot hold the process open.
  • The late expire() mutates timedOut/idledOut on a closure whose spawnSafe has already returned, and kill() on a dead child returns false.

No observable failure — just a leaked timer per clean-exit batch and a spurious late SIGTERM against a dead PID.

Fix

One line in done(), alongside the existing clearTimeout(idleTimer):

const done = resolve => {
  if (timer) clearTimeout(timer);
  clearTimeout(idleTimer);
  armIdleTimer = undefined;   // <-- so post-teardown "data" events no-op
  ...

(Optionally the same at the top of expire(), so the SIGTERM-window "Interrupted while still running:" chunks don't redundantly re-arm either — though done() does clear that one on the way out.)

Comment thread scripts/runner.node.mjs
Comment on lines +77 to +82
const runnerStartedAt = Date.now();
const jobBudgetMs = () => {
const minutes = parseInt(process.env.BUILDKITE_TIMEOUT || "", 10);
if (!Number.isFinite(minutes) || minutes <= 0) return Infinity;
return minutes * 60_000 - (Date.now() - runnerStartedAt);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 BUILDKITE_TIMEOUT is measured from when the agent starts the job (bootstrap → checkout → artifact download), but runnerStartedAt is captured at module load — so jobBudgetMs() overestimates the remaining budget by the pre-runner setup time. If that setup exceeds the 5-minute headroom, a still-producing-output batch's wall-clock cap can outlive the job, so "can never outlive the job" doesn't strictly hold. Still a strict improvement (and the 4-min idleTimeout covers the actual incident); consider widening the headroom constant or noting that it must absorb pre-runner setup.

Extended reasoning...

What the calculation misses

jobBudgetMs() computes the remaining Buildkite job budget as BUILDKITE_TIMEOUT × 60_000 − (Date.now() − runnerStartedAt), where runnerStartedAt = Date.now() at module load (scripts/runner.node.mjs:77). But BUILDKITE_TIMEOUT is the step's timeout_in_minutes (e.g. 45 for darwin/windows at .buildkite/ci.mjs:849), and Buildkite counts that from when the agent accepts the job — which includes bootstrap, git checkout, pre-command hooks, and artifact download of the built binary. All of that happens before runner.node.mjs is even loaded, so none of it is reflected in Date.now() − runnerStartedAt.

The result is that jobBudgetMs() systematically overestimates the true remaining budget by the pre-runner setup duration S. The batch cap at scripts/runner.node.mjs:985-988 is min(5s × N, jobBudgetMs() − 5min), so if S > 5min the cap can exceed the job's actual remaining time and Buildkite kills the job before expire() fires.

Step-by-step example

Take a 45-minute darwin shard where checkout + artifact download takes S = 7 min, and the batch is dispatched T = 2 min after runner.node.mjs loads (so 9 min into the job's real clock):

  1. jobBudgetMs() returns 45 − 2 = 43 min (it thinks only 2 min have elapsed).
  2. Actual job remaining is 45 − 7 − 2 = 36 min.
  3. For ~800 files, 5s × 800 ≈ 67 min, so the batch cap is min(67, 43 − 5) = 38 min.
  4. 38 min > 36 min actual remaining, so if the batch is actively producing output (idle timer keeps re-arming) and simply runs long, Buildkite kills the job at the 36-min mark — 2 minutes before expire() would have fired to name the hung file and trigger the solo retry.

Why the existing safeguards don't fully cover it

The 4-minute idleTimeout catches a silent batch regardless of jobBudgetMs() accuracy — that's the primary defense and it handles the actual incident from build 84092. But the wall-clock cap is the backstop for a batch that keeps emitting output while running long (e.g. one file with many slow tests), and that backstop is off by S. There's no standard Buildkite env var exposing the job-start wallclock (only BUILDKITE_TIMEOUT and BUILDKITE_JOB_ID are available), so the runner can't measure S directly.

Impact

Low — this is a nit, not a blocker:

  • Strict improvement over the pre-PR state: the old cap was max(10min, 5s × N) ≈ 68 min on a 45-min job; the new cap is bounded to ~40 + S min. Even with the overestimate, the window where Buildkite can kill the job before expire() fires shrank from ~23 min to max(0, S − 5) min.
  • Narrow trigger: requires pre-runner setup > 5 min and a batch that continuously produces output up to the cap. The reported failure mode (a wedged/silent batch) is caught by idleTimeout independently.
  • The PR description's "can never outlive the job" is an overstatement of the guarantee, not a code bug per se.

Fix

Either widen the headroom constant (e.g. jobBudgetMs() − 10 * 60_000 to absorb typical checkout + artifact-download time), or add a comment on the − 5 * 60_000 noting it must cover pre-runner setup. A harder fix would inject the job-start timestamp from the pipeline step (RUNNER_JOB_STARTED_AT) so jobBudgetMs() can subtract from the real clock.

Two failure modes from the first day of parallel batches in CI:

- A darwin shard died at the Buildkite job timeout inside an 813-file
  batch: the batch cap (5 s per file) was ~68 min on a 45-minute job, so
  the interrupt/name-the-hung-file/retry net never fired. A wedged batch
  is silent, so watch for that instead of wall clock: spawnSafe gains an
  idleTimeout re-armed on every output chunk; the batch aborts gracefully
  after 4 minutes of silence, naming the hung file, and the wall-clock
  cap is now bounded by the job's remaining time (BUILDKITE_TIMEOUT) so
  the backstop can't outlive the job either.

- On a worker panic, whether it was the panic path or --bail that set
  `bailed` was checked after account_crash — which can itself flip
  `bailed` via bail_out() — so the Nth failure being a panic skipped the
  panic banner and left in-flight siblings running. Snapshot `bailed`
  first, and run abort_on_worker_panic on every panic (its contract:
  runs even when already bailed).
--parallel --bail=1 where the first failure is a worker killed by a
fatal signal, with a sibling worker inside a hanging file: the panic
banner must print and the sibling must be stopped. With the previous
gate the sibling ran on and the run never finished (fails the test's
timeout); with the fix it exits promptly non-zero.
@robobun
robobun force-pushed the claude/parallel-batch-followups branch from c426d1d to 3983787 Compare July 28, 2026 21:01
Comment on lines +210 to +216
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--parallel=2", "--bail=1"],
env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" },
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This SIGSEGV fixture omits the ulimit -c 0 wrapper and BUN_CRASH_REPORT_URL: "" / BUN_ENABLE_CRASH_REPORTING: "0" env overrides that the near-identical precedent at test/regression/issue/30205.test.ts:153-181 uses for the same abort_on_worker_panic path. On Linux Buildkite (where --coredump-upload defaults on), spawnBun in runner.node.mjs scans coresDir before/after and marks the enclosing test error: "core dumped" when the worker's core appears — so parallel.test.ts fails on that lane. This is orthogonal to the Windows-skip comment above; even with test.skipIf(isWindows) added, POSIX coredump lanes still red. Copy the ["/bin/sh", "-c", 'ulimit -c 0 && exec "$@"', "--", …] cmd wrapper and the two env keys from 30205.

Extended reasoning...

What the fixture does vs. what the precedent does

The new test at test/cli/test/parallel.test.ts:203-223 has a worker execute process.kill(process.pid, "SIGSEGV") under --parallel=2 --bail=1 and asserts the panic banner. That is the same abort_on_worker_panic code path already covered by test/regression/issue/30205.test.ts:153-181, whose comment (lines 160-163) says exactly why it wraps the spawn:

CI lanes with coredump-upload flag any new core file in coresDir as a test failure — including the one the worker deliberately produces here. ulimit -c 0 on the coordinator is inherited by the workers; the test is POSIX-only so /bin/sh is available.

The 30205 test therefore spawns as ["/bin/sh", "-c", 'ulimit -c 0 && exec "$@"', "--", bunExe(), "test", …] and passes env: { ...bunEnv, …, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" }. The new test does neither: it spawns bunExe() directly and its env is { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" }. test/cli/run/run-crash-handler.test.ts:396-402 uses the identical wrapper for the identical reason, so this is an established harness convention.

Why the core is actually written

process.kill(pid, "SIGSEGV") delivers a real signal — it does not go through the internal js_segfault/js_panic test hooks in crash_handler_jsc.rs that call suppress_core_dumps_if_necessary(). Bun's crash handler catches SIGSEGV, prints the trace, then re-raises via SIG_DFL, and the kernel writes a core when RLIMIT_CORE > 0. Nothing in this test lowers that limit; bunEnv (harness.ts:64-90) even sets ASAN_OPTIONS with disable_coredump=0, so ASAN doesn't suppress it either.

Why the runner then fails parallel.test.ts

scripts/runner.node.mjs on Linux Buildkite defaults --coredump-upload on (line ~181: isBuildkite && isLinux). Inside spawnBun (lines ~1804-1826) it does readdirSync(coresDir) before and after the child, and if a new core appeared sets result.ok = false and result.error = "core dumped"; "core dumped" is in isAlwaysFailure so the retry gate does not save it. The scan is directory-wide, so the grandchild worker's core is attributed to the enclosing parallel.test.ts invocation.

Step-by-step on a Linux Buildkite lane

  1. runner.node.mjs records existingCores = readdirSync(coresDir) and spawns bun test test/cli/test/parallel.test.ts.
  2. The new test spawns bun test --parallel=2 --bail=1; worker B loads b-panic.test.js and executes process.kill(process.pid, "SIGSEGV").
  3. Bun's crash handler runs, then re-raises SIGSEGV via SIG_DFL; kernel writes /var/bun-cores-…/core.<pid> (ulimit unchanged).
  4. Coordinator sees is_panic_status(Signaled(SIGSEGV)) == true, prints the banner, kills the sibling — the assertions inside the test pass.
  5. parallel.test.ts finishes; spawnBun re-scans coresDir, finds the new core, and overwrites the result to { ok: false, error: "core dumped" }.
  6. parallel.test.ts is reported as failed with error core dumped; isAlwaysFailure("core dumped") is true so it stays red on retry.

Crash-reporter env

Separately, bunEnv does not set BUN_CRASH_REPORT_URL / BUN_ENABLE_CRASH_REPORTING (harness.ts:64-85). It spreads ...process.env, so on Buildkite it may inherit the runner's remap-server URL rather than being disabled — and locally it inherits nothing, so the crash handler is free to hit the default reporting host, which violates REVIEW.md's "Never contact external network hosts" hermeticity rule. The 30205 precedent explicitly sets both to "" / "0", and REVIEW.md says "Copy harness conventions exactly." The core-dump failure alone is sufficient to block; this is the second reason to copy the precedent env verbatim.

Relation to the earlier Windows comment

The prior 🔴 comment on this test says to add test.skipIf(isWindows) because is_panic_status() is POSIX-only. That is correct but does not fix this: with the Windows skip in place, the test still runs on Linux Buildkite and still leaks the core. Both fixes are needed. Conveniently, once skipIf(isWindows) is added the /bin/sh wrapper is safe to use unconditionally — exactly as 30205 does.

Fix

Match the precedent one-for-one:

await using proc = Bun.spawn({
  cmd: ["/bin/sh", "-c", `ulimit -c 0 && exec "$@"`, "--", bunExe(), "test", "--parallel=2", "--bail=1"],
  env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0", BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" },
  cwd: String(dir),
  stderr: "pipe",
  stdout: "pipe",
});

(combined with test.skipIf(isWindows) from the earlier comment).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The switch to crash_handler.segfault() fixes the core-dump half — that hook calls suppress_core_dumps_if_necessary() (crash_handler_jsc.rs:75) which zeroes RLIMIT_CORE before faulting, so the ulimit -c 0 wrapper is no longer needed. 👍

The two env keys are still missing though, and js_segfault() does not call suppress_reporting(). On CI runner.node.mjs sets BUN_CRASH_REPORT_URL=http://localhost:<remapPort> (line 1780), which bunEnv inherits via ...process.env — so the worker's deliberate crash still POSTs to the remap server. Per the comment at test/cli/run/run-crash-handler.test.ts:7-10, that makes the runner pin the crash on the next unrelated failing test as "crash reported" and blocks its retry. Add the env overrides:

env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0", BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" },

@robobun

robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 4:22 PM PT - Jul 28th, 2026

@Jarred-Sumner, your commit f4dc8d3 is building: #84463

process.kill(pid, "SIGSEGV") throws "Unknown signal" on Windows instead
of crashing the worker, so there the file was an ordinary failing test
and no panic ever occurred. Trigger a genuine segfault with the
crash-handler test primitive, which every platform delivers as a fatal
signal.
Comment thread scripts/runner.node.mjs
60_000,
Math.min(Math.max(10 * 60_000, bucketFiles.length * 5_000), jobBudgetMs() - 5 * 60_000),
),
idleTimeout: parseInt(process.env.BUN_RUNNER_BATCH_IDLE_MS || "", 10) || 4 * 60_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 On ASAN lanes perTestTimeout is Math.ceil(180_000/2) * 3 = 270s (line 957), which exceeds the hard-coded 4-minute idleTimeout here — so at the batch tail, when only one worker is left and no sibling dots are keeping the idle timer re-armed, a single test legitimately running 240–270s is SIGTERM'd as "stalled" even though it's within its per-test budget. Not a hard failure (the file lands in incomplete and passes on solo retry), just ~4 min of wasted CI and a false "stalled" classification. Consider idleTimeout: … || Math.max(4 * 60_000, perTestTimeout + 30_000) so the invariant idleTimeout > perTestTimeout holds regardless of the ASAN multiplier.

Extended reasoning...

The invariant that should hold, and why it does not on ASAN

The new idleTimeout at scripts/runner.node.mjs:994 defaults to 4 * 60_000 = 240_000 ms. The same batch spawn passes --timeout=${perTestTimeout} (line 983), where perTestTimeout = Math.ceil(testTimeout / 2) * (isAsan ? 3 : 1) (line 957) and testTimeout = 3 * 60_000 (line 85). On an ASAN lane that is Math.ceil(180_000 / 2) * 3 = 270_000 ms. So on ASAN, idleTimeout (240 s) < perTestTimeout (270 s) by 30 s. The idle timer is meant to detect a wedged batch, but on ASAN it can fire while a single test is still legitimately inside its allotted per-test window.

Why silence is expected at the batch tail

The batch runs with --dots (line 984). In --dots mode the coordinator only writes to stderr on TestDone (a dot per completed test) and flushes captured worker output at TestDone/FileDone; FileStart writes nothing (Coordinator.rs::on_frame). armIdleTimer is re-armed on every stdout/stderr chunk (lines 1556/1562), so as long as any worker is finishing tests, dots keep the idle timer alive. But once the batch drains to its last inflight file — every other worker has sent Shutdown and exited — the only source of output is that one worker's next TestDone. If its current test runs longer than 240 s, nothing is written and idleTimer fires.

Step-by-step on an ASAN lane

  1. runParallelBucket spawns bun test --parallel=N --timeout=270000 --dots … with idleTimeout: 240000.
  2. The batch runs down until worker 0 is the only live worker, running the last file's last test — say a 250 s integration-ish case (allowed: 250 s < 270 s per-test timeout).
  3. All other workers have already emitted their final dots and exited. From this point the coordinator writes nothing until worker 0 sends TestDone.
  4. At t + 240 s since the last dot, idleTimer fires → idledOut = true → expire() → subprocess.kill("SIGTERM").
  5. The coordinator's SIGTERM handler runs abort_all(), prints Interrupted while still running: <file>, and exits. spawnSafe returns {ok: false, error: "stalled", …}.
  6. runParallelBucket's !ok recovery parses the interrupt report, puts the file in incomplete, and re-runs it solo — where the per-file timeout applies and the 250 s test passes.

At t + 270 s the worker's own --timeout would have killed the test, printed a (fail) … [timeout] line (a TestDone frame → a chunk → idle timer re-armed), and the batch would have carried on. The idle timer pre-empts that by 30 s.

Why nothing else prevents it

isBucketCandidate (lines 1131–1138) filters by directory allowlist and excludes /stress/i, but does not filter by expected test duration, and ASAN lanes do run runParallelBucket (line 956/966 handle isAsan inside it — there is no ASAN gate on the bucketable path). The BUN_RUNNER_BATCH_IDLE_MS override is not set anywhere in .buildkite/, so the 4-minute default applies.

Impact — why this is a nit, not blocking

No hard failure: the SIGTERM path names the hung file, the file is retried solo, and (since it was within its per-test timeout) it passes. The cost is up to ~4 min of wasted wall-clock plus a misleading "stalled" classification for a healthy test. The trigger is narrow — batch tail on an ASAN lane with a single test in the 240–270 s band — but ASAN is exactly where slow tests cluster. It is also worth noting the non-ASAN perTestTimeout is 90 s, comfortably below 240 s, so this only bites the ASAN multiplier.

Fix

Derive the idle window from the per-test timeout so the invariant holds regardless of the ASAN multiplier:

idleTimeout: parseInt(process.env.BUN_RUNNER_BATCH_IDLE_MS || "", 10) || Math.max(4 * 60_000, perTestTimeout + 30_000),

(or apply the same isAsan ? 3 : 1 multiplier to the 4-minute constant).

Worker crashes are recognized as panics by fatal signal, which Windows
never surfaces: there Bun's abort() is ExitProcess(3), indistinguishable
from process.exit(3), so is_panic_status is documented as POSIX-only
and Windows worker crashes deliberately take the per-file-failure
path. The banner-and-kill contract this test asserts therefore only
exists on POSIX; skip it on Windows rather than assert behavior the
platform can't have.
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.

2 participants