-
Notifications
You must be signed in to change notification settings - Fork 5.1k
ci: recover test-bun from Buildkite artifact-download failures #33117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8cff88b
ci: retry Buildkite artifact download on timeout
robobun 9f231cc
ci: document why bun-profile is the preferred artifact
robobun 90f0df7
ci: auto-retry test-bun when the build artifact download fails
robobun 0f8b25a
ci: document the infra outcome in getExitCode JSDoc
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| // Downloads a build artifact from Buildkite with retries. Lives in its own | ||
| // module (instead of inline in runner.node.mjs) so the retry policy can be | ||
| // unit-tested without booting the whole test runner. | ||
|
|
||
| import { mkdirSync, readdirSync, rmSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| /** @typedef {(options: { command: string, args: string[], timeout: number }) => Promise<{ error?: string }>} SpawnFn */ | ||
|
|
||
| /** | ||
| * Download the bun build artifact for `target` into `releasePath` and return | ||
| * the path to the downloaded `bun*.zip`. | ||
| * | ||
| * Retries transient failures: a slow download killed at the per-attempt | ||
| * timeout, or the artifact not being uploaded yet (the download succeeds but | ||
| * no zip is present). It throws only after exhausting every attempt, so a | ||
| * single slow download no longer fails the job on the first try. | ||
| * | ||
| * Each attempt starts from an empty `releasePath`: a download killed at the | ||
| * timeout can leave a truncated zip behind, and a successful retry must never | ||
| * pick up that partial file (or a stale binary) instead of the real artifact. | ||
| * | ||
| * @param {object} params | ||
| * @param {string} params.target Buildkite step name to download from. | ||
| * @param {string} [params.buildId] Buildkite build id (defaults to the current build). | ||
| * @param {string} params.releasePath Directory to download into. | ||
| * @param {SpawnFn} params.spawn Spawns a command and resolves with `{ error }`. | ||
| * @param {number} [params.attempts] Max download attempts. | ||
| * @param {number} [params.timeout] Per-attempt timeout, in milliseconds. | ||
| * @param {(ms: number) => Promise<void>} [params.sleep] Backoff between attempts. | ||
| * @returns {Promise<string>} | ||
| */ | ||
| export async function downloadArtifactZip({ | ||
| target, | ||
| buildId, | ||
| releasePath, | ||
| spawn, | ||
| attempts = 10, | ||
| timeout = 120_000, | ||
| sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), | ||
| }) { | ||
| let lastError; | ||
| for (let i = 0; i < attempts; i++) { | ||
| rmSync(releasePath, { recursive: true, force: true }); | ||
| mkdirSync(releasePath, { recursive: true }); | ||
|
|
||
| const args = ["artifact", "download", "**", releasePath, "--step", target]; | ||
| if (buildId) { | ||
| args.push("--build", buildId); | ||
| } | ||
|
|
||
| const { error } = await spawn({ command: "buildkite-agent", args, timeout }); | ||
| if (error) { | ||
| lastError = error; | ||
| console.warn(`buildkite-agent artifact download failed for step '${target}' (${error}), retrying...`); | ||
| } else { | ||
| // When both are present, prefer bun-profile: it keeps its symbol table | ||
| // (the release bun is stripped), so CI crash backtraces symbolicate. | ||
| const zipPath = readdirSync(releasePath, { recursive: true, encoding: "utf-8" }) | ||
| .filter(filename => /^bun.*\.zip$/i.test(filename)) | ||
| .map(filename => join(releasePath, filename)) | ||
| .sort((a, b) => b.includes("profile") - a.includes("profile")) | ||
| .at(0); | ||
|
|
||
| if (zipPath) { | ||
| return zipPath; | ||
| } | ||
|
|
||
| console.warn(`Waiting for ${target}.zip to be available...`); | ||
| } | ||
|
|
||
| if (i < attempts - 1) { | ||
| await sleep((i + 1) * 1000); | ||
| } | ||
| } | ||
|
|
||
| // Tag the failure so the runner can exit with an infra status Buildkite | ||
| // auto-retries (getExitCode/main in runner.node.mjs, getRetry in ci.mjs) | ||
| // instead of a plain exit 1 that sits red until a human clicks Retry. | ||
| const error = new Error( | ||
| `Could not download ${target}.zip from Buildkite after ${attempts} attempts: ${releasePath}` + | ||
| (lastError ? ` (last download error: ${lastError})` : ""), | ||
| ); | ||
| error.code = "ARTIFACT_DOWNLOAD_FAILED"; | ||
| throw error; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| // Regression tests for the Buildkite artifact download retry policy | ||
| // (scripts/download-artifact.mjs). The runner used to throw on the first | ||
| // download timeout, failing the whole test job on a single slow download; the | ||
| // retry loop only retried the "artifact not uploaded yet" case. See | ||
| // https://github.com/oven-sh/bun/issues/33116 | ||
|
|
||
| import { describe, expect, test } from "bun:test"; | ||
| import { tempDir } from "harness"; | ||
| import { existsSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import { downloadArtifactZip } from "../../scripts/download-artifact.mjs"; | ||
|
|
||
| type Behavior = { error?: string; writeZip?: string }; | ||
|
|
||
| /** | ||
| * Fake `buildkite-agent` spawn. Each call consumes the next behavior (the last | ||
| * one repeats): it optionally writes a zip into the download directory (which | ||
| * the real agent derives from args[3]) and resolves with `{ error }`. | ||
| */ | ||
| function fakeSpawn(behaviors: Behavior[]) { | ||
| const calls: { args: string[]; timeout: number }[] = []; | ||
| const spawn = async ({ args, timeout }: { command: string; args: string[]; timeout: number }) => { | ||
| const behavior = behaviors[Math.min(calls.length, behaviors.length - 1)]; | ||
| calls.push({ args, timeout }); | ||
| if (behavior.writeZip) { | ||
| const releasePath = args[3]; | ||
| writeFileSync(join(releasePath, behavior.writeZip), "PK\u0003\u0004fake"); | ||
| } | ||
| return { error: behavior.error }; | ||
| }; | ||
| return { spawn, calls }; | ||
| } | ||
|
|
||
| const noSleep = async () => {}; | ||
|
|
||
| describe("downloadArtifactZip", () => { | ||
| test("retries a timed-out download and recovers", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| const { spawn, calls } = fakeSpawn([{ error: "timeout" }, { writeZip: "bun-linux-x64.zip" }]); | ||
|
|
||
| const zipPath = await downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| releasePath, | ||
| spawn, | ||
| sleep: noSleep, | ||
| }); | ||
|
|
||
| expect(zipPath).toBe(join(releasePath, "bun-linux-x64.zip")); | ||
| expect(calls.length).toBe(2); | ||
| }); | ||
|
|
||
| test("retries while the artifact is still uploading", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| // First download succeeds but the zip is not present yet. | ||
| const { spawn, calls } = fakeSpawn([{}, { writeZip: "bun-linux-x64.zip" }]); | ||
|
|
||
| const zipPath = await downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| releasePath, | ||
| spawn, | ||
| sleep: noSleep, | ||
| }); | ||
|
|
||
| expect(zipPath).toBe(join(releasePath, "bun-linux-x64.zip")); | ||
| expect(calls.length).toBe(2); | ||
| }); | ||
|
|
||
| test("prefers the profile build when several zips are present", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| const spawn = async ({ args }: { command: string; args: string[]; timeout: number }) => { | ||
| const downloadPath = args[3]; | ||
| writeFileSync(join(downloadPath, "bun-linux-x64.zip"), "PK\u0003\u0004fake"); | ||
| writeFileSync(join(downloadPath, "bun-profile.zip"), "PK\u0003\u0004fake"); | ||
| return {}; | ||
| }; | ||
|
|
||
| const zipPath = await downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| releasePath, | ||
| spawn, | ||
| sleep: noSleep, | ||
| }); | ||
|
|
||
| expect(zipPath).toBe(join(releasePath, "bun-profile.zip")); | ||
| }); | ||
|
|
||
| test("throws after exhausting every attempt and surfaces the last error", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| const { spawn, calls } = fakeSpawn([{ error: "timeout" }]); | ||
|
|
||
| const error = await downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| releasePath, | ||
| spawn, | ||
| attempts: 3, | ||
| sleep: noSleep, | ||
| }).catch(e => e); | ||
|
|
||
| expect(error).toBeInstanceOf(Error); | ||
| expect(error.message).toMatch(/after 3 attempts.*last download error: timeout/s); | ||
| // Tagged so the runner exits an infra status Buildkite auto-retries. | ||
| expect(error.code).toBe("ARTIFACT_DOWNLOAD_FAILED"); | ||
| expect(calls.length).toBe(3); | ||
| }); | ||
|
|
||
| test("never reuses a partial zip left behind by a killed download", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| // Attempt 1: the killed download leaves a truncated zip, then reports the | ||
| // timeout. Attempt 2: the download succeeds but produces no zip. The fix | ||
| // must start attempt 2 from a clean directory, so the stale zip is gone | ||
| // and we throw instead of handing back the partial artifact. | ||
| const { spawn, calls } = fakeSpawn([{ writeZip: "bun-stale.zip", error: "timeout" }, {}]); | ||
|
|
||
| await expect( | ||
| downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| releasePath, | ||
| spawn, | ||
| attempts: 2, | ||
| sleep: noSleep, | ||
| }), | ||
| ).rejects.toThrow(/after 2 attempts/); | ||
| expect(calls.length).toBe(2); | ||
| expect(existsSync(join(releasePath, "bun-stale.zip"))).toBe(false); | ||
| }); | ||
|
|
||
| test("passes the build id through to buildkite-agent when provided", async () => { | ||
| using dir = tempDir("dl-artifact", {}); | ||
| const releasePath = join(String(dir), "release"); | ||
| const { spawn, calls } = fakeSpawn([{ writeZip: "bun-linux-x64.zip" }]); | ||
|
|
||
| await downloadArtifactZip({ | ||
| target: "darwin-aarch64-build-bun", | ||
| buildId: "01234567-89ab-cdef-0123-456789abcdef", | ||
| releasePath, | ||
| spawn, | ||
| sleep: noSleep, | ||
| }); | ||
|
|
||
| expect(calls[0].args).toContain("--build"); | ||
| expect(calls[0].args).toContain("01234567-89ab-cdef-0123-456789abcdef"); | ||
| expect(calls[0].args).toEqual([ | ||
| "artifact", | ||
| "download", | ||
| "**", | ||
| releasePath, | ||
| "--step", | ||
| "darwin-aarch64-build-bun", | ||
| "--build", | ||
| "01234567-89ab-cdef-0123-456789abcdef", | ||
| ]); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.