Skip to content

ci(build): keep waiting for build-cpp while a retry is queued instead of bailing on a stale errored outcome - #36339

Open
robobun wants to merge 6 commits into
mainfrom
farm/de87bcd2/ci-wait-for-sibling-retry
Open

ci(build): keep waiting for build-cpp while a retry is queued instead of bailing on a stale errored outcome#36339
robobun wants to merge 6 commits into
mainfrom
farm/de87bcd2/ci-wait-for-sibling-retry

Conversation

@robobun

@robobun robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What broke

Build-bun steps across ~40 builds (84714 onward, including main #84716) failed with

Waiting for windows-x64-build-cpp to finish...
  windows-x64-build-cpp outcome: errored [0s]
error: Sibling step windows-x64-build-cpp errored — nothing to link

even though the sibling build-cpp step later passed on retry. First seen in build 84838 (reported from #31737).

Cause

rust-and-link (introduced in #34782) runs build-bun in parallel with build-cpp and polls buildkite-agent step get outcome --step <target>-build-cpp before downloading the C++ archive. Under last night's load spike the c8g.4xlarge queue backed up past Buildkite's 60-minute scheduled-job expiry, so many build-cpp jobs expired without ever being assigned an agent and were then retried.

step get outcome reports the result of the last completed job for a step. With a prior attempt expired and a retry still queued or running, it returns errored, and waitForStepOutcome() treated that as terminal:

# build 84838, windows-x64-build-bun attempt 2 (07:01:40 - 07:04:25)
# windows-x64-build-cpp attempt 1 expired 06:06:22; attempt 2 was queued
# (runnable 06:06:21, not yet started) when this poll ran.
windows-x64-build-cpp outcome: errored [0s]

So retrying a build-cpp job did nothing for a concurrently running build-bun, and a retried build-bun would bail immediately on its first poll.

Fix

Poll state alongside outcome. step get state returns the step-level state (ready/running/failing/finished/..., a different vocabulary from the REST API's job states; see the agent CLI docs), so a failed outcome is only acted on once state is in the terminal set {finished, canceled, ignored}. Any other state, including values we haven't enumerated, keeps the poll going; an empty/unavailable state falls back to the original outcome-only behaviour. Two consecutive terminal reads are required before giving up, covering the ~1 s window between an attempt finishing and its retry job appearing.

The genuine-failure fast path is preserved: a build-cpp that hard-fails with no retry reports state=finished on two consecutive polls and build-bun still exits within ~3 s of that.

Verification

Observed in this PR's own CI (build 85043, linux-aarch64-build-bun):

Waiting for linux-aarch64-build-cpp to finish...
  linux-aarch64-build-cpp outcome=(none) / state=running [0s]
  linux-aarch64-build-cpp outcome=passed / state=finished [6s]

test/internal/ci-sibling-wait.test.ts drives waitForStepOutcome() through an injected step get seam (no subprocess, runs on all hosts) and covers: pass, errored-while-retry-queued (state=ready), state=failing, an unrecognised state, the finished->ready transition gap, terminal hard-fail, state unavailable, and a transient agent error.

The underlying c8g.4xlarge backlog is a capacity issue (525 build-cpp jobs expired over ~3 h at a median 92 min queue wait); that is not addressed here, but with this change the retry path works through it instead of cascading into build-bun failures.


[stamp-90s] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/ci-sibling-wait.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/ci-sibling-wait.test.ts
bun test v1.4.0 (3897ad168)

test/internal/ci-sibling-wait.test.ts:
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=(none) / state=running [0s]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=ready [0s]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=failing [0s]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=limiting [0s]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=finished [0s]
  linux-x64-build-cpp outcome=passed / state=finished [0s]
(pass) waitForStepOutcome > resolves once the sibling passes [67.51ms]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=hard_failed / state=finished [0s]
  linux-x64-build-cpp outcome=errored / state=ready [0s]
  linux-x64-build-cpp outcome=errored / state=running [0s]
  linux-x64-build-cpp outcome=errored / state=running [0s]
  linux-x64-build-cpp outcome=passed / state=finished [0s]
  linux-x64-build-cpp outcome=passed / state=finished [0s]
  linux-x64-build-cpp outcome=passed / state=finished [0s]
(pass) waitForStepOutcome > throws once the sibling is terminally failed with no retry in flight [12.79ms]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=(none) [0s]
  linux-x64-build-cpp outcome=passed / state=finished [0s]
(pass) waitForStepOutcome > falls back to outcome when state is unavailable [17.21ms]
Waiting for linux-x64-build-cpp to finish...
  linux-x64-build-cpp outcome=errored / state=<502 Bad Gateway> [0s]
(pass) waitForStepOutcome > keeps polling through an unknown state value [50.42ms]
Waiting for linux-x64-build-cpp to finish...
  buildkite-agent step get outcome failed: transient 502
(pass) waitForStepOutcome > tolerates t
... (truncated)
Exit: 0
diff hotspot
scripts/build/ci.ts                   | 108 +++++++++++++++++++++---------
 test/internal/ci-sibling-wait.test.ts | 119 ++++++++++++++++++++++++++++++++++
 2 files changed, 197 insertions(+), 30 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
scripts/build/ci.ts                        4     12      0
test/internal/ci-sibling-wait.test.ts      2      7      0

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The Buildkite step waiter now reads both outcome and state, handles retries and transient agent errors, exposes injectable polling controls, and enforces terminal failure checks. New tests cover passing, retrying, non-terminal, unavailable-state, failure, and transient-error scenarios.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly matches the main CI behavior change and describes the retry-aware waiting fix.
Description check ✅ Passed The description includes the problem, cause, fix, and verification, covering the required intent despite different headings.

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

Comment thread scripts/build/ci.ts Outdated
Comment thread test/internal/ci-sibling-wait.test.ts Outdated
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:18 AM PT - Jul 29th, 2026

@robobun, your commit 3897ad16836c6d7a4cc3c622df00b4042388ae75 passed in Build #85073! 🎉


🧪   To try this PR locally:

bunx bun-pr 36339

That installs a local version of the PR into your bun-36339 executable, so you can run:

bun-36339 --bun

@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.

LGTM — both earlier nits are addressed (get() now throws immediately on spawn failure, and the fake-agent stub uses $BUN_EXE instead of system node).

What was reviewed:

  • The state + two-consecutive-terminal-reads gate in waitForStepOutcome; terminalReads is reset on any live/non-failed poll and the passed fast path is unchanged.
  • Fallback when step get state is unavailable — empty stateVal isn't in liveState, so behaviour matches the pre-change outcome-only logic.
  • Test hermeticity: withFakeAgent restores PATH/AGENT_DIR/BUN_EXE in finally, uses tempDir, and the stub advances its cursor on the state read so both reads in a poll see the same script entry.
Extended reasoning...

Overview

Changes waitForStepOutcome() in scripts/build/ci.ts — the poll that lets rust-and-link wait for its sibling build-cpp step — to also consult buildkite-agent step get state, so a stale errored outcome from an earlier expired attempt doesn't cause an immediate bail while a retry is queued/running. Requires two consecutive terminal reads before throwing to cover the ~1 s gap between an attempt finishing and its retry job appearing. Adds test/internal/ci-sibling-wait.test.ts with a fake buildkite-agent on PATH covering pass, retry-in-flight, transition gap, hard-fail, and state-unavailable.

Security risks

None. This is Buildkite build orchestration tooling in scripts/build/; it does not touch runtime code, user input, auth, or anything that ships in the binary.

Level of scrutiny

Low-to-medium. The blast radius is CI build steps: worst case is build-bun waits longer than needed or bails when it shouldn't, both of which are visible and recoverable at the CI level. The genuine-failure fast path is preserved (~6 s: two polls at the default 3 s interval). The liveState set is a defined allowlist, so unknown Buildkite state values fall through to the original outcome-only behaviour rather than blocking forever.

Other factors

Two prior review rounds each raised one nit and both were fixed in follow-up commits: (1) the spawn-error fast-fail in get() was restored to match run()/runAsync(), and (2) the test stub now execs $BUN_EXE -e (passed via env from bunExe()) instead of relying on system node. The test file follows the existing test/internal/ pattern of importing build-script code directly, mutates process env only inside a try/finally, and is skipIf(isWindows) since the stub is a bash script (the polled step never runs on Windows-hosted agents anyway). No outstanding reviewer comments remain.

…gate; inject the step-get seam so the test is pure

@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: 2

🤖 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/build/ci.ts`:
- Around line 610-614: Update the BuildError message in agentStepGet when
spawnSync returns r.error to include both the stepKey and attr being requested,
while preserving the existing error cause.
- Around line 665-686: Handle a failed state read in the sibling-step polling
flow by treating state.ok === false as non-terminal: reset terminalReads and
continue polling rather than converting it to an empty state and entering
outcome-only termination logic. Update the logic around get(stepKey, "state"),
stateVal, and stateIsTerminal while preserving the existing behavior for
successful reads with genuinely unavailable or terminal states.
🪄 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: 24ee2d76-bd51-41dd-8516-0358c14176d1

📥 Commits

Reviewing files that changed from the base of the PR and between 59242d6 and fd8b149.

📒 Files selected for processing (2)
  • scripts/build/ci.ts
  • test/internal/ci-sibling-wait.test.ts

Comment thread scripts/build/ci.ts
Comment thread scripts/build/ci.ts

@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.

LGTM — CI build-script fix with a clean injected test seam; all prior review threads addressed.

What was reviewed:

  • The retry-aware poll logic: unknown/non-terminal state values keep waiting, two-consecutive-terminal-reads gate covers the retry-appearance gap, and the 60 min deadline still bounds it.
  • Failed state read is now non-terminal and surfaced in the log; spawn-error fast-fail restored with stepKey/attr in the message.
  • Test seam is a pure injected get (no subprocess/bash stub anymore), pollMs: 0, and the index-advance logic in the run() helper matches the outcome-then-state read order.
Extended reasoning...

Overview

This PR modifies waitForStepOutcome() in scripts/build/ci.ts — the Buildkite poll loop that rust-and-link build-bun steps use to wait for the parallel build-cpp step before downloading its artifacts. The old loop treated step get outcome == errored as terminal; under queue backlog, an expired-then-retried build-cpp job reports errored while its retry is still queued, cascading ~40 build-bun failures. The fix reads step get state alongside outcome and only gives up when the step-level state is terminal (finished/canceled/ignored) on two consecutive polls. A new test/internal/ci-sibling-wait.test.ts drives the function through an injected get seam covering pass, retry-queued, failing, unknown state, the finished→ready transition gap, terminal hard-fail, empty state (outcome-only fallback), failed state read, and transient outcome-read error.

Security risks

None. This is internal CI build tooling that only runs inside Buildkite jobs and only shells out to buildkite-agent step get (an existing call, now factored into a helper). No user-facing surface, no untrusted input.

Level of scrutiny

Low-to-moderate. The change is confined to a build script (not shipped runtime code); the failure mode of a bug here is a CI hang bounded by the pre-existing 60-minute deadline or a spurious build-bun failure — both recoverable and visible. The logic change is strictly more permissive than before (waits longer instead of bailing early), the genuine-failure fast path is preserved (~3 s to throw on two consecutive state=finished reads), and the PR description shows it working in a real build (85043).

Other factors

All four prior review threads are resolved: my note on the lost spawn-error errno (restored in a38ab92, message now includes stepKey/attr per 3897ad1), my note on the test's system-node dependency (moot — fd8b149 replaced the bash stub with a pure injected seam so no subprocess is spawned at all), and coderabbit's two notes on the spawn-error message and failed-state-read handling (both in 3897ad1, with a covering test). The test file lives in test/internal/ alongside existing tests that import from scripts/build/, uses pollMs: 0 so there is no sleep, and the run() helper's index-advance matches the outcome-then-conditionally-state read order in the loop. No outstanding human reviewer comments.

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 3897ad1 (build 85073): all build-cpp/build-bun steps pass with the new poll (logs show outcome=passed / state=finished as expected), and test/internal/ci-sibling-wait.test.ts is green on every lane. The only failures are unrelated flakes (no-orphans Ctrl-Z bridging, proxy-stress-protocol ECONNRESET, node-net-server unix socket, bun-security-scanner-matrix), none of which touch scripts/ and all of which are tagged flaky by the annotation parser. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant