Files
2026-08-27 21:09:14 +00:00

1067 lines
42 KiB
TypeScript

/**
* CI integration: collapsible log groups, environment dump, Buildkite
* annotations on build failure.
*
* Thin layer over `scripts/utils.mjs` — the same helpers the CMake build
* uses. We import rather than reimplement so CI logs look identical and
* annotation regex stays in one place.
*/
import { spawn as nodeSpawn, spawnSync } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { basename, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateOrderFile } from "../orderfile/generate.ts";
// @ts-ignore — utils.mjs has JSDoc types but no .d.ts
import * as utils from "../utils.mjs";
import { bunExeName, shouldStrip, type BunOutput } from "./bun.ts";
import type { Config } from "./config.ts";
import { webkitTestFFIPath } from "./deps/webkit.ts";
import { BuildError } from "./error.ts";
import { crossFeaturesJson } from "./features-json.ts";
import { orderFilePath, usesOrderFile } from "./flags.ts";
/** True if running under any CI (env: CI, BUILDKITE, or GITHUB_ACTIONS). */
export const isCI: boolean = utils.isCI;
/** True if running under Buildkite specifically. */
export const isBuildkite: boolean = utils.isBuildkite;
/** True if running under GitHub Actions specifically. */
export const isGithubAction: boolean = utils.isGithubAction;
/**
* Print machine/environment/repository info in collapsible groups.
* Call at the top of a CI run so you can diagnose without SSH access.
*/
export const printEnvironment: () => void = utils.printEnvironment;
/**
* Start a collapsible log group. Buildkite: `--- Title`. GitHub: `::group::`.
* If `fn` is given, runs it and closes the group (handles async).
*/
export const startGroup: (title: string, fn?: () => unknown) => unknown = utils.startGroup;
/** Close the most recent group opened with `startGroup`. */
export const endGroup: () => void = utils.endGroup;
interface SpawnAnnotatedOptions {
/** Working directory for the subprocess. */
cwd?: string;
/** Label for duration printing (defaults to basename of command). */
label?: string;
/** Environment variables for the subprocess. */
env?: NodeJS.ProcessEnv;
}
/**
* Spawn a subprocess with CI output handling. Only call this in CI —
* locally use plain spawnSync for zero-overhead no-ops.
*
* Tees stdout/stderr to the terminal AND a buffer. On non-zero exit,
* parses the buffer for compiler errors (rustc/clang/cmake) and posts each
* as a Buildkite annotation. If nothing parseable is found, posts a generic
* "build failed" annotation with the full output. Prints duration at end.
*
* Exits the process with the subprocess's exit code on failure.
* Returns only on success.
*/
export async function spawnWithAnnotations(
command: string,
args: string[],
opts: SpawnAnnotatedOptions = {},
): Promise<void> {
const label = opts.label ?? command;
const child = nodeSpawn(command, args, {
stdio: "pipe",
cwd: opts.cwd,
env: opts.env,
});
// Kill child on parent signals so ninja doesn't linger.
let killedManually = false;
const onKill = () => {
if (!child.killed) {
killedManually = true;
child.kill();
}
};
if (process.platform !== "win32") {
process.once("beforeExit", onKill);
process.once("SIGINT", onKill);
process.once("SIGTERM", onKill);
}
const clearOnKill = () => {
process.off("beforeExit", onKill);
process.off("SIGINT", onKill);
process.off("SIGTERM", onKill);
};
const start = Date.now();
let buffer = "";
// Tee: write to terminal live AND buffer for later annotation parsing.
const stdout = new Promise<void>(resolve => {
child.stdout!.on("end", resolve);
child.stdout!.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
process.stdout.write(chunk);
});
});
const stderr = new Promise<void>(resolve => {
child.stderr!.on("end", resolve);
child.stderr!.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
process.stderr.write(chunk);
});
});
const { exitCode, signalCode, error } = await new Promise<{
exitCode: number | null;
signalCode: NodeJS.Signals | null;
error?: Error;
}>(resolve => {
child.on("error", error => {
clearOnKill();
resolve({ exitCode: null, signalCode: null, error });
});
child.on("exit", (exitCode, signalCode) => {
clearOnKill();
resolve({ exitCode, signalCode });
});
});
await Promise.all([stdout, stderr]);
const elapsed = Date.now() - start;
const elapsedStr =
elapsed > 60000 ? `${(elapsed / 60000).toFixed(2)} minutes` : `${(elapsed / 1000).toFixed(2)} seconds`;
console.log(`${label} took ${elapsedStr}`);
if (error) {
console.error(`Failed to spawn ${command}: ${error.message}`);
process.exit(127);
}
if (exitCode === 0) return;
// ─── Failure: report annotations to Buildkite ───
if (isBuildkite) {
let annotated = false;
try {
// In piped mode, ninja prints ALL command output including successful
// jobs — so the buffer contains dep cmake deprecation warnings from
// vendored CMakeLists.txt we don't control. Keep dep errors (broken
// compiler, bad flags) since those are actionable; drop dep warnings.
const annotatable = buffer
.split("\n")
.filter(line => !/^\[[\w-]+\]\s+CMake (Deprecation )?Warning/i.test(line.replace(/\x1b\[[0-9;]*m/g, "")))
.join("\n");
const { annotations } = utils.parseAnnotations(annotatable);
for (const ann of annotations) {
utils.reportAnnotationToBuildKite({
priority: 10,
label: ann.title || ann.filename,
content: utils.formatAnnotationToHtml(ann),
});
annotated = true;
}
} catch (err) {
console.error("Failed to parse annotations:", err);
}
// Nothing matched the compiler-error regexes → post a generic annotation
// with the full buffered output so there's still a PR-visible signal.
if (!annotated) {
const content = utils.formatAnnotationToHtml({
filename: relative(process.cwd(), fileURLToPath(import.meta.url)),
title: "build failed",
content: buffer,
source: "build",
level: "error",
});
utils.reportAnnotationToBuildKite({
priority: 10,
label: "build failed",
content,
});
}
}
if (signalCode) {
if (!killedManually) console.error(`Command killed: ${signalCode}`);
} else {
console.error(`Command exited: code ${exitCode}`);
}
utils.markBuildkiteStepReported();
process.exit(exitCode ?? 1);
}
// ───────────────────────────────────────────────────────────────────────────
// Buildkite artifacts — split-build upload/download
//
// CI splits builds per-platform into three parallel steps:
// build-cpp → libbun.a + all dep libs (this node uploads)
// build-rust → libbun_rust.a (this node uploads)
// build-bun → downloads both, links (this node downloads first)
//
// Paths are uploaded RELATIVE TO buildDir. buildkite-agent recreates the
// directory structure on download. The link-only ninja graph expects files
// at the SAME relative paths cpp-only produced them at — computeDepLibs()
// and emitNestedCmake() share the same path formula.
// ───────────────────────────────────────────────────────────────────────────
/**
* Upload build artifacts after a successful cpp-only or rust-only build.
* Runs `buildkite-agent artifact upload` with paths relative to buildDir.
*
* Large archives (libbun-*.a, >1GB) are gzipped — buildkite artifact
* storage is fine but upload/download is faster. link-only gunzips.
*
* ORDER MATTERS: upload dep libs FIRST (some live in cache/ — WebKit
* prebuilt), THEN rm cache + gzip + upload the archive. If cache is
* deleted first, WebKit lib upload fails with "file not found". The
* old cmake had this ordering implicitly — each dep's build uploaded
* its libs immediately; rm only ran when the archive target fired.
*/
export function uploadArtifacts(cfg: Config, output: BunOutput): void {
if (!isBuildkite) {
console.log("Not in Buildkite — skipping artifact upload");
return;
}
if (cfg.mode === "rust-only") {
// Relative to buildDir so link-only's `artifact download '*' .` recreates
// the rust-target/<triple>/<profile>/ layout that `rustLibPath(cfg)`
// expects. gzip on posix (release staticlib is ~200MB of mostly bitcode
// when LTO is on); .lib on Windows is uploaded raw — same convention as
// the cpp archive below.
const paths = output.rustObjects.map(obj => relative(cfg.buildDir, obj));
console.log(`Uploading ${paths.length} rust artifact(s)...`);
if (cfg.windows) {
upload(paths, cfg.buildDir);
} else {
for (const p of paths) run(["gzip", "-1", "-k", p], cfg.buildDir);
upload(
paths.map(p => `${p}.gz`),
cfg.buildDir,
);
}
return;
}
if (cfg.mode !== "cpp-only") {
// full/link-only don't upload split artifacts.
return;
}
// ─── Phase 1: upload dep libs (before we rm anything) ───
// In Buildkite, ninja already uploaded these via the bk_upload edge in
// bun.ts (overlapped with the cxx compile). The stamp is the witness; if
// it's missing (agent unavailable mid-build, or running cpp-only outside
// a real BK job), fall back to uploading here so link-only still gets them.
if (existsSync(resolve(cfg.buildDir, ".dep-libs-uploaded"))) {
console.log("Dep libs already uploaded during build");
} else {
const depPaths: string[] = [];
for (const dep of output.deps) {
for (const lib of dep.libs) {
depPaths.push(relative(cfg.buildDir, lib));
}
}
console.log(`Uploading ${depPaths.length} dep libs...`);
upload(depPaths, cfg.buildDir);
}
const testFFI = webkitTestFFIPath(cfg);
if (existsSync(testFFI)) {
console.log("Uploading testFFI...");
upload([relative(cfg.buildDir, testFFI)], cfg.buildDir);
}
// ─── Phase 2: free disk, gzip (posix only), upload archive ───
// CI agents are disk-constrained. Free what we no longer need: codegen/
// (sources already compiled into the archive), obj/ (.o files archived),
// cache/ (WebKit prebuilt — libs uploaded in phase 1, rest is headers
// + tarball we won't touch again).
if (output.archive !== undefined) {
const archiveName = basename(output.archive);
console.log("Cleaning intermediate files to free disk...");
rmSync(cfg.codegenDir, { recursive: true, force: true });
rmSync(resolve(cfg.buildDir, "obj"), { recursive: true, force: true });
rmSync(cfg.cacheDir, { recursive: true, force: true });
// gzip: posix only (matches cmake — only libbun-*.a are gzipped,
// Windows .lib archives uploaded uncompressed). gzip isn't a
// standard Windows tool anyway; the .lib is smaller (PDB is separate).
// downloadArtifacts() only gunzips .gz files it finds, so Windows
// archives pass through unchanged.
if (cfg.windows) {
console.log("Uploading archive (Windows: no gzip)...");
upload([archiveName], cfg.buildDir);
} else {
console.log(`Compressing ${archiveName}...`);
run(["gzip", "-1", archiveName], cfg.buildDir);
console.log("Uploading archive...");
upload([`${archiveName}.gz`], cfg.buildDir);
}
}
}
/**
* Upload via buildkite-agent. Semicolon-joined single arg — the agent
* splits on ";" by default (--delimiter flag, Value: ";"). Second
* positional arg is interpreted as upload DESTINATION, not another path.
*/
function upload(paths: string[], cwd: string): void {
if (paths.length === 0) return;
run(["buildkite-agent", "artifact", "upload", paths.join(";")], cwd);
}
// ───────────────────────────────────────────────────────────────────────────
// Link-only post-link: features.json + packaging + upload
//
// The zip contract (matching cmake's BuildBun.cmake packaging — test steps
// download these by exact name):
//
// ${bunTriplet}-profile.zip (plain release)
// └── ${bunTriplet}-profile/
// ├── bun-profile[.exe]
// ├── testFFI[.exe] (WebKit FFI test binary, when shipped)
// ├── features.json
// ├── bun-profile.linker-map (linux/mac non-asan)
// ├── bun-profile.pdb (windows)
// └── bun-profile.dSYM (mac)
//
// ${bunTriplet}.zip (stripped, plain release only)
// └── ${bunTriplet}/
// └── bun[.exe]
//
// ${bunTriplet}-asan.zip (asan — single zip, no strip)
// └── ${bunTriplet}-asan/
// ├── bun-asan
// └── features.json
//
// bunTriplet = bun-${os}-${arch}[-musl][-baseline]
//
// Test steps (runner.node.mjs) download '**' from build-bun and pick any
// bun*.zip; baseline-verification step downloads ${triplet}.zip specifically
// and expects ${triplet}/bun inside.
// ───────────────────────────────────────────────────────────────────────────
/**
* Base triplet (bun-os-arch[-musl][-baseline]). Variant suffix (-profile,
* -asan) is added by the caller. Matches ci.mjs getTargetTriplet() and
* cmake's bunTriplet — any drift breaks test-step downloads.
*/
export function computeBunTriplet(cfg: Config): string {
let t = `bun-${cfg.os}-${cfg.arch}`;
if (cfg.abi === "musl") t += "-musl";
if (cfg.abi === "android") t += "-android";
// No `-baseline` suffix: x64 is always baseline and the historical
// `-baseline` names are published as release-side aliases.
return t;
}
/**
* Post-link packaging and upload for the modes that link in CI. Runs
* AFTER ninja succeeds — at that point bun-profile (and stripped bun) exist.
*
* Generates features.json, packages into zips,
* uploads. Contract with test steps: see block comment above.
*/
export function packageAndUpload(cfg: Config, output: BunOutput): void {
if (!isBuildkite) return;
const exe = output.exe;
if (exe === undefined) {
throw new BuildError(`${cfg.mode} packaging: output.exe unset`);
}
const buildDir = cfg.buildDir;
const exeName = bunExeName(cfg); // bun-profile, bun-asan, etc.
const bunTriplet = computeBunTriplet(cfg);
// ─── features.json ───
// Run the built bun with features.mjs to dump its feature flags.
// Env vars match cmake's (BuildBun.cmake ~1462).
// No setarch wrapper — cmake doesn't use one for features.mjs either
// (only for the --revision smoke test).
// Binaries that can't run on this host: every field is a build-time
// constant, so generate the same payload host-side instead (the feature
// list is parsed out of src/analytics/lib.rs; see features-json.ts).
if (!cfg.canRunOnHost) {
console.log("Generating features.json (host-side; cross-compiled binary cannot run here)...");
writeFileSync(resolve(buildDir, "features.json"), crossFeaturesJson(cfg));
} else {
console.log("Generating features.json...");
run([exe, resolve(cfg.cwd, "scripts", "features.mjs")], buildDir, {
BUN_GARBAGE_COLLECTOR_LEVEL: "1",
BUN_DEBUG_QUIET_LOGS: "1",
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1",
});
}
const zipPaths: string[] = [];
// ─── Profile/variant zip ───
// cmake's bunPath: string(REPLACE bun ${bunTriplet} bunPath ${bun})
// where ${bun} is the target name (bun-profile, bun-asan, ...).
// Result: bun-linux-x64-profile, bun-linux-x64-asan, etc.
const bunPath = exeName.replace(/^bun/, bunTriplet);
const files: string[] = [basename(exe), "features.json"];
const testFFI = webkitTestFFIPath(cfg);
if (existsSync(testFFI)) {
chmodSync(testFFI, 0o755);
files.push(testFFI);
}
// Debug symbols / linker map — platform-specific extras.
if (cfg.windows) {
files.push(`${exeName}.pdb`);
} else if (cfg.darwin) {
files.push(`${exeName}.dSYM`);
}
// Linker map: posix non-asan (cmake gate: (APPLE OR LINUX) AND NOT ENABLE_ASAN).
if (cfg.unix && !cfg.asan) {
files.push(`${exeName}.linker-map`);
}
// The symbol ordering file this binary was linked with, next to the linker
// map. Skip the seeded placeholder — it has no functions in it.
const hasOrderFile = usesOrderFile(cfg) && orderFileFunctionCount(cfg) > 0;
if (hasOrderFile) {
files.push(basename(orderFilePath(cfg)));
}
zipPaths.push(makeZip(cfg, bunPath, files));
// Also upload it standalone, so the next build inherits it with a small
// download instead of pulling the whole profile zip. Only when this lane
// traced the file itself — a cross-compiled lane's fresh trace comes from the
// sibling trace-order step (.buildkite/ci.mjs), and re-uploading the inherited
// copy would give inheritOrderFile() two same-named artifacts to race over.
if (hasOrderFile && canTraceOrderFile(cfg)) {
const artifact = orderFileArtifact(cfg);
cpSync(orderFilePath(cfg), resolve(buildDir, artifact));
zipPaths.push(artifact);
}
// ─── Stripped zip ───
// Only for plain release (shouldStrip). Just the stripped `bun` binary.
// cmake: bunStripPath = string(REPLACE bun ${bunTriplet} bunStripPath bun) = bunTriplet.
if (shouldStrip(cfg) && output.strippedExe !== undefined) {
zipPaths.push(makeZip(cfg, bunTriplet, [basename(output.strippedExe)]));
const bytes = statSync(output.strippedExe).size;
run(["buildkite-agent", "meta-data", "set", `binary-size:${bunTriplet}`, String(bytes)], buildDir);
}
// ─── Upload ───
console.log(`Uploading ${zipPaths.length} zips...`);
upload(zipPaths, buildDir);
}
/**
* Create a zip at buildDir/${name}.zip containing buildDir/${name}/<files>.
*
* Uses `cmake -E tar cfv x.zip --format=zip` — cmake's cross-platform
* zip wrapper (wraps libarchive). GNU tar (Linux default) DOESN'T support
* --format=zip; bsdtar does but isn't guaranteed on Linux. cmake is
* already a required tool (we use it for nested dep builds), so this
* adds no new dependency. Identical to cmake's own packaging approach
* (BuildBun.cmake:1544).
*
* Files that don't exist are silently skipped (e.g., .pdb on a clean build).
* Returns the zip path relative to buildDir (for the upload call).
*/
function makeZip(cfg: Config, name: string, files: string[]): string {
const buildDir = cfg.buildDir;
const stageDir = resolve(buildDir, name);
const zip = `${name}.zip`;
// Clean previous run (idempotent).
rmSync(stageDir, { recursive: true, force: true });
rmSync(resolve(buildDir, zip), { force: true });
mkdirSync(stageDir, { recursive: true });
// Copy files that exist. Some debug outputs (.pdb, .dSYM, .linker-map)
// are optional depending on build config — skip rather than fail so a
// missing optional file doesn't break packaging.
let copied = 0;
for (const f of files) {
const src = resolve(buildDir, f);
if (!existsSync(src)) {
console.log(` (skip missing: ${f})`);
continue;
}
cpSync(src, resolve(stageDir, basename(f)), { recursive: true });
copied++;
}
console.log(`Creating ${zip} (${copied} files)...`);
// Relative path `name` puts `name/` prefix inside the zip — what test
// steps expect: they extract → `chmod +x ${triplet}/bun`.
run([cfg.cmake, "-E", "tar", "cfv", zip, "--format=zip", name], buildDir);
// Clean up the staging dir.
rmSync(stageDir, { recursive: true, force: true });
return zip;
}
/**
* Download artifacts from sibling buildkite steps before a link-only /
* rust-and-link build. Derives sibling step keys from BUILDKITE_STEP_KEY
* (swap `-build-bun` → `-build-cpp` / `-build-rust`). Gunzips any .gz files
* after download.
*
* rust-and-link runs in parallel with build-cpp (no depends_on), so it
* POLLS `buildkite-agent step get outcome` for the cpp step until it passes
* before attempting the download. link-only has depends_on and skips the
* poll.
*
* Call BEFORE ninja — the downloaded files are ninja's link inputs.
*/
export async function downloadArtifacts(cfg: Config): Promise<void> {
if (cfg.mode !== "link-only" && cfg.mode !== "rust-and-link") return;
const stepKey = process.env.BUILDKITE_STEP_KEY;
if (stepKey === undefined) {
throw new BuildError("BUILDKITE_STEP_KEY unset", {
hint: `${cfg.mode} mode requires running inside a Buildkite job`,
});
}
// step key is `<target>-build-bun`; siblings are `<target>-build-{cpp,rust}`.
const m = stepKey.match(/^(.+)-build-bun$/);
if (m === null) {
throw new BuildError(`Unexpected BUILDKITE_STEP_KEY: ${stepKey}`, {
hint: "Expected format: <target>-build-bun",
});
}
const targetKey = m[1]!;
const cppStep = `${targetKey}-build-cpp`;
// rust-and-link: no depends_on on build-cpp (it started alongside us so
// cargo could overlap). Poll its outcome; "passed" → download, any
// terminal failure → exit 1 with a clear message so the annotation points
// at build-cpp rather than a confusing "artifact not found" here.
if (cfg.mode === "rust-and-link") {
await waitForStepOutcome(cppStep);
}
const dl = (step: string) => {
console.log(`Downloading artifacts from ${step}...`);
return runAsync(["buildkite-agent", "artifact", "download", "*", ".", "--step", step], cfg.buildDir);
};
if (cfg.mode === "rust-and-link") {
// rust built locally — only the cpp archive + dep libs are fetched.
await dl(cppStep);
} else {
// link-only: both siblings. Overlap the two downloads; gunzip after both
// complete (the .gz scan is a recursive walk, so everything on disk first).
await Promise.all([dl(cppStep), dl(`${targetKey}-build-rust`)]);
}
// Recursive: rust artifact lands under rust-target/<triple>/<profile>/.
const gzFiles: string[] = [];
const walk = (dir: string) => {
if (!existsSync(dir)) return;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = resolve(dir, e.name);
if (e.isDirectory()) {
// rust-and-link built rust locally; skip cargo's huge output tree.
if (cfg.mode === "rust-and-link" && e.name === "rust-target") continue;
walk(p);
} else if (e.isFile() && e.name.endsWith(".gz")) gzFiles.push(relative(cfg.buildDir, p));
}
};
walk(cfg.buildDir);
await Promise.all(
gzFiles.map(gz => {
console.log(`Decompressing ${gz}...`);
return runAsync(["gunzip", "-f", gz], cfg.buildDir);
}),
);
}
/** Run a command synchronously, throw BuildError on non-zero exit. */
function run(argv: string[], cwd: string, env?: Record<string, string>): void {
const result = spawnSync(argv[0]!, argv.slice(1), {
cwd,
stdio: "inherit",
env: env ? { ...process.env, ...env } : undefined,
});
if (result.error) {
throw new BuildError(`Failed to spawn ${argv[0]}`, { cause: result.error });
}
if (result.status !== 0) {
throw new BuildError(`${argv[0]} exited with code ${result.status}`, {
hint: `Command: ${argv.join(" ")}`,
});
}
}
/** Async variant of `run()` for overlapping independent steps. */
function runAsync(argv: string[], cwd: string): Promise<void> {
return new Promise((res, rej) => {
const child = nodeSpawn(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" });
child.on("error", (err: Error) => rej(new BuildError(`Failed to spawn ${argv[0]}`, { cause: err })));
child.on("close", (code: number | null) => {
if (code === 0) res();
else rej(new BuildError(`${argv[0]} exited with code ${code}`, { hint: `Command: ${argv.join(" ")}` }));
});
});
}
/**
* Poll `buildkite-agent step get outcome --step <key>` until the step
* reaches a terminal state. Returns on "passed"; throws on any failure
* outcome so the caller exits 1 with a message that points at the real
* failing step (rather than a downstream "artifact not found").
*/
async function waitForStepOutcome(stepKey: string): Promise<void> {
const failed = new Set(["hard_failed", "soft_failed", "errored", "canceled", "cancelled"]);
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const start = Date.now();
const deadlineMs = 60 * 60 * 1000;
let last = "";
console.log(`Waiting for ${stepKey} to finish...`);
for (;;) {
const result = spawnSync("buildkite-agent", ["step", "get", "outcome", "--step", stepKey], { encoding: "utf8" });
if (result.error) {
throw new BuildError(`Failed to spawn buildkite-agent`, { cause: result.error });
}
if (result.status !== 0) {
const err = (result.stderr ?? "").trim();
if (err !== last) {
console.log(` buildkite-agent step get exited ${result.status}: ${err}`);
last = err;
}
if (Date.now() - start > deadlineMs) {
throw new BuildError(`buildkite-agent step get kept failing for ${stepKey}`, { hint: err });
}
await sleep(3000);
continue;
}
const outcome = (result.stdout ?? "").trim();
if (outcome !== last) {
const elapsed = Math.round((Date.now() - start) / 1000);
console.log(` ${stepKey} outcome: ${outcome || "(running)"} [${elapsed}s]`);
last = outcome;
}
if (outcome === "passed") return;
if (failed.has(outcome)) {
throw new BuildError(`Sibling step ${stepKey} ${outcome} — nothing to link`, {
hint: `See the ${stepKey} job for the real error; this step only downloads its artifacts.`,
});
}
if (Date.now() - start > deadlineMs) {
throw new BuildError(`Timed out after 60m waiting for ${stepKey}`, {
hint: `${stepKey} never reached a terminal outcome; check that job for a hang.`,
});
}
await sleep(3000);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Symbol ordering file
//
// A build either generates one (trace its own binary, relink against the result)
// or inherits an earlier build's and links once. Releases generate, canaries
// inherit, PRs do neither; one that inherits nothing generates, seeding the chain.
// ═══════════════════════════════════════════════════════════════════════════
/** Cap on builds we ask for an order file before giving up and generating one. */
const PREVIOUS_BUILDS_TO_TRY = 50;
/** Bound on the number-probe fallback: a branch is sparse among build numbers. */
const NUMBER_PROBE_BUDGET = 200;
/** Per-attempt cap, so a hung agent cannot blow the step's budget. */
const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 30_000;
/**
* The CI facts the order-file decisions depend on. Passed in rather than read
* from `process.env` inside, so the decisions are pure and testable.
*/
export interface OrderFileContext {
buildkite: boolean;
/** Buildkite build URL of the running build, for walking the branch. */
buildUrl: string | undefined;
branch: string | undefined;
buildNumber: number | undefined;
commitMessage: string;
pullRequest: boolean;
}
/** Read the environment once, at the edge. */
export function orderFileContext(): OrderFileContext {
const pr = process.env.BUILDKITE_PULL_REQUEST;
return {
buildkite: isBuildkite,
buildUrl: process.env.BUILDKITE_BUILD_URL,
branch: process.env.BUILDKITE_BRANCH,
buildNumber: Number(process.env.BUILDKITE_BUILD_NUMBER) || undefined,
commitMessage: process.env.BUILDKITE_MESSAGE ?? "",
pullRequest: pr !== undefined && pr !== "" && pr !== "false",
};
}
/** Only builds that link, on targets that use an order file, outside PRs. */
export function orderFileEligible(cfg: Config, ctx: OrderFileContext): boolean {
if (!usesOrderFile(cfg) || !ctx.buildkite || ctx.pullRequest) return false;
return cfg.mode !== "cpp-only" && cfg.mode !== "rust-only";
}
/** Tracing runs the binary we just linked, so the host must be able to execute it. */
export function canTraceOrderFile(cfg: Config): boolean {
return cfg.canRunOnHost;
}
/**
* An eligible lane that cannot trace (cross-compiled) and inherited nothing is
* shipping unordered. A sibling `-trace-order` step on a native-arch host seeds
* the chain (see getTraceOrderStep in .buildkite/ci.mjs), so this fires once on
* the first build and then the next build inherits that trace. If it persists,
* the trace step is failing or missing for this target.
*/
export function reportOrderFileCannotTrace(cfg: Config): void {
const msg =
`${orderFileArtifact(cfg)}: nothing to inherit and this lane cross-compiles ` +
`(target ${cfg.crossTarget}), so the binary cannot be traced here. Shipping unordered. ` +
`Expected once while the native-arch trace-order step seeds the chain; if this ` +
`appears on every build, that step is failing or missing.`;
console.log(`~ symbol order: ${msg}`);
if (!isBuildkite) return;
utils.reportAnnotationToBuildKite({
style: "warning",
priority: 5,
label: "symbol order file",
content: utils.formatAnnotationToHtml({
filename: "scripts/build/ci.ts",
title: "symbol order file: cross-compiled lane cannot trace, shipping unordered",
content: msg,
source: "build",
level: "warning",
}),
});
}
/** Artifact name for the standalone order file: `bun-linux-x64.order`. */
function orderFileArtifact(cfg: Config): string {
return `${computeBunTriplet(cfg)}.order`;
}
/** "1m4s" / "12s" — durations show up in every order-file log line. */
function since(start: number): string {
const seconds = Math.round((Date.now() - start) / 1000);
return seconds >= 60 ? `${Math.floor(seconds / 60)}m${seconds % 60}s` : `${seconds}s`;
}
/** Functions listed in the order file. 0 for the seeded placeholder or no file. */
function orderFileFunctionCount(cfg: Config): number {
const path = orderFilePath(cfg);
if (!existsSync(path)) return 0;
return readFileSync(path, "utf8")
.split("\n")
.filter((line: string) => line && !line.startsWith("#")).length;
}
/**
* Releases always trace their own binary — it is the artifact people install.
* A canary only does so on request, since it costs a second link.
*/
export function shouldGenerateOrderFile(cfg: Config, ctx: OrderFileContext): boolean {
if (!orderFileEligible(cfg, ctx) || !canTraceOrderFile(cfg)) return false;
if (!cfg.canary) return true;
return /\[generate symbol order\]/i.test(ctx.commitMessage);
}
/**
* A build that inherited nothing must generate: otherwise it publishes nothing,
* the next build inherits nothing either, and the chain never recovers.
*/
export function mustGenerateOrderFile(cfg: Config, ctx: OrderFileContext, inherited: boolean): boolean {
if (shouldGenerateOrderFile(cfg, ctx)) return true;
return orderFileEligible(cfg, ctx) && canTraceOrderFile(cfg) && !inherited;
}
/**
* Builds on this branch that might have published an order file, newest first.
* Lazy: the first candidate is nearly always the answer and the caller stops
* there, so the happy path is one lookup.
*/
async function* candidateBuilds(ctx: OrderFileContext): AsyncGenerator<{ id: string; number?: number }> {
const { branch, buildUrl } = ctx;
if (!branch || !buildUrl) return;
// https://buildkite.com/<org>/<pipeline>/builds/<n> -> https://buildkite.com/<org>/<pipeline>
const url = new URL(buildUrl);
const pipeline = new URL(url.pathname.replace(/\/builds\/.*$/, ""), url.origin).toString();
const fetchBuild = async (target: string): Promise<any | undefined> => {
const response: { error?: unknown; body?: any } = await utils.curl(target, { json: true, cache: true });
return response.error ? undefined : response.body;
};
const seen = new Set<string>();
// Buildkite dropped `prev_branch_build` from the public build JSON, so
// `utils.getLastSuccessfulBuild()` always returns undefined. This redirect is
// what works unauthenticated; it drops the `.json`, so read it rather than follow it.
const newest = await (async () => {
try {
const latest = `${pipeline}/builds/latest?branch=${encodeURIComponent(branch)}&state=passed`;
const location = (await fetch(latest, { redirect: "manual" })).headers.get("location");
return location ? await fetchBuild(`${location}.json`) : undefined;
} catch {
return undefined;
}
})();
if (newest?.id) {
seen.add(newest.id);
yield { id: newest.id, number: newest.number };
}
// Probe downwards from this build, not from the newest passed one: a build can
// fail its tests and still have linked and published.
let number = ctx.buildNumber;
if (number === undefined) return;
for (let probes = 0; probes < NUMBER_PROBE_BUDGET; probes++) {
number -= 1;
if (number < 1) return;
const body = await fetchBuild(`${pipeline}/builds/${number}.json`);
if (!body?.id || body.branch_name !== branch || seen.has(body.id)) continue;
seen.add(body.id);
yield { id: body.id, number: body.number };
}
}
/**
* Pull an earlier build's order file so this build links ordered without tracing.
* Downloads the small standalone `.order` artifact, not the profile zip it also
* rides in. Best-effort: no file means an unordered link, never a failed build.
*/
export async function inheritOrderFile(cfg: Config, ctx: OrderFileContext): Promise<boolean> {
if (!orderFileEligible(cfg, ctx) || shouldGenerateOrderFile(cfg, ctx)) return false;
const start = Date.now();
const artifact = orderFileArtifact(cfg);
console.log(`Looking for ${artifact} published by an earlier build on ${ctx.branch}...`);
const downloaded = resolve(cfg.buildDir, artifact);
let tried = 0;
for await (const build of candidateBuilds(ctx)) {
if (++tried > PREVIOUS_BUILDS_TO_TRY) break;
// No --step: exactly one step per build publishes the target-unique name —
// packageAndUpload() for a lane that traced its own binary, the sibling
// trace-order step (.buildkite/ci.mjs) for a cross-compiled one.
const result = spawnSync("buildkite-agent", ["artifact", "download", artifact, ".", "--build", build.id], {
cwd: cfg.buildDir,
stdio: "ignore",
timeout: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
});
if (result.status !== 0 || !existsSync(downloaded)) {
console.log(` #${build.number ?? "?"}: no ${artifact} (cancelled, failed, or too old) — looking further back`);
continue;
}
cpSync(downloaded, orderFilePath(cfg));
rmSync(downloaded, { force: true });
// An empty artifact would make us publish nothing, breaking the next build.
const functions = orderFileFunctionCount(cfg);
if (functions === 0) {
console.log(` #${build.number ?? "?"}: ${artifact} is empty — looking further back`);
continue;
}
console.log(
`+ symbol order: inherited ${artifact}, ${functions} functions from #${build.number ?? "?"} in ${since(start)}`,
);
return true;
}
const what =
tried === 0 ? "found no earlier build to inherit from" : `none of the ${tried} builds tried published it`;
console.log(`~ symbol order: ${what} (${since(start)})`);
return false;
}
/**
* Trace the binary from pass 1 and overwrite the order file. The caller re-runs
* ninja, which relinks and nothing else: `linkDepends()` lists the order file,
* so it is the only edge whose input changed.
*/
export function regenerateOrderFile(cfg: Config, ctx: OrderFileContext): void {
const start = Date.now();
const exeName = bunExeName(cfg); // bun-profile, or bun-assertions on an assertions build
const why = !cfg.canary
? "release build"
: shouldGenerateOrderFile(cfg, ctx)
? "[generate symbol order] in the commit message"
: "nothing to inherit";
console.log(`Tracing ${exeName} to build a fresh order file (${why})`);
console.log("Each workload runs under an injected function-entry tracer, so it is slower than a normal run.\n");
const { count } = generateOrderFile({ buildDir: cfg.buildDir, exeName, verbose: true });
console.log(`\n+ symbol order: traced ${count} functions in ${since(start)} — relinking against them`);
}
/**
* A canary found nothing to inherit and is paying a second link to seed the
* chain. Expected once; on every build it means inheriting is broken.
*/
export function reportOrderFileBootstrap(cfg: Config): void {
if (!cfg.canary) return; // a release always generates — nothing to report
const message =
`No earlier build published ${orderFileArtifact(cfg)}, so this build is tracing its own binary and ` +
`relinking (one extra link). Expected once, to seed the chain. If every build on this branch says ` +
`this, inheriting is broken — check the "Inherit symbol order file" step.`;
console.log(`~ symbol order: ${message}`);
if (!isBuildkite) return;
utils.reportAnnotationToBuildKite({
style: "warning",
priority: 5,
label: "symbol order file",
content: utils.formatAnnotationToHtml({
filename: "scripts/build/ci.ts",
title: "symbol order file: nothing to inherit, generating from scratch",
content: message,
source: "build",
level: "warning",
}),
});
}
/**
* The trace failed. Ship the unordered binary — correct, just fatter in resident
* pages — but annotate, so this cannot rot into a permanently unordered release.
*/
export function reportOrderFileFailure(error: Error): void {
console.error(`- symbol order: FAILED to generate — ${error.message}`);
console.error("- symbol order: linking unordered. The binary is correct; it just faults in more pages at startup.");
if (!isBuildkite) return;
utils.reportAnnotationToBuildKite({
// Not an error: the build is fine. A red annotation would read as a failure.
style: "warning",
priority: 5,
label: "symbol order file",
content: utils.formatAnnotationToHtml({
filename: "scripts/orderfile/generate.ts",
title: "symbol order file not generated — shipped unordered",
content: error.message,
source: "build",
level: "warning",
}),
});
}
/**
* Prove the relink honoured the order file: one lld silently ignores produces a
* binary indistinguishable from an unordered one. Scale-free — compare where the
* hot functions landed against where a typical function landed.
*/
export function verifyOrderFileApplied(cfg: Config, ctx: OrderFileContext, exe: string, { strict = true } = {}): void {
const SAMPLE = 1000;
/** Ordered, the hot set sits near the front; unordered, at ~100% of the control. */
const MAX_FRACTION_OF_CONTROL = 0.4;
/** Strict mode traced this exact binary, so nearly every name must resolve. */
const MIN_STRICT_MATCH_RATE = 0.75;
const start = Date.now();
if (!orderFileEligible(cfg, ctx)) return;
const wanted = readFileSync(orderFilePath(cfg), "utf8")
.split("\n")
.filter((line: string) => line && !line.startsWith("#"))
.slice(0, SAMPLE);
if (wanted.length < SAMPLE) {
console.log(`~ symbol order: only ${wanted.length} functions in the order file — nothing to verify`);
return;
}
// Same resolution as generate.ts: honor NM, else llvm-nm, else nm.
let nm = { status: null, stdout: "" } as { status: number | null; stdout: string };
for (const tool of [process.env.NM, "llvm-nm", "nm"].filter(Boolean) as string[]) {
nm = spawnSync(tool, ["--defined-only", exe], { encoding: "utf8", maxBuffer: 1 << 29 });
if (nm.status === 0) break;
}
if (nm.status !== 0) {
console.log("~ symbol order: no working nm — skipping verification");
return;
}
const addresses = new Map<string, number>();
let textBase = Number.MAX_SAFE_INTEGER;
for (const line of nm.stdout.split("\n")) {
const m = /^([0-9a-f]+) ([tT]) (\S+)$/.exec(line);
if (!m) continue;
const address = parseInt(m[1]!, 16);
addresses.set(m[3]!, address);
if (address < textBase) textBase = address;
}
const median = (values: number[]) => (values.length ? values[values.length >> 1]! : 0);
const sorted = (values: number[]) => values.sort((a, b) => a - b);
const offsets = sorted(
wanted
.map(name => addresses.get(name))
.filter((address): address is number => address !== undefined)
.map(address => address - textBase),
);
// Where a typical function sits. Ordering does not move this.
const control = median(sorted([...addresses.values()].map(address => address - textBase)));
// An inherited file legitimately loses symbols to code churn; one we just
// generated from this binary has no such excuse, so only that case is fatal.
const fail = (message: string, hint: string) => {
if (strict) throw new BuildError(`symbol order: ${message}`, { hint });
console.log(`~ symbol order: ${message}${hint}`);
};
const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;
const rate = offsets.length / wanted.length;
if (offsets.length === 0) {
fail(
"not one of the order file's symbols is in the linked binary",
"the symbol spellings do not match this link — see scripts/orderfile/generate.ts",
);
return;
}
if (strict && rate < MIN_STRICT_MATCH_RATE) {
fail(
`only ${offsets.length}/${wanted.length} of the order file's symbols are in the binary we traced`,
"the order file and the link disagree on symbol names — most of the win is being silently lost",
);
return;
}
const hot = median(offsets);
if (control > 0 && hot > control * MAX_FRACTION_OF_CONTROL) {
fail(
`the order file had no effect: hot functions sit at ${mb(hot)}, a typical one at ${mb(control)}`,
cfg.darwin
? "Apple ld ignored it — check -order_file and that the names match nm's"
: "lld ignored it — check --symbol-ordering-file and that -ffunction-sections survived",
);
return;
}
console.log(
`+ symbol order: applied — ${offsets.length}/${wanted.length} (${(rate * 100).toFixed(0)}%) of the hottest ` +
`functions resolved; median ${mb(hot)} into .text vs ${mb(control)} for a typical one (${since(start)})`,
);
}