Skip to content

ci: wait for dockerd before launching the test-service coordinator on Linux agents - #34480

Closed
robobun wants to merge 2 commits into
mainfrom
farm/3ef7bd9b/ci-wait-for-docker-daemon
Closed

robobun wants to merge 2 commits into
mainfrom
farm/3ef7bd9b/ci-wait-for-docker-daemon

Conversation

@robobun

@robobun robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

test/js/sql/sql-mysql-query-string-leak.test.ts and test/regression/issue/26030.test.ts went red on :alpine: 3.23 x64 shard 0 in build 74556 (PR #34477, unrelated to its diff):

failed to connect to the docker API at unix:///var/run/docker.sock; ... dial unix /var/run/docker.sock: connect: no such file or directory
error: A functional `docker` is required in CI for some tests.
      at isDockerEnabled (test/harness.ts:1056:17)
error: Failed to start service mysql_plain (via coordinator): Docker is not available. Please ensure Docker is installed and running.

All four in-shard retries hit the same state because the docker daemon on that VM never came up for the life of the job.

Why

scripts/agent.mjs installs the buildkite-agent service with no ordering against docker:

# openrc                      # systemd
depend() {                    After=syslog.target
  need net                    After=network-online.target
  use dns logger
}

so on a cold ephemeral boot the agent can accept a job before dockerd is listening. Grepping the shard logs for that build: on every Alpine shard the runner's --- Docker section (docker ps) prints "failed to connect to /var/run/docker.sock", and the coordinator's one-shot docker version a few seconds later normally wins the race (shards 1-19 and the x64-baseline twin of shard 0 all went on to coordinator: mysql_plain ready). On this one VM it lost, and docker version was still failing 3+ minutes later when the test file ran, so the daemon never came up at all on that boot.

On Debian/Ubuntu the same --- Docker section already shows a healthy CONTAINER ID ... header at runner start, so this is specific to the openrc boot ordering.

Fix

  • scripts/runner.node.mjs: on CI Linux, if the docker CLI is present but docker version can't reach the daemon, poll it for up to 30s before spawning the test-service coordinator. This closes the cold-boot race for the current images at zero cost on hosts where the daemon is already up. If the daemon is still unreachable after 30s the shard is on a VM where dockerd failed to start; the runner annotates and exits 75 (EX_TEMPFAIL) so Buildkite's automatic-retry (limit: 1, Linux test agents are ephemeral acquire-job/disconnect-after-job) re-runs the shard on a fresh VM instead of burning 4x retries of every container-backed test against a dead host. A fleet-wide docker regression still surfaces on the second attempt.
  • .buildkite/ci.mjs: add exit_status: 75 to getRetry()'s automatic list alongside the existing -1/255 infra-retry codes.
  • scripts/agent.mjs: declare after docker / use ... docker in the openrc service and After=docker.service in the systemd unit so newly baked images boot with the correct ordering and the wait loop above is not doing load-bearing work.

Verification

This is CI machinery with no src/ or test changes; the reported test already passes on main HEAD whenever docker is reachable (299/300 recent builds). The fix cannot be demonstrated as fail-before/pass-after locally: without a reachable docker daemon the test is skipped (isDockerEnabled() returns false outside CI), and with one it already passed.

What I did verify:

  • node --check passes on all three modified files.
  • The new branch runs inside the existing isCI && isLinux && docker compose version guard, so non-Linux / dockerless hosts are untouched and a healthy daemon takes the fast path (one extra spawnSync docker version).
  • Sampled five Alpine shard logs from build 74556: all show docker ps failing at runner start and docker becoming ready within the 30s budget, so the poll is sized against real numbers rather than guessed.
  • openrc use/after and systemd After= are ordering-only (not need/Requires=), so images without docker still boot the agent.

no test proof · iteration 0 · build/CI scripts only; test-proof not applicable

On Alpine (openrc) test agents the buildkite-agent service did not declare any
ordering against docker, so on a cold boot the agent accepts a job before
dockerd is listening. Every Alpine shard's `--- Docker` section shows
"failed to connect to /var/run/docker.sock" at runner start; the daemon
normally appears a few seconds later and the coordinator's one-shot
`docker version` happens to win the race. When it loses (or dockerd fails
to come up at all, as on one VM in build 74556), every container-backed test
in the shard fails with "Docker is not available" /
"A functional docker is required in CI" on all retries.

- runner.node.mjs: if docker CLI is present but the daemon is unreachable,
  poll `docker version` for up to 30s before spawning the coordinator.
  Costs nothing on hosts where the daemon is already up; gives openrc a
  bounded window to finish bringing dockerd online.
- agent.mjs: order buildkite-agent after docker in both the openrc and
  systemd unit definitions so future images boot with the correct ordering
  and the wait loop is not doing load-bearing work.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 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: 2a76636b-3796-4e3a-be26-cff8667b09b8

📥 Commits

Reviewing files that changed from the base of the PR and between 6618e7f and 0bcc32f.

📒 Files selected for processing (3)
  • .buildkite/ci.mjs
  • scripts/agent.mjs
  • scripts/runner.node.mjs

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

@robobun

robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:03 AM PT - Jul 17th, 2026

❌ @robobun, your commit 0bcc32f has 7 failures in Build #74580 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34480

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

bun-34480 --bun

@robobun

robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: CI-infrastructure fix for an Alpine-only boot race between the buildkite-agent service and dockerd. No src/ changes; the reported test already passes wherever docker is reachable, so this cannot be demonstrated as fail-before/pass-after locally. See the PR body for the log evidence from build 74556.

Self-review surfaced one concern which is now addressed in 0bcc32f: when the 30s poll expires with the daemon still unreachable, exit EX_TEMPFAIL (75) so Buildkite auto-retries the shard on a fresh ephemeral VM instead of running every container-backed test 4x against a dead host. exit_status: 75 added to getRetry() alongside the existing -1/255 infra-retry codes.

CI on 0bcc32f (build 74580): all 60 Alpine test-bun shards passed, including sql-mysql-query-string-leak.test.ts and 26030.test.ts on x64 shard 0. Remaining red is unrelated to this scripts-only diff: the JSC worker-termination assertion on debian asan (tracked separately), a darwin 14 x64 shard that hit the 30-minute job timeout (5 bake tests timed out together on that one box; the runner change here is inside isCI && isLinux), and a handful of known flakes. Ready for a maintainer.

@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Arrived at the same fix from a separate report of test/regression/issue/26030.test.ts on the same build; this PR already covers it.

One data point from that shard's log worth folding in: on ip-172-31-41-171-1 the daemon was still unreachable at t+276s (attempt 4 of 26030), so on that particular VM the 30s poll would have fallen through to the same test failures. I pushed farm/a75ef405/ci-retry-dead-docker-daemon which, on top of the same poll + service ordering, gates the poll on the shard actually carrying container-backed tests, exits 75 (EX_TEMPFAIL) when the daemon never comes up, and adds exit_status: 75 to getRetry() so Buildkite reschedules once on a fresh agent instead of reporting the docker tests as new failures. Feel free to take whatever is useful.

@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-infrastructure-only fix for the Alpine cold-boot race between buildkite-agent and dockerd.

What was reviewed:

  • The 30s poll loop sits inside the existing isCI && isLinux && docker compose version guard, so non-Linux/dockerless hosts and hosts with a healthy daemon are unaffected (one extra spawnSync on the fast path); setTimeoutPromise is already imported.
  • openrc use ... docker / after docker and systemd After=docker.service are ordering-only (not need/Requires=), so images without docker still boot the agent.
  • Checked that docker compose version (the outer guard) does not need the daemon while docker version does, so the new inner check is the right probe.
Extended reasoning...

Overview

Two CI-script changes to fix a boot-ordering race observed on Alpine ephemeral agents where the buildkite-agent service accepted a job before dockerd was listening:

  • scripts/agent.mjs: adds use ... docker + after docker to the openrc init script's depend() and After=docker.service to the systemd unit, so newly-baked images order the agent after docker.
  • scripts/runner.node.mjs: inside the existing isCI && isLinux && spawnSync('docker', ['compose', 'version']) block, polls docker version for up to 30s before spawning the docker-service coordinator, falling through (not blocking) if the daemon never comes up.

No src/, test/, or user-facing changes.

Security risks

None. This is internal CI orchestration; no new inputs are parsed, no secrets touched, no network endpoints added. The added spawnSync calls invoke docker version with fixed argv and stdio: 'ignore'.

Level of scrutiny

Low. This is non-production CI plumbing with a clear, bounded blast radius: the runner poll only executes on CI Linux hosts that already pass the docker compose version gate, adds at most 30s of wait when the daemon is genuinely down (after which behaviour is identical to today's), and is a no-op on healthy hosts. The service-unit changes use soft ordering directives (use/after in openrc, After= without Requires= in systemd), so agents on images without docker are unaffected.

Other factors

  • setTimeoutPromise is already imported from node:timers/promises in this file and used elsewhere, so no new import needed.
  • The outer guard's docker compose version is a client-side plugin check that succeeds without a running daemon, whereas docker version contacts the daemon — so the new inner probe correctly distinguishes "CLI present but daemon not ready" from "docker missing entirely".
  • The 10-line explanatory comment matches the style of the existing coordinator comment block immediately above it in the same file.
  • The PR body documents log evidence from build 74556 showing the race window is well within the 30s budget, and correctly notes this cannot be demonstrated fail-before/pass-after locally (the affected tests skip when docker is unreachable outside CI).
  • The bug-hunting system found no issues.

When the poll budget expires and the daemon is still unreachable, the shard is
on a VM where dockerd failed to start (the motivating build 74556 alpine VM was
still failing 3+ minutes in). Running on would exercise 4x retries of every
container-backed test against a dead host. The Linux test agents are ephemeral
(acquire-job + disconnect-after-job), so exit EX_TEMPFAIL and let Buildkite's
existing automatic-retry hook (limit: 1) re-run the shard on a fresh VM. A
fleet-wide docker regression still surfaces on the second attempt.
@robobun
robobun force-pushed the farm/3ef7bd9b/ci-wait-for-docker-daemon branch from 05c82c6 to 0bcc32f Compare July 17, 2026 11:39

@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 — small, well-scoped CI infra fix.

What was reviewed:

  • runner.node.mjs: new poll sits inside the existing isCI && isLinux && docker compose version guard; docker compose version doesn't need the daemon so the guard still passes when dockerd is down. getHostname/reportAnnotationToBuildKite/setTimeoutPromise are already imported, and the annotate call is synchronous so process.exit(75) won't cut it off.
  • agent.mjs: openrc use/after docker and systemd After=docker.service are soft ordering — no-ops on hosts without docker.
  • ci.mjs: getRetry() is shared across all step types, so exit 75 anywhere now auto-retries once; bounded by limit: 1 and 75 is conventional EX_TEMPFAIL, so harmless.
Extended reasoning...

Overview

CI-only change across three script files: (1) .buildkite/ci.mjs adds { exit_status: 75, limit: 1 } to the shared getRetry() automatic-retry list; (2) scripts/agent.mjs adds use/after docker to the openrc service and After=docker.service to the systemd unit so the buildkite-agent boots after dockerd on newly baked images; (3) scripts/runner.node.mjs polls docker version for up to 30s before spawning the docker test-service coordinator, and if the daemon never comes up, annotates the build and exits 75 so Buildkite reschedules once on a fresh ephemeral VM.

Security risks

None. No user-controlled input is parsed; the only new subprocess calls are spawnSync("docker", ["version"]) with a fixed argv. Service-unit edits are ordering-only declarations.

Level of scrutiny

Low-to-medium. This is CI plumbing with no src/ or test changes, and every branch is guarded (isCI && isLinux plus a successful docker compose version). The failure mode it addresses (agent accepting a job before dockerd listens on Alpine) is well-evidenced in the PR body, and the fallback (process.exit(75) → one Buildkite retry) is bounded. Worst-case regressions are a 30s wait on a broken host or one spurious retry of a step that happens to exit 75.

Other factors

  • Checked that reportAnnotationToBuildKite is synchronous (nodeSpawnSync) and early-returns when not on Buildkite, so process.exit(75) immediately after is safe and non-Buildkite CI just gets a nonzero exit with a clear message.
  • After=docker.service without Wants=/Requires= and openrc use docker are soft deps, so images without docker (e.g. the mac/windows paths, or a future dockerless linux image) are unaffected.
  • getRetry() is used by build/link/image steps too, not just tests; adding exit 75 there is fine since it's capped at one retry and EX_TEMPFAIL is the conventional meaning.
  • The PR body's "falls through" wording predates the exit-75 addition folded in from the follow-up branch, but the code is consistent with the final intent and the timeline explains the evolution.
  • No CODEOWNERS on these paths.

@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 — small, self-contained CI infrastructure fix.

What was reviewed:

  • The 30s poll sits inside the existing isCI && isLinux && docker compose version guard, so non-Linux/dockerless hosts are untouched; docker compose version is client-only so the outer guard passing while the daemon is down is the exact case being handled.
  • reportAnnotationToBuildKite is synchronous (spawnSync-based), so the annotation posts before process.exit(75); getHostname/setTimeoutPromise are already imported.
  • openrc use/after and systemd After= are ordering-only (no need/Requires=), so agents on images without docker still start.
  • getRetry() is applied to getTestBunStep, so exit 75 triggers the new limit: 1 auto-retry on a fresh ephemeral VM.
Extended reasoning...

Overview

CI-infrastructure only: three script edits with no src/ or test changes. .buildkite/ci.mjs adds { exit_status: 75, limit: 1 } to the existing automatic-retry list. scripts/agent.mjs adds ordering-only dependencies on docker to the openrc and systemd unit definitions. scripts/runner.node.mjs adds a bounded 30s poll for the docker daemon before spawning the test-service coordinator, and if the daemon never comes up, annotates and exits EX_TEMPFAIL (75) so Buildkite reschedules the shard on a fresh ephemeral VM.

Security risks

None. No user-controllable input is parsed; the only new external calls are spawnSync("docker", ["version"]) with fixed argv and a Buildkite annotation via the existing helper. The service-unit additions are declarative ordering hints.

Level of scrutiny

Low-to-medium. This is build/CI plumbing, not runtime code, and the changes are additive inside existing guards. The failure mode of a mistake here is a mis-scheduled CI job, not a shipped bug. node --check passes on all three files and I traced the imports (getHostname, reportAnnotationToBuildKite, setTimeoutPromise) to confirm they're already present.

Other factors

  • Confirmed reportAnnotationToBuildKite is synchronous, so it completes before process.exit(75); no lost annotation.
  • Confirmed getRetry() is used by getTestBunStep, so the new exit_status: 75 entry actually applies to the code path that emits it. Other steps sharing getRetry() are unlikely to exit 75 accidentally, and limit: 1 caps any surprise.
  • openrc use docker/after docker and systemd After=docker.service are soft ordering — agents on images without docker still boot, matching the PR's stated intent.
  • The poll loop is placed before the coordinator spawn and before any test runs, so process.exit(75) at that point leaves nothing to clean up.
  • No CODEOWNERS cover these paths.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #39243. Logs from builds 97891, 98647, 98707 and 98746 show the daemon on these alpine agents does come up, but only 5 to 7 minutes into the job, so a 30s poll in the runner would not have covered any of them, and getRetry() has since been rewritten to cover agent loss only. #39243 instead waits for the daemon inside the test-service coordinator (overlapping the non-container tests), has isDockerEnabled() trust the coordinator, and carries over this PR's openrc ordering change for the next image bake.

@robobun robobun closed this Aug 15, 2026
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