Files

1184 lines
54 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
/**
* The bun executable target — orchestrates everything.
*
* This is where all the phases come together:
* - emit codegen → generated .cpp/.h/.rs
* - emit cargo build → libbun_rust.a
* - resolve all deps → lib paths + include dirs
* - build PCH from root-pch.h (implicit deps: WebKit libs + all codegen)
* - compile all C/C++ with the PCH
* - link everything → bun-debug (or bun-profile, bun-asan, etc.)
* - smoke test: run `<exe> --revision` to catch load-time failures
*
* ## Build modes
*
* `cfg.mode` controls what we actually produce:
* - "full": everything (default, local dev)
* - "cpp-only": compile to libbun.a, skip rust/link (CI upstream)
* - "rust-only": codegen + cargo → libbun_rust.a (CI upstream)
* - "link-only": link pre-built artifacts (CI downstream)
* - "rust-and-link": cargo + link; downloads cpp-only's archive (CI)
* - "archive-link": full build on one agent, linked from the cpp-only-style archive; uploads it + libbun_rust.a (CI)
*
* The split modes are for CI where C++ and Rust build in parallel on
* separate machines. rust-and-link folds the rust + link steps onto one
* agent (cargo runs while cpp-only is still compiling elsewhere; the
* cpp archive is polled for and downloaded before ninja links).
*/
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { dirname, relative, resolve, sep } from "node:path";
import type { Sources } from "../glob-sources.ts";
import { emitCodegen, type CodegenOutputs } from "./codegen.ts";
import { ar, cc, cxx, link, pch } from "./compile.ts";
import { bunExeName, shouldStrip, type Config } from "./config.ts";
import { generateDepVersionsHeader } from "./depVersionsHeader.ts";
import { allDeps } from "./deps/index.ts";
import { lolhtml } from "./deps/lolhtml.ts";
import { assert } from "./error.ts";
import { bunIncludes, computeFlags, extraFlagsFor, linkDepends } from "./flags.ts";
import { writeIfChanged } from "./fs.ts";
import type { BuildNode, Ninja } from "./ninja.ts";
import { emitRust, linkerMapPath, rustLibPath, rustLtoLinkInputs } from "./rust.ts";
import { quote, slash } from "./shell.ts";
import { emitShims, machoPostlinkCommand, machoPostlinkImplicitInputs } from "./shims.ts";
import { computeDepLibs, resolveDep, type ResolvedDep } from "./source.ts";
import { streamPath } from "./stream.ts";
import { generateUnifiedSources } from "./unified.ts";
// ───────────────────────────────────────────────────────────────────────────
// Executable naming
// ───────────────────────────────────────────────────────────────────────────
// Re-exported for existing importers (configure.ts, ci.ts). These live
// in config.ts now so flags.ts can use bunExeName without circular import.
export { bunExeName, shouldStrip };
/**
* System libraries to link. Platform-dependent.
*/
function systemLibs(cfg: Config): string[] {
const libs: string[] = [];
if (cfg.linux) {
if (cfg.abi === "android") {
// bionic: pthread/dl/rt are folded into libc; no separate libatomic
// (compiler-rt builtins). -llog for __android_log_*.
libs.push("-lc", "-lm", "-llog");
} else {
libs.push("-lc", "-lpthread", "-ldl");
// libatomic: static by default (CI distros ship it), dynamic on Arch-like.
// The static path needs to be the actual file path for lld to find it;
// dynamic uses -l syntax. We emit what CMake does: bare libatomic.a gets
// found in lib search paths, -latomic.so doesn't exist so we use -latomic.
if (cfg.staticLibatomic) {
libs.push("-l:libatomic.a");
} else {
libs.push("-latomic");
}
}
// Linux local WebKit: link system ICU (prebuilt bundles its own).
// Assumes system ICU is in default lib paths — true on most distros.
// Android: no system ICU; the local WebKit build must bundle it.
if (cfg.webkit === "local" && cfg.abi !== "android") {
libs.push("-licudata", "-licui18n", "-licuuc");
}
}
if (cfg.darwin) {
// icucore: system ICU framework.
// resolv: DNS resolution (getaddrinfo et al).
libs.push("-licucore", "-lresolv");
}
if (cfg.freebsd) {
// pthread/m: explicit on FreeBSD (not folded into libc).
// execinfo: backtrace() — separate library on FreeBSD.
// kvm/procstat/elf/util: process introspection for node:os and crash handler.
libs.push("-lc", "-lpthread", "-lm", "-lexecinfo", "-lkvm", "-lprocstat", "-lelf", "-lutil");
}
if (cfg.windows) {
// Explicit .lib: these go after /link so no auto-suffixing by the
// clang-cl driver. lld-link auto-appends .lib but link.exe doesn't;
// explicit is portable.
libs.push(
"winmm.lib",
"bcrypt.lib",
"ntdll.lib",
"userenv.lib",
"dbghelp.lib",
"crypt32.lib",
"wsock32.lib", // ws2_32 + wsock32 — wsock32 has TransmitFile (sendfile equiv)
"ws2_32.lib",
"delayimp.lib", // required for /delayload: in release
);
}
return libs;
}
// ───────────────────────────────────────────────────────────────────────────
// Main orchestration
// ───────────────────────────────────────────────────────────────────────────
/**
* Output of `emitBun()`. Paths to the produced artifacts and resolved
* deps — used by configure.ts for mkdir + default-target selection, and
* by ci.ts for artifact upload.
*
* Optional fields are present only when the mode produces them:
* full: exe, strippedExe?, dsym?, rustObjects, objects, deps, codegen
* cpp-only: archive, objects, deps, codegen
* rust-only: rustObjects, deps (lolhtml), codegen
* link-only: exe, strippedExe?, dsym?
*/
export interface BunOutput {
/** Linked executable (bun-debug, bun-profile). Full/link-only. */
exe?: string;
/** Stripped `bun`. Plain release full/link-only. */
strippedExe?: string | undefined;
/** .dSYM bundle (darwin plain release). Added to default targets so ninja builds it. */
dsym?: string | undefined;
/** libbun.a — all C/C++ objects archived. cpp-only. */
archive?: string;
/** All resolved deps (full libs list). Empty in link-only (paths computed separately). */
deps: ResolvedDep[];
/** All codegen outputs. Not present in link-only. */
codegen?: CodegenOutputs;
/** The Rust staticlib path(s). Empty in cpp-only. */
rustObjects: string[];
/** All compiled .o files. Empty in link-only/rust-only. */
objects: string[];
/** Stamps of the buildkite artifact-upload edges; archive-link adds them to the default targets. */
uploadStamps?: string[];
}
/**
* Emit the full bun build graph. Returns the output executable path.
*
* Call after `registerAllRules(n, cfg)`. `sources` is the globbed file
* snapshot from `globAllSources()` — passed in so globbing happens once.
*/
export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput {
// Split modes get minimal graphs — separate functions.
if (cfg.mode === "rust-only") {
return emitRustOnly(n, cfg, sources);
}
if (cfg.mode === "link-only") {
return emitLinkOnly(n, cfg);
}
if (cfg.mode === "rust-and-link") {
return emitRustAndLink(n, cfg, sources);
}
const exeName = bunExeName(cfg);
n.comment("════════════════════════════════════════════════════════════════");
n.comment(` Building ${exeName}`);
n.comment("════════════════════════════════════════════════════════════════");
n.blank();
// ─── Step 1: codegen + rust ───
// Emitted before the deps: ninja breaks scheduling ties by emission order, and cargo is the critical path (see the compile pool in compile.ts).
const codegen = emitCodegen(n, cfg, sources);
const depsByName = new Map<string, ResolvedDep>();
// One cargo invocation produces a single staticlib that occupies the
// same slot in the link as the C++ archive. Rust `include!`s codegen
// `.rs` outputs (written as side effects of the generate-classes /
// bundle-modules / generate-jssink edges), so the codegen output set
// is forwarded as implicit inputs to order it first.
//
// cpp-only: skip rust entirely (runs on a separate CI machine).
let rustObjects: string[] = [];
if (cfg.mode !== "cpp-only") {
// lol-html is a direct path dep of `bun_runtime`/`bun_bundler`
// (`lol_html = { path = "vendor/lolhtml" }` in the workspace Cargo.toml),
// not built into a separate archive — cargo needs `vendor/lolhtml/` on
// disk before it resolves the manifest. The `.ref` stamp's content is
// the pinned commit, so a bump re-invokes cargo.
const lolhtmlDep = resolveDep(n, cfg, lolhtml, depsByName);
assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped");
depsByName.set(lolhtml.name, lolhtmlDep);
rustObjects = emitRust(n, cfg, {
codegenInputs: codegen.rustInputs,
codegenOrderOnly: codegen.rustOrderOnly,
rustSources: sources.rust,
vendorStamps: lolhtmlDep.outputs,
});
}
// ─── Step 2: resolve all deps ───
n.comment("─── Dependencies ───");
n.blank();
const deps: ResolvedDep[] = [];
for (const dep of allDeps) {
const resolved = depsByName.get(dep.name) ?? resolveDep(n, cfg, dep, depsByName);
if (resolved !== null) {
deps.push(resolved);
depsByName.set(dep.name, resolved);
}
}
// Collect all dep lib paths, include dirs, output stamps, and directly-
// compiled source files (deps like picohttpparser that provide .c files
// instead of a .a — we compile those alongside bun's own sources).
const depLibs: string[] = [];
const depObjects: string[] = [];
const depIncludes: string[] = [];
// Outputs of deps that provide headers — used as implicit inputs on PCH/cc/
// no-PCH cxx so a dep rebuild invalidates compiles that #include its headers
// (the .a is the signal — see comment at the PCH step). Deps with no provided
// includes (tinycc, lolhtml) are skipped: nothing to invalidate, and a tinycc
// no-op rebuild (ar has no restat) would otherwise cascade to a full PCH+cxx
// rebuild. Link still gets every dep via depLibs/depObjects.
const depHeaderSignal: string[] = [];
for (const d of deps) {
depLibs.push(...d.libs);
depObjects.push(...d.objects);
depIncludes.push(...d.includes);
// d.outputs is the "headers are ready" signal: for nested-cmake/
// prebuilt that's the .a/stamp (headers are undeclared side-effects),
// for direct deps it's the generated-header set + source stamp.
if (d.includes.length > 0) depHeaderSignal.push(...d.outputs);
}
// ─── Step 3: configure-time generated header + assemble flags ───
// bun_dependency_versions.h — written at configure time, not a ninja rule.
// BunProcess.cpp includes it for process.versions. writeIfNotChanged
// semantics so bumping an unrelated dep doesn't recompile everything.
generateDepVersionsHeader(cfg);
const flags = computeFlags(cfg);
// Full include set: bun's own + all dep includes + buildDir (for the
// generated versions header).
const allIncludes = [...bunIncludes(cfg), cfg.buildDir, ...depIncludes];
const includeFlags = allIncludes.map(inc => `-I${inc}`);
const defineFlags = flags.defines.map(d => `-D${d}`);
// Final flag arrays for compile.
const cxxFlagsFull = [...flags.cxxflags, ...includeFlags, ...defineFlags];
const cFlagsFull = [...flags.cflags, ...includeFlags, ...defineFlags];
// ─── Step 4: PCH ───
// CI full mode (unused by the pipeline) skips the PCH; cpp-only/archive-link use it.
const usePch = !cfg.ci || cfg.mode !== "full";
let pchOut: { pch: string; wrapperHeader: string } | undefined;
if (usePch) {
n.comment("─── PCH ───");
n.blank();
// Dep outputs are IMPLICIT inputs (not order-only). The crucial case is
// local WebKit: headers live in buildDir and get REGENERATED by dep_build
// mid-run. At startup, ninja sees old headers via PCH's depfile → thinks
// PCH is fresh. dep_build then regenerates them. cxx fails with "file
// modified since PCH was built". As implicit inputs, restat sees the .a
// changed → PCH rebuilds → one-build convergence. See the pch() docstring.
//
// Codegen stays order-only: those outputs only change if inputs change,
// and inputs don't change mid-build. cppAll (not all) — bake/.rs outputs
// are rust-only; pulling them here would run bake-codegen in cpp-only CI
// mode where it fails on the pinned bun version (see cppAll docstring).
// Scripts that emit undeclared .h also emit a .cpp/.h in cppAll, so they
// still run. cxx transitively waits: cxx → PCH → deps+cppAll.
pchOut = pch(n, cfg, "src/jsc/bindings/root-pch.h", {
flags: cxxFlagsFull,
implicitInputs: depHeaderSignal,
orderOnlyInputs: codegen.cppAll,
});
}
// ─── Step 5: compile C/C++ ───
n.comment("─── C/C++ compilation ───");
n.blank();
// Source lists: from the pre-globbed snapshot + platform extras.
// Unified sources: bundle the globbed .cpp into N-per-TU wrappers (see
// unified.ts for N). Generated at configure time; depfiles track the underlying
// .cpp files so editing one rebuilds its bundle. Codegen .cpp are kept
// separate — those are already large single TUs (ZigGeneratedClasses.cpp
// is 3.3 MB) and bundling them would serialize work. Always called so
// stale bundles are pruned even with --unifiedSources=false.
const split = generateUnifiedSources(cfg, sources.cxx);
const cxxSources = [...split.unified, ...split.standalone];
const cSources = [...sources.c];
// Sources that must NOT use the PCH. Anything that needs to set defines
// before <Windows.h> (UNICODE, WIN32_LEAN_AND_MEAN opt-outs, etc.) goes
// here — root-pch.h transitively includes Windows.h via WTF, so the
// force-include would lock those in before the source can speak.
const noPchSources = new Set<string>();
// highway_json.cpp is compiled -O2 even in debug profiles (see its
// fileOverrides entry in flags.ts); a TU at a different -O level than the
// PCH cannot use the PCH ("__OPTIMIZE__ ... was disabled in precompiled
// file"). It only includes highway + libc headers anyway.
if (cfg.debug) {
noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_json.cpp"));
noPchSources.add(resolve(cfg.cwd, "src/jsc/bindings/highway_xml.cpp"));
}
// Windows-only cpp sources (rescle — PE resource editor for --compile).
if (cfg.windows) {
// rescle.h does `#define UNICODE` before including ATL; with PCH the
// headers are already past in MBCS mode and ATL's TCHAR mismatches.
const rescle = resolve(cfg.cwd, "src/jsc/bindings/windows/rescle.cpp");
const rescleBinding = resolve(cfg.cwd, "src/jsc/bindings/windows/rescle-binding.cpp");
cxxSources.push(rescle, rescleBinding);
noPchSources.add(rescle);
noPchSources.add(rescleBinding);
}
// Deps with provides.sources compiled in the loop below so each dep's
// phony can point at its own .o files.
// Codegen .cpp files — compiled like regular sources.
cxxSources.push(...codegen.cppSources);
cxxSources.push(...codegen.bindgenV2Cpp);
// All deps must be ready (headers extracted, libs built) before compile.
//
// depHeaderSignal are IMPLICIT inputs, not order-only. A locally-built dep's
// sub-build (e.g. WebKit) rewrites forwarding headers as an undeclared side
// effect of the edge whose declared outputs are only lib*.a. Depfiles record
// those headers, but ninja stats them BEFORE the sub-build runs — so with
// order-only, any compile that #includes a dep header lags one build behind
// a dep rebuild (observed: uv-posix-*.c → wtf/Compiler.h).
// Implicit deps on the libs make "dep rebuilt" itself the invalidation
// signal. Cost is negligible: if the libs changed you're relinking anyway.
//
// codegen.cppAll stays order-only: those headers ARE declared ninja outputs
// with restat, so depfile tracking is exact and doesn't lag.
//
// PCH also has implicit deps on depHeaderSignal (see above). When PCH is enabled,
// cxx inherits the dep transitively via its implicit dep on the PCH, so we
// don't add it again.
const codegenOrderOnly = codegen.cppAll;
// Compile all .cpp with PCH.
// Emit compile_commands.json entries for the ORIGINAL bundled .cpp files
// too — clangd looks up flags by the file you opened, and a bundled source
// has no ninja edge of its own. Same flags as the bundle (no PCH listed —
// clangd parses standalone, and the PCH path is build-internal).
for (const src of split.bundled) {
n.addCompileCommand({
directory: cfg.buildDir,
file: src,
arguments: [cfg.cxx, ...cxxFlagsFull, "-c", src],
});
}
const cxxObjects: string[] = [];
for (const src of cxxSources) {
const relSrc = relative(cfg.cwd, src);
const extraFlags = extraFlagsFor(cfg, relSrc);
const opts: Parameters<typeof cxx>[3] = {
flags: [...cxxFlagsFull, ...extraFlags],
};
if (pchOut !== undefined && !noPchSources.has(src)) {
// PCH has implicit deps on depHeaderSignal. cxx has implicit dep on PCH.
// Transitively: cxx waits for deps. No need to repeat them here.
opts.pch = pchOut.pch;
opts.pchHeader = pchOut.wrapperHeader;
} else {
// No PCH (CI full mode, or per-file opt-out) — each cxx needs the dep
// signal directly.
opts.implicitInputs = depHeaderSignal;
opts.orderOnlyInputs = codegenOrderOnly;
}
cxxObjects.push(cxx(n, cfg, src, opts));
}
// Compile all .c files. No PCH — dep signal applied directly.
const cObjects: string[] = [];
const compileC = (src: string): string => {
const obj = cc(n, cfg, src, {
flags: cFlagsFull,
implicitInputs: depHeaderSignal,
orderOnlyInputs: codegenOrderOnly,
});
cObjects.push(obj);
return obj;
};
for (const src of cSources) compileC(src);
// InternalModuleRegistryConstants.S — `.incbin`s the bundled JS module sources
// so InternalModuleRegistry.cpp sees a tiny {offset, length} table instead of
// megabytes of byte-array initializers. The `.bin` payload is an implicit
// input: `.incbin` is opaque to depfiles, and the `.S` itself rarely changes.
// cFlagsFull carries --target/--sysroot/-march so a cross-compile's
// preprocessor picks the right __APPLE__/_WIN32 branch and object format.
cObjects.push(
cc(n, cfg, codegen.internalModulesAsm, {
flags: cFlagsFull,
implicitInputs: [codegen.internalModulesBin],
}),
);
// Deps that contribute source files for bun to compile directly (via
// provides.sources) instead of building a lib. Compile them here with
// bun's full flag set and give each a phony so `--target <name>` builds
// its .o files. libs.length === 0 guard: deps with a build step already
// got a phony in resolveDep — don't emit a duplicate.
for (const d of deps) {
if (d.sources.length === 0 || d.libs.length > 0) continue;
n.phony(d.name, d.sources.map(compileC));
}
// Dep objects (when !cfg.archiveDeps) are linked alongside bun's own
// objects — same response file, same archive in cpp-only mode. With
// cfg.archiveDeps they live in depLibs as .a files instead.
const allObjects = [...cxxObjects, ...cObjects, ...depObjects];
// ─── Step 6: cpp-only / archive-link → archive (cpp-only returns here) ───
// CI's build-cpp step: archive all .o into libbun.a, stop. The sibling
// build-rust step produces libbun_rust.a independently; build-bun
// downloads both artifacts and links them. Archive name uses the exe
// name (not just "libbun") so asan/debug variants are distinguishable.
const archived = cfg.mode === "cpp-only" || cfg.mode === "archive-link";
let archive: string | undefined;
const uploadStamps: string[] = [];
if (archived) {
n.comment(`─── Archive (${cfg.mode}) ───`);
n.blank();
archive = ar(n, cfg, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`, allObjects);
// Upload dep libs as soon as they're built — they're ready ~minutes
// before the archive (WebKit copies from prefetch in seconds; lolhtml
// builds in ~30s), so the upload overlaps the cxx compile instead of
// waiting for it. Own pool so it doesn't take a compile slot. ci.ts's
// uploadArtifacts() then only handles the archive.
if (cfg.buildkite) {
registerBkUploadRules(n, cfg);
if (depLibs.length > 0) uploadStamps.push(emitBkUpload(n, cfg, ".dep-libs-uploaded", depLibs));
// archive-link: each upload edge depends only on its input, so it starts the moment the archive / staticlib exists and overlaps the link.
if (cfg.mode === "archive-link") {
uploadStamps.push(emitBkUpload(n, cfg, ".archive-uploaded", [archive], { gzip: !cfg.windows }));
uploadStamps.push(emitBkUpload(n, cfg, ".rust-lib-uploaded", rustObjects, { gzip: !cfg.windows }));
}
}
// depLibs explicit in the phony: deps with no provided includes (tinycc,
// lolhtml) aren't in depHeaderSignal, so the archive doesn't pull them
// transitively — but link-only still needs them uploaded.
if (cfg.mode === "cpp-only") {
n.phony("bun", [archive, ...depLibs, ...uploadStamps]);
n.default(["bun"]);
return { archive, deps, codegen, rustObjects, objects: allObjects };
}
}
// ─── Step 6: link ───
n.comment("─── Link ───");
n.blank();
// Windows resources (.rc → .res): icon, VersionInfo. Compiled at link
// time (not archived in cpp-only) — .res is small and the .rc depends
// on cfg.version which the link step already has. Matches cmake's
// behavior of adding WINDOWS_RESOURCES to add_executable in link-only.
const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : [];
// Full link.
// The Rust staticlib goes into `$in` between bun's own objects and the
// dependency archives so symbol resolution order is preserved: C++
// objects create the `Bun__*` undefined refs, the Rust archive satisfies
// them (and `main`, via crt1.o) and in turn references JSC/WTF, depLibs
// satisfies those. Every `#[no_mangle]` export the C++ side touches is
// reached transitively from those roots, so no `--whole-archive` wrapping
// is needed; if a member ever isn't, `rustLinkFlags()` in rust.ts is the
// wrapping helper.
const shims = emitShims(n, cfg);
// rustLtoLinkInputs(): on ELF cross-language LTO targets the Rust bitcode
// is rewritten with a regular-LTO summary first (identity elsewhere).
const linkObjects = [
...(archive !== undefined ? [archive] : allObjects),
...rustLtoLinkInputs(n, cfg, rustObjects),
...windowsRes,
];
const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags];
const exe = link(n, cfg, exeName, linkObjects, {
libs: depLibs,
flags: ldflags,
implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
// Declare the `-Wl,-Map=` side-product so `perf` symbolication picks it
// up. Linux release only — the map flag itself is gated identically in
// flags.ts.
linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined,
});
// ─── Step 7: post-link (strip, dsymutil, smoke test) ───
const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags);
return { exe, strippedExe, dsym, deps, codegen, rustObjects, objects: allObjects, uploadStamps };
}
function registerBkUploadRules(n: Ninja, cfg: Config): void {
n.pool("bk_upload", 1);
// Paths are buildDir-relative so artifact names match downloadArtifacts(); `;` is the agent's path delimiter.
const win = cfg.host.os === "windows";
n.rule("bk_upload", {
command: win
? `cmd /c buildkite-agent artifact upload "$paths" && type nul > $out`
: `buildkite-agent artifact upload '$paths' && touch $out`,
description: "buildkite upload $out",
pool: "bk_upload",
});
if (!win) {
n.rule("bk_upload_gz", {
command: `gzip -1 -k -f $in && buildkite-agent artifact upload '$paths' && touch $out`,
description: "gzip + buildkite upload $out",
pool: "bk_upload",
});
}
}
function emitBkUpload(n: Ninja, cfg: Config, stamp: string, files: string[], { gzip = false } = {}): string {
const useGz = gzip && cfg.host.os !== "windows";
const rel = files.map(p => relative(cfg.buildDir, p));
const out = resolve(cfg.buildDir, stamp);
n.build({
outputs: [out],
rule: useGz ? "bk_upload_gz" : "bk_upload",
inputs: files,
vars: { paths: (useGz ? rel.map(p => `${p}.gz`) : rel).join(";") },
});
return out;
}
/**
* rust-only mode: emit just the cargo build graph. CI's build-rust step
* uses this to compile libbun_rust.a in parallel with build-cpp; target
* set via --os/--arch overrides (cargo `--target <triple>`).
*
* Needs:
* - lolhtml FETCHED (path dep of `bun_runtime`/`bun_bundler`) — not built separately
* - codegen (Rust `include!`s/`include_bytes!`s the same generated set)
* - cargo build → libbun_rust.a
*
* Does NOT need: any C dep built, any cxx, PCH, link. ninja only pulls
* what's depended on — lolhtml's configure/build rules are emitted but
* unused (only its `.ref` fetch stamp is depended on by emitRust).
*/
function emitRustOnly(n: Ninja, cfg: Config, sources: Sources): BunOutput {
n.comment("════════════════════════════════════════════════════════════════");
n.comment(` Building libbun_rust.a (rust-only, target: ${cfg.os}-${cfg.arch})`);
n.comment("════════════════════════════════════════════════════════════════");
n.blank();
// Only dep: lolhtml, fetched as a cargo path dependency. resolveDep
// emits its fetch; emitRust depends on the fetch stamp via vendorStamps.
const lolhtmlDep = resolveDep(n, cfg, lolhtml, new Map());
assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped");
// Codegen: emitted fully, but only the embed-input subset is pulled.
// The cpp-related outputs (cppSources, bindgenV2Cpp) have no consumer
// in this graph — ninja skips them.
const codegen = emitCodegen(n, cfg, sources);
const rustObjects = emitRust(n, cfg, {
codegenInputs: codegen.rustInputs,
codegenOrderOnly: codegen.rustOrderOnly,
rustSources: sources.rust,
vendorStamps: lolhtmlDep.outputs,
});
n.phony("bun", rustObjects);
n.default(["bun"]);
return { deps: [lolhtmlDep], codegen, rustObjects, objects: [] };
}
/**
* link-only mode: link artifacts downloaded from sibling buildkite steps.
* CI's build-bun step. Build.ts downloads into buildDir BEFORE ninja runs;
* ninja sees the files as source inputs (no build rule — errors cleanly
* if download failed or paths drift).
*
* Expected artifacts (same paths cpp-only/rust-only produced):
* - libbun-profile.a — from cpp-only's ar()
* - libbun_rust.a / bun_rust.lib — from rust-only's cargo (rustLibPath)
* - deps/<name>/lib<name>.a — from cpp-only's dep builds
* - cache/webkit-<hash>/lib/... — WebKit prebuilt (same cache path)
*/
function emitLinkOnly(n: Ninja, cfg: Config): BunOutput {
const exeName = bunExeName(cfg);
n.comment("════════════════════════════════════════════════════════════════");
n.comment(` Linking ${exeName} (link-only — artifacts from buildkite)`);
n.comment("════════════════════════════════════════════════════════════════");
n.blank();
// Dep lib paths — computed, not built. Must match cpp-only's output
// paths exactly; computeDepLibs() and emitNestedCmake()'s path logic
// share the same formula. If they drift, link fails with "file not
// found" — loud enough to catch in CI.
const depLibs: string[] = [];
for (const dep of allDeps) {
depLibs.push(...computeDepLibs(cfg, dep));
}
// Archive from cpp-only: same name cpp-only emits (exe name + lib
// prefix/suffix, e.g. libbun-profile.a).
const archive = resolve(cfg.buildDir, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`);
// libbun_rust.a from rust-only: same path emitRust writes to. Shared
// helper so both sides of the CI split agree (cargo's
// `<target-dir>/<triple>/<profile>/` layout). rustLtoLinkInputs(): on ELF
// cross-language LTO targets the downloaded archive's bitcode is rewritten
// with a regular-LTO summary on this (link) agent before the link.
const rustObjects = rustLtoLinkInputs(n, cfg, [rustLibPath(cfg)]);
// Only need ldflags + stripflags (no cflags/cxxflags — no compile).
const flags = computeFlags(cfg);
n.comment("─── Link ───");
n.blank();
// Windows resources: compiled here, not downloaded from cpp-only.
// .res is small; .rc substitution depends on cfg.version which link-only
// knows. Matches cmake's BUN_LINK_ONLY adding WINDOWS_RESOURCES directly.
const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : [];
const shims = emitShims(n, cfg);
const linkObjects = [archive, ...rustObjects, ...windowsRes];
const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags];
const exe = link(n, cfg, exeName, linkObjects, {
libs: depLibs,
flags: ldflags,
implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined,
});
// Strip + smoke test — same as full mode.
const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags);
return {
exe,
strippedExe,
dsym,
deps: [], // no ResolvedDep — we only computed lib paths
rustObjects,
objects: [],
};
}
/**
* rust-and-link mode: cargo build + link on one CI agent. The cpp archive
* and dep libs are downloaded from the sibling build-cpp step (ci.ts polls
* for its outcome and downloads before ninja runs); libbun_rust.a is built
* locally. Graph = emitRustOnly's cargo edge + emitLinkOnly's link edge.
*
* Expected downloaded artifacts (same paths cpp-only produced):
* - libbun-profile.a — from cpp-only's ar()
* - deps/<name>/lib<name>.a — from cpp-only's dep builds
* - cache/webkit-<hash>/lib/... — WebKit prebuilt (same cache path)
*/
function emitRustAndLink(n: Ninja, cfg: Config, sources: Sources): BunOutput {
const exeName = bunExeName(cfg);
n.comment("════════════════════════════════════════════════════════════════");
n.comment(` Building ${exeName} (rust-and-link — cpp archive from buildkite)`);
n.comment("════════════════════════════════════════════════════════════════");
n.blank();
// ─── Rust (built here) ───
// lolhtml fetch + codegen + cargo — same as emitRustOnly. The cargo edge
// runs while build-cpp is still compiling on its own agent; by the time
// ninja reaches the link edge, ci.ts has already downloaded the archive.
const lolhtmlDep = resolveDep(n, cfg, lolhtml, new Map());
assert(lolhtmlDep !== null, "lolhtml resolveDep returned null — should never be skipped");
const codegen = emitCodegen(n, cfg, sources);
const rustObjects = emitRust(n, cfg, {
codegenInputs: codegen.rustInputs,
codegenOrderOnly: codegen.rustOrderOnly,
rustSources: sources.rust,
vendorStamps: lolhtmlDep.outputs,
});
// ─── C++ archive + dep libs (downloaded, not built) ───
// Paths computed exactly as emitLinkOnly does — must match cpp-only's
// output layout. ninja sees them as source inputs (no build rule).
const depLibs: string[] = [];
for (const dep of allDeps) {
depLibs.push(...computeDepLibs(cfg, dep));
}
const archive = resolve(cfg.buildDir, `${cfg.libPrefix}${exeName}${cfg.libSuffix}`);
// ─── Link ───
const flags = computeFlags(cfg);
n.comment("─── Link ───");
n.blank();
const windowsRes = cfg.windows ? [emitWindowsResources(n, cfg)] : [];
const shims = emitShims(n, cfg);
const linkObjects = [archive, ...rustLtoLinkInputs(n, cfg, rustObjects), ...windowsRes];
const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags];
const exe = link(n, cfg, exeName, linkObjects, {
libs: depLibs,
flags: ldflags,
implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs],
linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined,
});
const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags);
return {
exe,
strippedExe,
dsym,
deps: [lolhtmlDep],
codegen,
rustObjects,
objects: [],
};
}
/**
* Post-link steps shared by every linking mode (full, link-only,
* rust-and-link): strip, dsymutil, the `bun` phony, and the `--revision`
* smoke test.
*
* Centralized because the smoke_test and dsymutil edges must be ordered
* after strip — their rule commands wrap through `cfg.jsRuntime`
* (process.execPath), which can BE the strip output when `bun` on PATH
* resolves into the build directory (build/release/bun). Without the
* ordering, ninja runs strip and the wrapper exec concurrently (both
* depend only on `exe`) and the wrapper fails with "Permission denied" on
* the half-written file. Open-coding this in each mode already caused one
* call site to be missed (#30539), so the invariant lives here.
*/
export function emitPostLink(
n: Ninja,
cfg: Config,
exe: string,
exeName: string,
stripflags: string[],
): { strippedExe: string | undefined; dsym: string | undefined } {
// Plain release only: produce stripped `bun` alongside `bun-profile`.
// Debug/asan/valgrind/assertions keep symbols (you want them for
// debugging).
let strippedExe: string | undefined;
let dsym: string | undefined;
if (shouldStrip(cfg)) {
strippedExe = emitStrip(n, cfg, exe, stripflags);
// darwin: extract debug symbols from the UNSTRIPPED exe into a .dSYM
// bundle. dsymutil reads DWARF from bun-profile, writes
// bun-profile.dSYM. The input exe is never stripped in-place (strip
// writes a new file via -o), so the read is safe.
if (cfg.darwin) dsym = emitDsymutil(n, cfg, exe, exeName, strippedExe);
}
// `bun` phony — only when strip didn't produce a literal file named
// `bun` (which would collide with the phony). When strip runs, `ninja
// bun` builds the stripped file; no phony needed.
if (strippedExe === undefined) n.phony("bun", [exe]);
// Run `<exe> --revision`. If it exits non-zero or crashes, something
// broke at load time (missing symbol, static initializer blowup, ABI
// mismatch). Catching this HERE is much better than "CI passes, user
// runs bun, it segfaults".
//
// Linux+ASAN quirk: some systems need ASLR disabled (`setarch -R`) for
// ASAN binaries to run from subprocesses (shadow memory layout conflict
// with ELF_ET_DYN_BASE, see sanitizers/856). We try with setarch first,
// fall back to direct invocation.
emitSmokeTest(n, cfg, exe, exeName, strippedExe);
return { strippedExe, dsym };
}
/**
* Smoke test: run the built executable with --revision. If it crashes or
* errors, the build failed — typically means a link-time issue that the
* linker didn't catch (missing symbol only referenced at init, ICU ABI
* mismatch, etc.).
*
* `strippedExe` is the strip output (release builds only), added as an
* order-only input so this rule never runs while strip is mid-write; see
* emitPostLink for why.
*/
function emitSmokeTest(n: Ninja, cfg: Config, exe: string, exeName: string, strippedExe: string | undefined): void {
// Skip when the binary can't run on this host (different os/arch/abi) —
// `ninja check` becomes a no-op alias for the exe.
if (!cfg.canRunOnHost) {
n.phony("check", [exe]);
return;
}
const stamp = resolve(cfg.buildDir, `${exeName}.smoke-test-passed`);
// Linux+ASAN: wrap in `setarch <arch> -R` to disable ASLR. Fall back
// to direct invocation if setarch fails (not all systems have it).
// The `|| true` on the outer command isn't there — if BOTH fail, we
// want the rule to error.
const envWrap = "env BUN_DEBUG_QUIET_LOGS=1";
let testCmd: string;
if (cfg.linux && cfg.asan) {
const arch = cfg.x64 ? "x86_64" : "aarch64";
testCmd = `${envWrap} setarch ${arch} -R ${exe} --revision || ${envWrap} ${exe} --revision`;
} else if (cfg.windows) {
// Windows: no setarch, no env wrapper syntax differences matter for
// this simple case. cmd /c handles the pipe.
testCmd = `${exe} --revision`;
} else {
testCmd = `${envWrap} ${exe} --revision`;
}
// stream.ts --console: passthrough + ninja Windows buffering fix.
// sh -c with parens: testCmd may contain `||` (ASAN setarch fallback);
// without grouping, `a || b && touch` parses as `a || (b && touch)` —
// stamp wouldn't get written when setarch succeeds.
const q = (p: string) => quote(p, cfg.windows);
const wrap = `${cfg.jsRuntime} ${q(streamPath)} check --console`;
n.rule("smoke_test", {
command: cfg.windows
? `${wrap} cmd /c "${testCmd} && type nul > $out"`
: `${wrap} sh -c '( ${testCmd} ) && touch $out'`,
description: `${exeName} --revision`,
// pool = console: user wants to see the revision output.
pool: "console",
});
n.build({
outputs: [stamp],
rule: "smoke_test",
inputs: [exe],
...(strippedExe !== undefined ? { orderOnlyInputs: [strippedExe] } : {}),
});
// Phony target — `ninja check` runs the smoke test.
n.phony("check", [stamp]);
}
/**
* Strip the linked executable → plain `bun`. Returns absolute path to
* the stripped output.
*
* Input (bun-profile) is NOT modified — strip writes a new file via `-o`.
* The profile binary keeps its symbols for profiling/debugging release crashes.
*/
function emitStrip(n: Ninja, cfg: Config, inputExe: string, stripflags: string[]): string {
const out = resolve(cfg.buildDir, "bun" + cfg.exeSuffix);
// Windows: strip equivalent is handled at link time (/OPT:REF etc), no
// separate strip binary. The "stripped" bun is just a copy. Copy command
// follows the HOST shell (cmd natively, cp when cross-compiling).
if (cfg.windows) {
// Copy as-is. /OPT:REF already applied at link.
n.rule("strip", {
command: cfg.host.os === "windows" ? `cmd /c "copy /Y $in $out"` : `cp $in $out`,
description: "copy $out (windows: no strip)",
});
} else {
// Darwin cross: llvm-strip regenerates a bare linker-style ad-hoc
// signature on its output, dropping the entitlements the link step
// embedded — so the stripped `bun` needs its own postlink pass.
// (machoPostlinkCommand is "" everywhere else.)
n.rule("strip", {
command: `${quote(cfg.strip, false)} $stripflags $in -o $out${machoPostlinkCommand(cfg)}`,
description: "strip $out",
});
}
const node: BuildNode = {
outputs: [out],
rule: "strip",
inputs: [inputExe],
vars: cfg.windows ? {} : { stripflags: stripflags.join(" ") },
};
const postlinkInputs = machoPostlinkImplicitInputs(cfg);
if (postlinkInputs.length > 0) node.implicitInputs = postlinkInputs;
n.build(node);
return out;
}
/**
* Extract debug symbols from the linked (unstripped) executable into a
* .dSYM bundle. darwin-only.
*
* Runs dsymutil on bun-profile (which has full DWARF). The .dSYM lets you
* symbolicate crash logs from the stripped `bun` — lldb/Instruments find
* it automatically by UUID.
*
* `strippedExe` is order-only for the same reason as emitSmokeTest: the
* `cfg.jsRuntime` wrapper may be the strip output itself.
*/
function emitDsymutil(n: Ninja, cfg: Config, inputExe: string, exeName: string, strippedExe: string): string {
assert(cfg.darwin, "dsymutil is darwin-only");
assert(cfg.dsymutil !== undefined, "dsymutil not found in toolchain");
const out = resolve(cfg.buildDir, `${exeName}.dSYM`);
// --flat: single-file .dSYM (not a bundle directory). Simpler to upload
// as a CI artifact.
// --keep-function-for-static: keep symbols for static functions (more
// complete backtraces).
// --object-prefix-map: rewrite DWARF path prefixes so debuggers find
// source in the repo root rather than the build machine's absolute path.
// -j: parallelism. Use all cores (dsymutil parallelizes per compile unit).
// CMake uses CMAKE_BUILD_PARALLEL_LEVEL; we use the host's core-count
// command via a subshell (sysctl on a darwin host, nproc when
// cross-compiling from linux).
// stream.ts --console for pool:console consistency (no-op on darwin).
const q = (p: string) => quote(p, false); // darwin/linux host → posix
const ncpu = cfg.host.os === "linux" ? "nproc" : "sysctl -n hw.ncpu";
const wrap = `${cfg.jsRuntime} ${q(streamPath)} dsym --console`;
n.rule("dsymutil", {
command: `${wrap} sh -c '${cfg.dsymutil} $in --flat --keep-function-for-static --object-prefix-map .=${cfg.cwd} -o $out -j $$(${ncpu})'`,
description: "dsymutil $out",
// Not restat — dsymutil always writes.
pool: "console", // Can take a while, show progress
});
n.build({
outputs: [out],
rule: "dsymutil",
inputs: [inputExe],
orderOnlyInputs: [strippedExe],
});
return out;
}
// ───────────────────────────────────────────────────────────────────────────
// Windows resources (.rc → .res)
// ───────────────────────────────────────────────────────────────────────────
/**
* Template-substitute windows-app-info.rc and compile it with llvm-rc.
* Returns the path to the .res output (to be linked like an object file).
*
* The .rc file provides:
* - Icon (bun.ico)
* - VS_VERSION_INFO resource (ProductName, FileVersion, CompanyName, ...)
* - The application manifest (longPathAware + SegmentHeap) as an
* RT_MANIFEST resource. Embedding it here instead of via the linker's
* /MANIFEST:EMBED keeps the link independent of the linker's manifest
* tooling: lld-link only handles /MANIFEST:EMBED itself when built with
* libxml2 and otherwise shells out to mt.exe — rustc's bundled lld-link
* (used for the cross-language-LTO links) has neither, and mt.exe does
* not exist on non-Windows hosts. The resource route produces the same
* RT_MANIFEST id-1 resource with any linker.
*
* This resource section is what rescle's ResourceUpdater modifies when
* `bun build --compile --windows-title ...` runs. Without it, the copied
* bun.exe has no VersionInfo to update and rescle silently does nothing.
*/
function emitWindowsResources(n: Ninja, cfg: Config): string {
assert(cfg.windows, "emitWindowsResources is windows-only");
assert(cfg.rc !== undefined, "llvm-rc not found in toolchain");
// ─── Template substitution (configure time) ───
// The .rc uses @VAR@ cmake-style placeholders. Substitute and write to
// buildDir (not codegenDir — link-only doesn't create codegenDir).
// writeIfChanged → mtime preserved → no spurious rc rebuild when the
// substituted content hasn't changed.
const rcTemplate = resolve(cfg.cwd, "src/windows-app-info.rc");
const ico = resolve(cfg.cwd, "src/bun.ico");
const manifest = resolve(cfg.cwd, "src/bun.exe.manifest");
const rcIn = readFileSync(rcTemplate, "utf8");
const [major = "0", minor = "0", patch = "0"] = cfg.version.split(".");
const versionWithTag = cfg.canary ? `${cfg.version}-canary.${cfg.canaryRevision}` : cfg.version;
// slash(): rc parses .rc as C-like source; backslashes in the ICON path
// string would need escaping. Forward slashes work for Windows file APIs.
const rcOut = rcIn
.replace(/@Bun_VERSION_MAJOR@/g, major)
.replace(/@Bun_VERSION_MINOR@/g, minor)
.replace(/@Bun_VERSION_PATCH@/g, patch)
.replace(/@Bun_VERSION_WITH_TAG@/g, versionWithTag)
.replace(/@BUN_ICO_PATH@/g, slash(ico))
.replace(/@BUN_MANIFEST_PATH@/g, slash(manifest));
const rcFile = resolve(cfg.buildDir, "windows-app-info.rc");
writeIfChanged(rcFile, rcOut);
// ─── Compile .rc → .res (ninja time) ───
// llvm-rc: /FO sets output. `#include "windows.h"` in the .rc resolves
// via the INCLUDE env var set by the VS dev shell (vs-shell.ps1) on a
// Windows host; when cross-compiling there is no dev shell, so the SDK
// and MSVC include dirs from the winsysroot are passed explicitly.
const hostWin = cfg.host.os === "windows";
const rcFlags: string[] = [];
if (cfg.winsysroot !== undefined) {
const includeDirs = windowsSysrootIncludeDirs(cfg.winsysroot);
// The include dirs are baked into the rc edge at configure time, so the
// sysroot must already be populated (configure.ts fetches it in CI
// before emitBun). An empty set would only surface later as a cryptic
// llvm-rc "windows.h not found" — fail here with the real cause instead.
assert(
includeDirs.length > 0,
`Windows sysroot at ${cfg.winsysroot} has no MSVC/SDK include dirs — is it a complete xwin splat?`,
);
for (const dir of includeDirs) {
rcFlags.push("/I", quote(dir, hostWin));
}
}
const resFile = resolve(cfg.buildDir, "windows-app-info.res");
n.rule("rc", {
command: `${quote(cfg.rc, hostWin)} $rcflags /FO $out $in`,
description: "rc $out",
});
n.build({
outputs: [resFile],
rule: "rc",
inputs: [rcFile],
// .ico and the manifest are embedded by rc at compile time — rebuild if
// they change. The template is NOT tracked here: it's substituted at
// configure time, so template edits need a reconfigure (happens rarely).
implicitInputs: [ico, manifest],
vars: { rcflags: rcFlags.join(" ") },
});
return resFile;
}
/**
* Include dirs inside an xwin-style Windows sysroot, for tools that don't
* understand `/winsysroot` themselves (llvm-rc). Layout:
* <root>/VC/Tools/MSVC/<ver>/include
* <root>/Windows Kits/10/Include/<sdkver>/{ucrt,shared,um}
* The SDK "Include" dir is title-case in a real VS/SDK copy and lowercase
* in an xwin winsysroot-style splat — accept either.
*/
function windowsSysrootIncludeDirs(winsysroot: string): string[] {
const dirs: string[] = [];
const msvcRoot = resolve(winsysroot, "VC", "Tools", "MSVC");
if (existsSync(msvcRoot)) {
for (const ver of readdirSync(msvcRoot)) {
const d = resolve(msvcRoot, ver, "include");
if (existsSync(d)) dirs.push(d);
}
}
const sdkRoot = resolve(winsysroot, "Windows Kits", "10");
const sdkInclude = ["Include", "include"].map(name => resolve(sdkRoot, name)).find(existsSync);
if (sdkInclude !== undefined) {
for (const ver of readdirSync(sdkInclude)) {
for (const sub of ["ucrt", "shared", "um"]) {
const d = resolve(sdkInclude, ver, sub);
if (existsSync(d)) dirs.push(d);
}
}
}
return dirs;
}
/**
* Files the linker reads via ldflags that ninja should track for relinking
* (symbol lists, linker script). CMake's LINK_DEPENDS equivalent.
* (The Windows manifest is no longer a link input — it's embedded by the
* resource compiler; see emitWindowsResources.)
*/
function linkImplicitInputs(cfg: Config): string[] {
return linkDepends(cfg);
}
// ───────────────────────────────────────────────────────────────────────────
// Pre-flight checks
// ───────────────────────────────────────────────────────────────────────────
/**
* Validate config before emitting. Catches obvious problems at configure
* time instead of cryptic build failures later.
*/
export function validateBunConfig(cfg: Config): void {
// build.ninja encodes both absolute -I/-D paths derived from cfg.cwd and
// buildDir-relative source paths (../../src/...). If buildDir is reached
// through a symlink that escapes this checkout — e.g. a sibling worktree
// symlinking its build/ at ours to "share" artifacts — a configure from
// that worktree overwrites our build.ninja with its own absolute paths
// while the relative ones still resolve against whichever cwd ninja is
// launched from. The result is the same header included via two distinct
// realpaths, defeating #pragma once and producing redefinition errors (or
// PCH macro mismatches) the next time the rightful owner builds. Refuse
// up front so the misconfigured worktree fails loudly instead of poisoning
// a neighbour. An explicit --build-dir pointing outside the repo is still
// permitted; only a symlink masquerading as a path under cwd is rejected.
if (existsSync(cfg.buildDir)) {
const realCwd = realpathSync(cfg.cwd);
const realBuild = realpathSync(cfg.buildDir);
const rel = relative(realCwd, realBuild);
const escapes = rel.startsWith("..") || rel === "";
const claimedRel = relative(cfg.cwd, cfg.buildDir);
const claimsInside = !claimedRel.startsWith("..") && claimedRel !== "";
assert(
!(claimsInside && escapes),
`buildDir '${cfg.buildDir}' resolves to '${realBuild}', outside the source tree '${realCwd}'.\n` +
`A symlinked build/ shared between worktrees corrupts build.ninja for both. ` +
`Remove the symlink and let this worktree own its build directory ` +
`(ccache already shares object files across checkouts).`,
);
}
// Also reject the common shape directly: <cwd>/build as a symlink. This
// catches the race before the first configure ever creates buildDir.
const buildParent = resolve(cfg.cwd, "build");
if (cfg.buildDir.startsWith(buildParent + sep) && existsSync(buildParent)) {
assert(
!lstatSync(buildParent).isSymbolicLink(),
`'${buildParent}' is a symlink (→ ${realpathSync(buildParent)}). ` +
`Sharing build/ between worktrees corrupts build.ninja for both — ` +
`remove the symlink; ccache already shares compiled objects.`,
);
}
// Cross-language LTO needs an lld at least as new as the LLVM that emitted
// the rust bitcode. `resolveConfig()` swaps `cfg.ld` to `cfg.rustLld` when
// rustc's LLVM is newer than clang's; if `rustLld` couldn't be discovered
// (rustc/rustup missing, pinned toolchain not installed, agent provisioned
// without it), the build would proceed with the stale lld and fail at link
// time with an opaque `error: ... .rcgu.o: Invalid record`. Fail at
// configure time instead with a hint that points at the real problem.
if (
cfg.crossLangLto &&
cfg.rustToolchain !== undefined &&
cfg.rustLlvmVersion !== undefined &&
cfg.clangVersion !== undefined
) {
const rustMajor = Number.parseInt(cfg.rustLlvmVersion.split(".")[0] ?? "", 10);
const clangMajor = Number.parseInt(cfg.clangVersion.split(".")[0] ?? "", 10);
if (Number.isFinite(rustMajor) && Number.isFinite(clangMajor) && rustMajor > clangMajor) {
// `cfg.ld` must be one of rustc's bundled lld flavors. On ELF targets
// it's `cfg.rustLld` exactly; on darwin/windows cross targets it's the
// ld64.lld / lld-link sibling from the same gcc-ld/ directory.
assert(
cfg.rustLld !== undefined && (cfg.ld === cfg.rustLld || dirname(cfg.ld) === dirname(cfg.rustLld)),
`Cross-language LTO is on and rustc's LLVM (${cfg.rustLlvmVersion}) is newer than clang's ` +
`(${cfg.clangVersion}), but rustc's bundled lld wasn't found — the link would fail with ` +
`"Invalid record" reading libbun_rust.a's bitcode. Install the pinned toolchain on this ` +
`host (\`rustup toolchain install ${cfg.rustToolchain}\`), upgrade clang/lld to LLVM ` +
`${rustMajor}+, or disable LTO with \`--lto=off\`.`,
);
}
}
// --local-deps names must match a dep — a typo would otherwise silently
// build the pinned tarball while the banner claims `local:<typo>`.
const depsByName = new Map(allDeps.map(d => [d.name, d]));
for (const [name, path] of Object.entries(cfg.localDeps)) {
const dep = depsByName.get(name);
assert(dep !== undefined, `--local-deps: unknown dep '${name}'`, {
hint: `Known deps: ${[...depsByName.keys()].sort().join(", ")}`,
});
assert(
!dep.enabled || dep.enabled(cfg),
`--local-deps: ${name} is disabled for ${cfg.os}-${cfg.arch}${cfg.abi ? `-${cfg.abi}` : ""} in this configuration, so the checkout at ${path} would never be built`,
{ hint: `Drop ${name} from --local-deps, or build a target/config where its \`enabled\` predicate holds` },
);
// A dep the graph fetches but never reads (no build step, no sources, no
// includes — lolhtml, which cargo consumes through the workspace
// Cargo.toml's `path = "vendor/lolhtml"`) can't be redirected from here.
const provides = dep.provides(cfg);
assert(
dep.build(cfg).kind !== "none" || (provides.sources ?? []).length > 0 || provides.includes.length > 0,
`--local-deps: ${name} is only fetched by the build graph, never compiled or included by it, so redirecting it to ${path} would change nothing`,
{
hint: `Point ${name}'s real consumer at the checkout instead (for a cargo path dependency: the workspace Cargo.toml)`,
},
);
}
}