Files

1004 lines
52 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
/**
* Rust build step — cargo as a ninja edge.
*
* The Rust port lives in the workspace rooted at the repo's `Cargo.toml`;
* the leaf crate is `src/bun_bin` (`crate-type = ["staticlib"]`). One
* `cargo build -p bun_bin` produces `libbun_rust.a` containing the entire
* Rust crate graph plus libstd, with `main` exported `#[no_mangle] extern "C"`.
*
* Cargo's own incremental compilation handles per-file tracking; our ninja
* rule just invokes it and declares the output. `restat` lets cargo's no-op
* prune the downstream link when nothing changed.
*
* ## Why an `.a` and not a single `.o`
*
* A single `.o` would need either full LTO (`-C lto=fat --emit=obj`, which
* recompiles the whole crate graph from bitcode every build — minutes in
* debug) or an `ld -r --whole-archive` post-merge (extra platform-specific
* step). The staticlib goes into the link's `$in` list between the C++
* objects and the dependency archives;
* crt1.o's undefined `main` plus the C++ side's hundreds of `extern "C"`
* `Bun__*`/`Zig*` references pull every reachable member, and the release
* link's `--gc-sections` still DCEs per-function. `rustLinkFlags()` wraps
* the archive in `--whole-archive` so members that are *only* referenced via
* the dynamic-list / NAPI surface (no inbound static ref) are retained too.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { bunExeName, type Abi, type Arch, type Config, type OS } from "./config.ts";
import { assert } from "./error.ts";
import { computeCpuTargetFlags } from "./flags.ts";
import type { Ninja } from "./ninja.ts";
import { rustLtoFixCliPath } from "./rust-lto-fix-cli.ts";
import { quote, quoteArgs } from "./shell.ts";
import { streamPath } from "./stream.ts";
// ───────────────────────────────────────────────────────────────────────────
// Target / profile mapping
// ───────────────────────────────────────────────────────────────────────────
/**
* Rust target triple. Arch is `x86_64`/`aarch64`, not `x64`/`arm64`.
*
* Passed explicitly via `--target` for two reasons:
* - `-Z sanitizer=address` requires it (rustc refuses on the implicit
* host triple)
* - Cross-compiles (Android/FreeBSD) need it anyway
*/
export function rustTarget(cfg: Config): string {
return rustTriple(cfg.os, cfg.arch, cfg.abi);
}
/** `rustTarget()` on the bare target platform; `abi` is linux-only. */
export function rustTriple(os: OS, arch: Arch, abi: Abi | undefined): string {
const rustArch = arch === "x64" ? "x86_64" : "aarch64";
if (os === "darwin") return `${rustArch}-apple-darwin`;
if (os === "windows") return `${rustArch}-pc-windows-msvc`;
if (os === "freebsd") return `${rustArch}-unknown-freebsd`;
if (os === "haiku") return `${rustArch}-unknown-haiku`;
// linux
assert(abi !== undefined, "linux build missing abi");
if (abi === "android") return `${rustArch}-linux-android`;
if (abi === "musl") return `${rustArch}-unknown-linux-musl`;
return `${rustArch}-unknown-linux-gnu`;
}
/**
* Cargo profile + the subdirectory it writes into under `--target-dir`.
* `dev` writes to `debug/`, every other profile name writes to `<name>/`.
*
* `cfg.asan` does NOT change the profile (it changes rustflags); a debug-asan
* build still uses `dev`. RelWithDebInfo / MinSizeRel collapse to `release` —
* cargo's stock release already keeps debuginfo (`debug = 1` is the workspace
* default), and we don't ship a `MinSizeRel` Rust path yet.
*/
export function cargoProfile(cfg: Config): { name: string; subdir: string } {
return cfg.buildType === "Debug" ? { name: "dev", subdir: "debug" } : { name: "release", subdir: "release" };
}
/**
* All target triples CI builds (`buildPlatforms` in .buildkite/ci.mjs, one
* triple per os/arch/abi; test/internal/source-lints/build-rust.test.ts keeps
* the two in sync). Drives `rust:check-all` and the generated
* `.cargo/config.toml` (cargo-config.ts). `rust-toolchain.toml`'s `targets`
* is this list minus the Tier 3 triples.
*/
export const allRustTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-linux-android",
"aarch64-linux-android",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-freebsd",
"aarch64-unknown-freebsd",
"x86_64-unknown-haiku",
] as const;
/**
* Tier 3 targets — rustup ships no prebuilt `rust-std` for these, so
* `rustup target add` would fail and cargo needs `-Zbuild-std` (which in turn
* needs the `rust-src` component). As of nightly-2026-05, the only Tier 3
* triple in CI's matrix is aarch64-freebsd.
*/
export function rustTargetIsTier3(triple: string): boolean {
return triple === "aarch64-unknown-freebsd" || triple === "x86_64-unknown-haiku";
}
/**
* Build std/core/alloc from source instead of linking the rustup prebuilt.
* The workspace is `panic = "abort"` (see Cargo.toml). `proc_macro` is
* needed because `cargo build --target` still resolves proc-macro crates for
* the host through the same `-Zbuild-std` flag set. Requires the `rust-src`
* component, which `rust-toolchain.toml` requests and CI images preinstall
* (Dockerfile / bootstrap.sh `rustup component add rust-src`). Shared with
* `rust:check-all`, which needs it for the Tier 3 triples.
*/
export const cargoBuildStdArg = "-Zbuild-std=core,alloc,std,proc_macro,panic_abort";
/**
* The C++ side's `cpuTargetFlags` (flags.ts) spelled as rustflags, derived
* from that table so the two halves of the binary can't drift apart. They
* have to agree: the Rust half runs on whatever CPU the C++ baseline admits,
* and under cross-language LTO (`cfg.crossLangLto`) LLVM only inlines a call
* when the callee's CPU feature set is a subset of the caller's (a CPU's own
* tuning features and the tune CPU count too), so a mismatch turns off
* inlining across the Rust/C++ boundary in both directions.
*
* -mcpu=X → -Ctarget-cpu=X (both take LLVM CPU names)
* -mtune=X → -Ztune-cpu=X (nightly-only, like the other -Z flags here)
* -march=X → x64: -Ctarget-cpu=X, since x86 -march values are CPU names
* arm64: -Ctarget-cpu=generic -Ctarget-feature=+ext,...
*
* The arm64 `-march` value is an architecture level plus extensions
* (`armv8-a+crc`), which clang itself lowers to LLVM's `generic` aarch64 CPU
* plus the extensions as features (`clang -### ...` shows `-target-cpu
* generic`); rustc's target features use the same names. This used to name a
* real CPU instead (cortex-a72), which also assumed aes, sha2 and pmuv3
* (`rustc --print cfg`), none of which the C++ side does.
*
* clang-cl (windows) spells the same flags `/clang:-march=...`.
*/
function rustCpuTargetFlags(cfg: Config): string[] {
const rustflags: string[] = [];
for (const clangFlag of computeCpuTargetFlags(cfg)) {
const parsed = /^(?:\/clang:)?-m(cpu|tune|arch)=(.+)$/.exec(clangFlag);
assert(parsed !== null, `rustCpuTargetFlags() can't translate cpuTargetFlags entry '${clangFlag}'`);
const kind = parsed[1]!;
const value = parsed[2]!;
if (kind === "tune") {
rustflags.push(`-Ztune-cpu=${value}`);
} else if (kind === "cpu" || cfg.x64) {
rustflags.push(`-Ctarget-cpu=${value}`);
} else {
const [level, ...extensions] = value.split("+");
assert(level === "armv8-a", `rustCpuTargetFlags() only knows how to spell -march=armv8-a, not -march=${value}`);
rustflags.push("-Ctarget-cpu=generic");
if (extensions.length > 0) rustflags.push(`-Ctarget-feature=${extensions.map(ext => `+${ext}`).join(",")}`);
}
}
return rustflags;
}
/**
* Absolute source-tree path the Windows .bin/ shim PE is copied to, where
* `bun_install`'s `include_bytes!("bun_shim_impl.exe")` reads it from. The
* build product lands in `rust-target/<triple>/shim/`; it's copied here so
* the embed path is a fixed relative-to-source string (no env-var plumbing).
* Git-ignored; `src/install/build.rs` creates a 0-byte placeholder for bare
* `cargo check` so the embed never sees ENOENT.
*/
function windowsShimDestPath(cfg: Config): string {
return resolve(cfg.cwd, "src", "install", "windows-shim", "bun_shim_impl.exe");
}
/**
* Path to the `rustup` binary that owns `cfg.cargo`, or `undefined` if
* `cfg.cargo` isn't a rustup proxy (a distro/Homebrew cargo, say).
* `rustup target add` is only meaningful when rustup is the toolchain
* manager — `rust_build_cross` requires it; everyone else gets `rust_build`.
*/
function findRustup(cfg: Config): string | undefined {
if (cfg.cargo === undefined) return undefined;
const rustup = join(dirname(cfg.cargo), `rustup${cfg.host.exeSuffix}`);
return existsSync(rustup) ? rustup : undefined;
}
// ───────────────────────────────────────────────────────────────────────────
// Paths
// ───────────────────────────────────────────────────────────────────────────
/** `<buildDir>/rust-target` — sibling of `obj/`, `pch/`. */
function rustTargetDir(cfg: Config): string {
return resolve(cfg.buildDir, "rust-target");
}
/**
* Absolute path to `libbun_rust.a` (or `bun_rust.lib` on Windows).
*
* `--target` is always passed, so cargo's output layout is
* `<target-dir>/<triple>/<profile>/<libPrefix>bun_rust<libSuffix>`.
*/
export function rustLibPath(cfg: Config): string {
const { subdir } = cargoProfile(cfg);
return resolve(rustTargetDir(cfg), rustTarget(cfg), subdir, `${cfg.libPrefix}bun_rust${cfg.libSuffix}`);
}
// ───────────────────────────────────────────────────────────────────────────
// Ninja rules
// ───────────────────────────────────────────────────────────────────────────
export function registerRustRules(n: Ninja, cfg: Config): void {
const hostWin = cfg.host.os === "windows";
const q = (p: string) => quote(p, hostWin);
// Regular-LTO summary fix-up for the ELF cross-language LTO link (see
// rustLtoLinkInputs() below). Registered before the cargo gate: the
// link-only CI agents emit this edge too, and it needs rustc's
// llvm-tools, not cargo. Not darwin/windows: their ThinLTO links keep the
// per-CGU summaries (CARGO_PROFILE_RELEASE_LTO=off) and need no fix-up.
if (cfg.crossLangLto && !cfg.darwin && !cfg.windows) {
n.rule("rust_lto_fix", {
command: `${cfg.jsRuntime} ${q(rustLtoFixCliPath)} $in $out $llvm_bin $ar`,
description: "regular-LTO summary → $out",
});
}
if (cfg.cargo === undefined) return; // emitRust() asserts with a hint
const stream = `${cfg.jsRuntime} ${q(streamPath)} rust`;
// Cargo build for `bun_bin`. Runs from repo root (workspace `Cargo.toml`
// lives there). Env passed via stream.ts `--env=K=V`.
//
// `--console`: cargo has its own progress bar / colour; pool=console gives
// it the TTY directly. restat: cargo's incremental build doesn't touch
// the staticlib when nothing changed.
n.rule("rust_build", {
command: `${stream} --console --cwd=$cwd $env ${q(cfg.cargo)} build $args`,
description: "cargo bun_bin → $label",
pool: "console",
restat: true,
});
// Variant that ensures the pinned toolchain (and `rust-std` for
// `$rust_target` when it has a prebuilt one) is fully installed before
// building. CI agents pin the toolchain via `RUSTUP_TOOLCHAIN`, which
// bypasses `rust-toolchain.toml`'s `targets`/`components` install list, and
// rustup-proxy auto-install can leave a *partial* toolchain dir (rustc/cargo
// present, no `rust-std`, no `lib/rustlib/multirust-channel-manifest.toml`).
// That surfaces as either `error[E0463]: can't find crate for core` (cargo
// ran, no std) or `error: Missing manifest in toolchain '<channel>-<host>'`
// (rustup-proxy refused to even resolve cargo). `rustup toolchain install`
// repairs both, and is an offline ~70ms no-op when the toolchain is already
// complete. No `--force`: that means "update even if the manifest lacks a
// component", which re-fetches the channel manifest on every build.
//
// `$rust_target_arg` is `--target <triple>` for Tier 1/2 (also installs the
// prebuilt `rust-std-<triple>`), and empty for Tier 3 (no prebuilt; cargo
// gets `-Zbuild-std` instead — see emitRust). Both still get `rust-src`
// (needed for `-Zbuild-std`).
//
// Only registered when `cfg.rustToolchain` is pinned and `cfg.cargo` is a
// rustup proxy — otherwise there's no channel to install / no `rustup` to
// call, and toolchain management is the user's problem.
// `--console` on the rustup step too: it's sequential with cargo (both
// sides of `&&`) and the rule already takes the console pool, so there's
// no interleaving risk — and `--console` inherits stdio directly, which
// matters because stream.ts's pipe path can drop a fast-failing child's
// output (it `process.exit()`s on `close` before the async stdout writes
// drain). Without it, a failed `toolchain install` shows no error at all.
//
// No `--profile minimal`: the agent already has the default profile, and
// rustup applies `--profile` to the install spec, not just first-install —
// requesting a *narrower* profile on a reinstall is asking for
// trouble. We only care that `rust-src` and `rust-std-<triple>` exist on
// top of whatever profile is there.
//
// Windows: ninja spawns commands via CreateProcess directly (no shell), so
// `&&` would be passed as a literal argument to the first node.exe — rustup
// then sees the second half of the chain as extra argv and rejects
// `--experimental-strip-types`. Wrap in `cmd /c "..."` so cmd.exe handles
// the chain (cmd's quote-stripping rule removes only the outermost pair,
// preserving the embedded `"..."` around paths/env values). Same pattern as
// codegen.ts / bun.ts.
// Windows .bin/ shim PE: cargo build → copy into the source tree for
// `include_bytes!`. One rule does both; cargo's own output path and the
// source-tree copy are undeclared side effects (see below for what $out is).
//
// Copy is *content-conditional* (`fc /b` / `cmp -s` returns 0 iff bytes
// match): any `.rs` edit re-invokes this rule (it shares `rustSources`
// with the main build), cargo no-ops, and a blind copy would still bump
// the destination's mtime → `bun_install`'s `include_bytes!` dep-info sees
// a change → spurious recompile of `bun_install` + downstream on every
// build. Skipping the copy when bytes match keeps its mtime stable.
//
// The declared output ($out) is a per-build-dir stamp, NOT the source-tree
// exe: the exe path is shared by every windows arch/profile (the
// `include_bytes!` path is fixed), so if it were the output, building x64
// then arm64 in sibling build dirs would leave the arm64 dir believing the
// (x64) exe is up to date and embed the wrong-arch shim. With the stamp as
// output and the shared exe as an implicit *input*, a sibling build dir
// overwriting the exe makes this dir's stamp stale → the shim is rebuilt
// for the right arch on the next build here.
//
// Registered for windows *targets* only; the shell dialect follows the
// HOST (cmd.exe natively, sh when cross-compiling from linux/macOS).
if (cfg.windows) {
n.rule("rust_shim", {
command: hostWin
? `cmd /c "${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` +
`( fc /b $shim_src $shim_dest >nul 2>&1 || copy /Y /B $shim_src $shim_dest >nul ) && type nul > $out"`
: `${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` +
`( cmp -s $shim_src $shim_dest 2>/dev/null || cp $shim_src $shim_dest ) && touch $out`,
description: "cargo bun_shim_impl → $shim_dest",
pool: "console",
// No restat: the stamp ($out) is touched unconditionally, so there's
// nothing for ninja to prune on; the content-conditional copy above
// exists for cargo's dep-info on $shim_dest, not for restat.
});
}
const rustup = findRustup(cfg);
if (rustup !== undefined && cfg.rustToolchain !== undefined) {
// `-q` + `--no-self-update` silence the five `info:` lines rustup prints
// on every no-op reinstall; warnings/errors still show.
const chain =
`${stream} --console $env ${q(rustup)} -q toolchain install ${cfg.rustToolchain} --no-self-update --component rust-src $rust_target_arg && ` +
`${stream} --console --cwd=$cwd $env ${q(cfg.cargo)} build $args`;
n.rule("rust_build_cross", {
command: hostWin ? `cmd /c "${chain}"` : chain,
description: "cargo bun_bin → $label ($rust_target_arg)",
pool: "console",
restat: true,
});
}
}
// ───────────────────────────────────────────────────────────────────────────
// Rust build emission
// ───────────────────────────────────────────────────────────────────────────
/**
* Inputs to the cargo build step. Assembled by the caller from
* emitted codegen outputs + globbed `*.rs` sources.
*/
export interface RustBuildInputs {
/**
* Generated files Rust `include!`s / `include_bytes!`s — content tracked.
* The `.rs` files (`generated_classes.rs` etc.) are undeclared side
* effects of the same scripts that produce `CodegenOutputs.rustInputs`, so
* passing that set here is sufficient to order codegen before cargo.
*/
codegenInputs: string[];
/**
* Generated files Rust needs to EXIST but doesn't embed (debug-mode bake
* runtime, runtime-loaded modules). Order-only.
*/
codegenOrderOnly: string[];
/**
* All `*.rs` source files + workspace `Cargo.toml`/`Cargo.lock` (globbed
* at configure time). Implicit inputs for ninja's staleness check —
* cargo discovers sources itself; this is just so ninja knows when to
* re-invoke.
*/
rustSources: string[];
/**
* Fetch stamps for vendored Rust crates the workspace consumes as path
* dependencies (currently lol-html). Implicit inputs so cargo never runs
* before the source tree exists, and so a commit bump re-invokes cargo.
*/
vendorStamps: string[];
}
/**
* The exact `cargo build` invocation the Rust step uses.
*
* Extracted so tooling (`scripts/rust-timings.ts`) can run cargo with the same
* args/rustflags/env that `emitRust()` puts into the ninja edge, without
* re-deriving any of it. `emitRust()` is the only build-graph caller.
*/
export interface CargoInvocation {
/** `cargo build <args>` — everything after `build`. */
args: string[];
/** Env vars the cargo process runs under. `CARGO_ENCODED_RUSTFLAGS` included. */
env: Record<string, string>;
/** `--target-dir` absolute path (also present in `args`). */
targetDir: string;
/** `--target` triple (also present in `args`). */
triple: string;
}
/**
* Compute the cargo command line + environment for `cargo build -p bun_bin`.
* Pure function of `cfg`; does no I/O.
*/
export function cargoBuildInvocation(cfg: Config): CargoInvocation {
const targetDir = rustTargetDir(cfg);
const triple = rustTarget(cfg);
const tier3 = rustTargetIsTier3(triple);
const profile = cargoProfile(cfg);
// ─── Build args ───
const args: string[] = [
"-p",
"bun_bin",
"--lib",
"--target-dir",
targetDir,
"--target",
triple,
"--profile",
profile.name,
"--locked",
];
if (tier3 || cfg.release || cfg.asan) {
// Rebuild std from source (cargoBuildStdArg) because:
// tier3: no prebuilt `rust-std` exists.
// release: prebuilt std is native code built for generic x86-64 with no
// `.llvm_addrsig`. Rebuilding with our RUSTFLAGS gets it
// `-Ctarget-cpu=` (AVX2/BMI in core::str / hashbrown), and under
// `cfg.lto` it becomes bitcode that joins the cross-language LTO
// unit + safe ICF instead of being an opaque blob in the link.
// asan: prebuilt std is uninstrumented; rebuilding applies
// `-Zsanitizer=address` so OOB/UAF inside Vec/String/HashMap are
// visible instead of stopping at the std boundary.
args.push(cargoBuildStdArg);
if (cfg.release && !cfg.asan) {
// Cargo's default build-std feature set is `panic-unwind,backtrace,default`.
// `backtrace` links std's symbolizer (gimli, addr2line, miniz_oxide,
// rustc-demangle, ~200 KB on linux-x64) for `std::backtrace` and the
// default panic hook; bun installs its own panic hook and symbolizes
// crash traces out of process, so nothing reads it.
args.push("-Zbuild-std-features=panic-unwind,default");
}
}
// ─── rustflags ───
// CARGO_ENCODED_RUSTFLAGS: U+001F-separated so multi-arg flags survive.
const rustflags: string[] = [];
// Match the C/C++ side's `-fno-pic` / `-Wl,-no-pie` (flags.ts:929,1001) on
// the targets where bun links as a position-dependent ET_EXEC. With the
// default `pic`, every Rust `&'static [T]` / `&'static str` / vtable is a
// GOT-relative reference and the constant ends up in `.data.rel.ro` (RW
// segment, eagerly faulted) instead of `.rodata`; libbun_rust.a alone
// contributes ~561 KiB of `.data.rel.ro` that the Zig binary placed in
// shareable read-only pages. `static` lets rustc emit absolute references
// and the constants land in `.rodata`. This is a *target* RUSTFLAG: with
// `--target` set, cargo does NOT apply it to host artifacts (proc-macro
// dylibs / build scripts), so those still build PIC. Darwin (Mach-O is
// always PIC), Android (bionic loader requires PIE — flags.ts:934), and
// Windows (COFF has its own model) are excluded.
if ((cfg.linux && cfg.abi !== "android") || cfg.freebsd) {
rustflags.push("-Crelocation-model=static");
}
// Keep frame pointers — matches the C++ side's `-fno-omit-frame-pointer` / `/Oy-`
// (flags.ts:293-301). Needed so profilers and crash backtraces can walk Rust frames.
rustflags.push("-Cforce-frame-pointers=yes");
// Parallel frontend: rustc's default is single-threaded for parse / macro
// expansion / typeck / borrowck, so the critical-path crate (`bun_runtime`)
// sits on one core while the rest idle. With this, independent compiler
// queries run on a rayon pool and the long pole roughly halves. The pool
// shares cargo's jobserver, so N rustcs × 8 doesn't oversubscribe — each
// thread acquires a `-j` token before doing work.
//
// Why 8, not nproc: returns flatten past ~8 (the query DAG has its own
// serial spine — macro expansion in particular), and `-Zthreads=0` (= nproc)
// measured marginally *worse* on a 32-core box from sharded-lock contention.
// 8 is also the upstream proposal for the eventual default
// (rust-lang/compiler-team#681).
//
// Local-only: CI/release builds want byte-identical output across runs, and
// the parallel frontend can reorder diagnostics (and is still nightly
// `-Z`-gated). The shipped binaries stay on the serial path.
if (!cfg.ci) {
rustflags.push("-Zthreads=8");
}
// rustc does not emit `.llvm_addrsig` by default on *any* target (verified
// empirically — Linux-gnu, musl, darwin, msvc all missing it). lld's
// `--icf=safe` (flags.ts:960) and lld-link's `/OPT:SAFEICF` (flags.ts:778)
// need the table to know which functions are safe to fold; without it every
// Rust monomorphization is treated as address-taken and *none* fold
// (#53159: 33,162 extra `.pdata` entries vs Zig main on Windows, all from
// Rust functions). C++ already emits it via `-faddrsig` (flags.ts:350).
// `-Cllvm-args=-addrsig` sets the same LLVM module flag clang's `-faddrsig`
// does. Harmless on Apple ld64 (ignores the section).
rustflags.push("-Cllvm-args=-addrsig");
// Reuse an upstream crate's monomorphization instead of re-instantiating
// it locally. rustc defaults this on only at opt-level 0/1/s/z: at O2/O3 a
// shared generic is an out-of-line upstream symbol the caller can't
// inline. Cross-language ThinLTO re-imports and inlines any callee under
// the import threshold at link time, so here it only dedups the large
// bodies nobody inlines. Nightly-only; the pinned toolchain is nightly.
// Not under ASAN: routing Box/Vec allocs through the shared alloc-crate
// instantiation moves their frames and LSAN's conservative reachability
// loses some at-exit allocations it previously found (bun-info, bun-audit,
// issue 30205), turning benign at-exit state into reported leaks.
if (!cfg.asan) rustflags.push("-Zshare-generics=y");
rustflags.push(...rustCpuTargetFlags(cfg));
// `bun_core::build_options::ENABLE_ASAN = cfg!(bun_asan)` — must agree with
// the C++ `ASAN_ENABLED` macro so Global::exit() picks the same libc exit
// path (`exit` vs `quick_exit`) that c-bindings.cpp registered Bun__onExit on.
rustflags.push("--check-cfg=cfg(bun_asan)");
if (cfg.asan) {
// Match the C/C++ side's instrumentation so cross-language stack traces
// and shadow-memory bookkeeping agree. Nightly-only flag; the pinned
// toolchain in `rust-toolchain.toml` is nightly.
rustflags.push("-Zsanitizer=address");
rustflags.push("--cfg=bun_asan");
}
// `bun_debug`: the cargo profile is `dev` (a Debug-buildtype build).
// `bun_core::env::IS_DEBUG` and `build_options::ENABLE_LOGS` key on this
// instead of `cfg!(debug_assertions)` so that release-asan /
// release-assertions (which enable `debug-assertions` below for
// `debug_assert!()` coverage) don't also flip on Debug-only conveniences:
// `DUMP_SOURCE` (per-module writes to /tmp/bun-debug-src/), `debug_warn!`
// stderr noise, the `bun-debug` self-name for `npm run` rewrites,
// experimental feature-flag defaults. Mirrors Zig's
// `builtin.mode == .Debug`, which the Rust port had proxied via
// `debug_assertions` only because the two were coextensive until now.
rustflags.push("--check-cfg=cfg(bun_debug)");
if (cfg.debug) {
rustflags.push("--cfg=bun_debug");
}
// `bun_codegen_embed`: embed codegen-output `.js` (`include_bytes!`) instead
// of reading them from `BUN_CODEGEN_DIR` at runtime. Mirrors Zig
// `BunBuildOptions.shouldEmbedCode() = optimize != .Debug or codegen_embed`.
// Debug builds skip it for faster iteration (and the dir always exists
// locally); anything else needs it for the binary to be portable across
// machines — without it `bun_runtime::bake`/`bun_resolver::node_fallbacks`
// panic with `Failed to load '<build-machine-path>/codegen/...'` when a CI
// test runner runs an artifact built on a different agent.
rustflags.push("--check-cfg=cfg(bun_codegen_embed)");
if (!cfg.debug) {
rustflags.push("--cfg=bun_codegen_embed");
}
// `socket_fault_injection`: usockets bsd_* fault-injection hooks compiled
// in (LIBUS_SOCKET_FAULT_INJECTION=1 on the C side). The Rust FFI for
// us_fault_set/us_fault_clear_all and the JS control surface gate on this
// so the C symbol and the Rust extern are either both present or both
// absent regardless of profile.
rustflags.push("--check-cfg=cfg(socket_fault_injection)");
if (cfg.socketFaultInjection) {
rustflags.push("--cfg=socket_fault_injection");
}
// Drop `#[track_caller]` source-location capture in release. Every
// `Option::unwrap`/`slice[i]`/`RefCell::borrow` etc. otherwise emits a
// `&'static core::panic::Location` (file/line/col) plus the file-path string
// and a per-call-site `lea` to load it — ~320 KB across the crate graph
// (measured macOS arm64). Release ships `panic = "abort"` and the crash
// handler captures a frame-pointer backtrace that bun.report symbolizes to
// file:line server-side, so the panic call site is recoverable from the trace
// without embedding the location in the binary — same as the Zig build, which
// had ~0 embedded source paths. Kept off for debug and `release-assertions`
// where panic messages are read locally. Nightly-only; the pinned toolchain
// is nightly.
if (cfg.release && !cfg.assertions) {
rustflags.push("-Zlocation-detail=none");
}
// Path remapping (CI reproducibility) — rustc equivalent of the C/C++
// `-ffile-prefix-map` entries in flags.ts. Without this, `file!()` /
// panic locations and the DWARF compilation-dir from every workspace
// crate and vendored Rust dep (lol-html) embed the absolute checkout
// path into the release binary (`strings bun | grep $PWD` shows them).
// Gated on `cfg.ci` to match the flags.ts entry.
if (cfg.ci) {
rustflags.push(`--remap-path-prefix=${cfg.cwd}=.`);
rustflags.push(`--remap-path-prefix=${cfg.vendorDir}=vendor`);
}
// IR PGO, Rust half — mirrors the C++ `-fprofile-generate`/`-fprofile-use`
// (flags.ts) so the Rust ~half of bun's `.text` participates too (a port-era
// `bun` is mostly Rust now; instrumenting only C++ would leave most of the
// cold-start working set un-ordered). One merged `.profdata` covers both:
// clang and rustc share LLVM's IR-PGO format, and scripts/build-pgo.ts
// resolves `llvm-profdata` from the build's own toolchain so the versions
// line up. Stale/partial coverage is expected (codegen drifts; prebuilt
// WebKit isn't instrumented) — `-fprofile-use`'s C++ warnings are already
// silenced in flags.ts; rustc just emits "no profile data" notes and skips
// those functions, it does not fail. Driven end-to-end by `bun run
// build:btg:pgo`. RUSTFLAGS only reach target crates (with `--target`), so
// host build scripts / proc-macros stay un-instrumented, which is what we
// want. Not on Windows (the C++ PGO flags are `c.unix`-gated; keep parity).
if (!cfg.windows && cfg.pgoGenerate) {
rustflags.push(`-Cprofile-generate=${cfg.pgoGenerate}`);
}
if (!cfg.windows && cfg.pgoUse) {
// Functions absent from the profile (or whose CFG hash drifted) just don't
// get PGO applied — rustc emits a stderr warning, not an error, so a
// stale/partial profile degrades gracefully rather than failing the build.
rustflags.push(`-Cprofile-use=${cfg.pgoUse}`);
}
// Force lld for any target link rustc itself performs. None exists today
// (`bun_bin` is a staticlib with no link step; `lol_html` is a plain rlib
// path dep), so this is defensive — see the Windows note below. The
// default `cc` driver picks BFD `/usr/bin/ld`, which doesn't match the
// semantics the C/C++ object set assumes (and, under `-Clinker-plugin-lto`,
// doesn't understand `-plugin-opt`). This used to live only behind
// `cfg.lto`, with the non-LTO build relying on `.cargo/config.toml`'s
// `rustflags`; but `CARGO_ENCODED_RUSTFLAGS` (always set below) *replaces*
// the config-file `rustflags` rather than merging, so the config entry was
// dead for any ninja build. Push it unconditionally so the ninja build's
// behavior doesn't depend on the generated `.cargo/config.toml` at all.
//
// Not on Windows: the per-target linker there is `link.exe` / `lld-link.exe`
// (see `CARGO_TARGET_*_LINKER` below), which take `/X` args, not the GCC/clang
// `-fuse-ld=`. RUSTFLAGS only reach *target* crates when `--target` is given,
// and the `bun_bin` staticlib has no link step, so it's normally dead — but
// if a target cdylib ever appears it'd fail with "could not open '-fuse-ld=lld'".
if (!cfg.windows) rustflags.push(`-Clink-arg=-fuse-ld=lld`);
// Keep the clang driver quiet about link args that don't apply to a given
// artifact kind: rustc adds `-no-pie` under `-Crelocation-model=static`,
// which is meaningless when it links a target cdylib, and rustc's
// `linker_messages` lint then re-surfaces clang's
// "argument unused during compilation: '-no-pie'" as a warning on every
// build-rust job. No target cdylib exists today (same story as
// `-fuse-ld=lld` above), so this too is defensive. Same approach as the
// WebKit configure (`-Qunused-arguments`); real linker errors still fail
// the link.
if (!cfg.windows) rustflags.push(`-Clink-arg=-Qunused-arguments`);
// And allow the lint itself: CI treats new warnings as failures, and the
// lint forwards anything any platform's linker prints to stderr - the
// -Qunused-arguments above only covers the clang-driver case. Real linker
// errors are unaffected (they fail the link, not the lint).
rustflags.push(`-Alinker_messages`);
if (cfg.crossLangLto) {
// Cross-language LTO: emit LLVM bitcode (not machine code) into the .a
// so the final lld LTO link sees through Rust↔C++ call edges. The shape
// of that bitcode must match the platform's C++ LTO mode — thin
// (per-CGU, ThinLTO-summaried) on darwin, fat (pre-merged by rustc,
// summary-less) on ELF — selected via the CARGO_PROFILE_RELEASE_LTO
// override in the env block below.
//
// Bitcode-format compatibility: lld must be able to read rustc's bitcode.
// LLVM bitcode is forward-compatible (newer reads older), so this works
// when the linker's LLVM ≥ rustc's bundled LLVM. resolveConfig() swaps
// `cfg.ld` to rustc's bundled rust-lld when rustc's LLVM major is ahead
// of clang's — see workarounds.ts "rust-lld-for-crosslang-lto".
rustflags.push("-Clinker-plugin-lto");
rustflags.push("-Cembed-bitcode=yes");
// EnableSplitLTOUnit consistency: lld errors with "inconsistent LTO Unit
// splitting" if any bitcode module in the link disagrees with the others.
// Every LTO platform now links ThinLTO with the C/C++ side passing
// -fno-split-lto-unit (index-based WPD, no hybrid split), so every C/C++
// module (ours and the WebKit -lto prebuilts) says 0. rustc's default is
// also 0, so pass nothing. (`-Clink-arg=-fuse-ld=lld` is pushed
// unconditionally above — under LTO it doubles as making rustc's bitcode
// link go through the LTO-aware linker our final link uses, not BFD
// `/usr/bin/ld`.)
if (!cfg.darwin && !cfg.windows) {
// Rust functions default to carrying the `uwtable(async)` attribute.
// When the LTO inliner inlines such a callee into one of our C++
// callers (compiled without unwind tables), the caller inherits the
// attribute — so cross-language inlining sprays full .eh_frame FDEs
// across thousands of C++ functions (~+1.8 MB on the linux links;
// the musl release binary keeps .eh_frame so it pays it in full).
// We build with panic=abort and always keep frame pointers, and the
// glibc release binary already ships without .eh_frame entirely, so
// the tables are pure dead weight here — turn them off for the Rust
// side of the merged module. (The prebuilt std bitcode keeps its own
// uwtable attrs; this only stops our crates from spreading them.)
rustflags.push("-Cforce-unwind-tables=no");
}
}
// ─── Environment ───
const env: Record<string, string> = {
CARGO_TERM_COLOR: "always",
// `include!(concat!(env!("BUN_CODEGEN_DIR"), "/generated_*.rs"))` and
// `include_bytes!` in `bun_js_parser`/`bun_runtime` resolve against this.
// Set in cargo's env so it reaches every crate's `rustc` invocation
// (not just those with a `build.rs` re-export). `bun_core::build_options`
// is also `include!()`'d from here — its values come from
// `buildOptionsRs.ts` (written at configure time), not env vars.
BUN_CODEGEN_DIR: cfg.codegenDir,
// ── toolchain forwarding (cc-rs / build scripts) ──
// build.rs of crates in the dep graph (anything using `cc`) and rustc's
// own linker invocations must use the SAME clang/ar `tools.ts` resolved —
// not whatever is first in PATH. On CI the LLVM toolchain lives at a
// versioned path (`/opt/llvm-N/`) and the system `cc` may be absent or
// mismatched. cc-rs honours `CC`/`CXX`/`AR`; cargo honours
// `CARGO_TARGET_<TRIPLE>_LINKER` for the per-target linker.
CC: cfg.cc,
CXX: cfg.cxx,
AR: cfg.ar,
// Per-target linker. The `bun_bin` artifact is a staticlib (no link step);
// what actually gets linked are HOST executables/dylibs in the dep graph
// (build scripts, proc-macros) — and on a native build, `--target` is the
// host triple, so this env var sets *their* linker too.
//
// Non-Windows: `cfg.cxx` (clang++) drives lld with the same flag dialect
// the C++ side uses. `-Clink-arg=-fuse-ld=lld` (pushed into rustflags
// below) selects lld for any rustc-driven cdylib link.
//
// Windows: rustc's `*-msvc` linker flavor passes `link.exe`-style args
// directly (`/NOLOGO`, `/OUT:`, `/NATVIS:`, `/PDBALTPATH:`, …). `clang-cl`
// is a *compiler driver*, not a linker — it reads `/N…` args as input
// filenames ("no such file or directory: '/NOLOGO'") and never reaches the
// underlying linker. Use the discovered MSVC `link.exe` (matches what
// `dep_cargo` sets for vendored crates — see source.ts), falling back to
// `lld-link.exe` (`cfg.ld`); both speak the `/X` dialect rustc emits.
[`CARGO_TARGET_${triple.toUpperCase().replace(/-/g, "_")}_LINKER`]: cfg.windows
? (cfg.msvcLinker ?? cfg.ld)
: cfg.cxx,
};
if (cfg.cargoHome !== undefined) env.CARGO_HOME = cfg.cargoHome;
if (cfg.rustupHome !== undefined) env.RUSTUP_HOME = cfg.rustupHome;
// Pin the toolchain explicitly. `vendor/` is commonly a symlink shared
// across worktrees; rustup's directory walk could otherwise resolve a
// different worktree's `rust-toolchain.toml`.
if (cfg.rustToolchain !== undefined) env.RUSTUP_TOOLCHAIN = cfg.rustToolchain;
// Darwin cross-compile from a non-darwin host: point anything in the dep
// graph that cares about the Apple SDK at the extracted sysroot. rustc
// itself doesn't need it for a staticlib, but cc-rs (build scripts
// compiling target C) honours CFLAGS_<triple>/SDKROOT, and
// MACOSX_DEPLOYMENT_TARGET keeps the LC_BUILD_VERSION minos rustc stamps
// into its objects consistent with the C++ side's -mmacosx-version-min.
if (cfg.darwin && cfg.host.os !== "darwin") {
if (cfg.osxDeploymentTarget !== undefined) env.MACOSX_DEPLOYMENT_TARGET = cfg.osxDeploymentTarget;
if (cfg.osxSysroot !== undefined && cfg.crossTarget !== undefined && cfg.osxDeploymentTarget !== undefined) {
env.SDKROOT = cfg.osxSysroot;
const sdkFlags = `--target=${cfg.crossTarget} -isysroot ${cfg.osxSysroot} -mmacosx-version-min=${cfg.osxDeploymentTarget}`;
const tripleEnv = triple.replace(/-/g, "_");
env[`CFLAGS_${tripleEnv}`] = sdkFlags;
env[`CXXFLAGS_${tripleEnv}`] = sdkFlags;
}
}
if (cfg.crossLangLto) {
// Every crossLangLto platform links ThinLTO, so leave each crate's per-CGU
// bitcode with its ThinLTO summary intact: the whole link is one uniform
// ThinLTO graph and cross-module importing works across Rust↔C++/JSC.
// `fat` would pre-merge the crates into one summary-less blob the thin
// link can't import from. (The workspace `[profile.release] lto = "fat"`
// exists for non-LTO release builds, where the rust .a is linked as
// already-codegen'd machine code and still wants intra-Rust inlining.)
env.CARGO_PROFILE_RELEASE_LTO = "off";
} else if (cfg.asan) {
// release-asan has `cfg.lto` forced off (config.ts), but without this
// override Cargo.toml's `[profile.release] lto = "fat"` still applies —
// rustc merges every crate into one module and codegens it serially, on
// IR that ASAN instrumentation has already ~doubled. That's the 15-min
// cargo step vs 4m36s for the linker-plugin-lto build (which defers
// codegen to lld). ASAN builds don't need intra-Rust LTO; turn it off.
env.CARGO_PROFILE_RELEASE_LTO = "off";
// With LTO off, `codegen-units = 1` only serializes each crate's LLVM pass over the doubled IR; nothing built with ASAN ships, so take cargo's release default instead.
env.CARGO_PROFILE_RELEASE_CODEGEN_UNITS = "16";
}
if (cfg.assertions) {
// Turn `debug_assert!()` / `#[cfg(debug_assertions)]` on in the release
// cargo profile. `cfg.assertions` defaults to `debug || asan`
// (config.ts), so release-asan and release-assertions both get Rust
// invariant checks to match the C++ side's `-DASSERT_ENABLED=1` (keyed
// on the same `cfg.assertions` in flags.ts). Without this override the
// workspace `[profile.release]` leaves debug-assertions off and ~3k
// `debug_assert!` sites compile to nothing under ASAN. The `dev` profile
// (debug builds) already defaults it on, so this is a no-op there.
env.CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS = "true";
}
if (rustflags.length > 0) env.CARGO_ENCODED_RUSTFLAGS = rustflags.join("\x1f");
return { args, env, targetDir, triple };
}
/**
* Emit the cargo build step. Returns the output staticlib path as a
* one-element array so the link step can spread it alongside the C++
* object list.
*/
export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string[] {
assert(cfg.cargo !== undefined, "building bun's Rust crates requires cargo but no rust toolchain was found", {
hint: "Install rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
});
n.comment("─── Rust ───");
n.blank();
const hostWin = cfg.host.os === "windows";
const lib = rustLibPath(cfg);
const tier3 = rustTargetIsTier3(rustTarget(cfg));
const { args, env, targetDir, triple } = cargoBuildInvocation(cfg);
// ─── Windows .bin/ shim PE ───
// Builds `src/install/windows-shim/bun_shim_impl.rs` as a freestanding release PE and wires the artifact into `include_bytes!`. Without this step `include_bytes!` embeds the
// 0-byte placeholder and `bun install` writes empty `.exe`s into
// `node_modules/.bin/`.
//
// Ordered before the main cargo build via `implicitInputs` below so the
// real PE is on disk when `bun_install` compiles. Same env as the main
// build (toolchain forwarding, CARGO_HOME) but no codegen dep — the shim
// crate's graph is bun_core/bun_sys/bun_string only.
const shimInputs: string[] = [];
if (cfg.windows) {
const shimDest = windowsShimDestPath(cfg);
// Always `--profile shim` (workspace `[profile.shim]`: panic=abort,
// opt-level=z, lto, codegen-units=1, strip) regardless of bun's own
// profile — a debug bun should still write release shims (matches Zig's
// unconditional `.ReleaseFast`).
//
// `-Zbuild-std=core,compiler_builtins` rebuilds the sysroot for the
// freestanding `#![no_std]` crate so LTO can inline across `core`;
// `panic_immediate_abort` makes every `panic!`/`unreachable!`/`assert!`
// (incl. those buried in `core::fmt`, slice indexing, `Option::unwrap`)
// compile to a bare `ud2`/`brk` with no `core::fmt::Arguments` payload —
// that machinery is otherwise the bulk of `.text`. Nightly + `rust-src`
// are guaranteed by `rust-toolchain.toml`.
const shimArgs: string[] = [
"-p",
"bun_shim_impl",
"--bin",
"bun_shim_impl",
"--features",
"shim_standalone",
"--target-dir",
targetDir,
"--target",
triple,
"--profile",
"shim",
"--locked",
"-Zbuild-std=core,compiler_builtins",
"-Zbuild-std-features=compiler-builtins-mem",
];
const shimSrc = resolve(targetDir, triple, "shim", "bun_shim_impl.exe");
// Same env minus the main build's CARGO_ENCODED_RUSTFLAGS — the shim has
// its own panic strategy (abort) so `-Zsanitizer=address` (which assumes
// unwind) and `-Clinker-plugin-lto` (the PE is final-linked here, not
// deferred to bun's lld link) don't apply, and `-Cforce-frame-pointers` /
// `-Ctarget-cpu` cost size we don't want. Replace with a freestanding
// flag set:
// - `/ENTRY:shim_main` — bypass the CRT (`mainCRTStartup`) entirely;
// the launcher reads argv from TEB→PEB itself.
// - `/SUBSYSTEM:CONSOLE` — link.exe can't infer subsystem without a
// recognised entry symbol.
// - `/NODEFAULTLIB` — don't pull msvcrt/vcruntime/ucrt; the only
// imports are kernel32 + ntdll (named via
// `#[link]` on the externs).
//
// (`-Cforce-unwind-tables=no` would drop `.pdata`, but the
// `*-windows-msvc` target spec sets `requires_uwtable: true` so rustc
// rejects it. The section is ~3 KiB; not worth a custom target JSON.)
const { CARGO_ENCODED_RUSTFLAGS: _, ...shimEnv } = env;
shimEnv.CARGO_ENCODED_RUSTFLAGS = [
// `panic = "immediate-abort"` is the new (nightly ≥ 2025-12) spelling of
// the old `-Zbuild-std-features=panic_immediate_abort`: every panic call
// (incl. core::fmt-carrying assert/unreachable/unwrap) compiles to a
// bare trap with no `Arguments` payload.
"-Zunstable-options",
"-Cpanic=immediate-abort",
"-Clink-arg=/ENTRY:shim_main",
"-Clink-arg=/SUBSYSTEM:CONSOLE",
"-Clink-arg=/NODEFAULTLIB",
"-Clink-arg=kernel32.lib",
"-Clink-arg=ntdll.lib",
// Cross-compiling from a unix host: this is the only cargo-driven link
// of a *target* artifact, and the linker is lld-link (no MSVC install),
// so point it at the xwin splat for the kernel32/ntdll import libs.
...(cfg.winsysroot !== undefined ? [`-Clink-arg=/winsysroot:${cfg.winsysroot}`] : []),
].join("\x1f");
// Declared output = per-build-dir stamp; the shared source-tree exe is an
// implicit INPUT (see the rust_shim rule comment for why). The exe must
// exist before ninja evaluates the graph — pre-create an empty
// placeholder the same way `src/install/build.rs` does for bare
// `cargo check`, so a fresh checkout doesn't error on a missing input.
if (!existsSync(shimDest)) {
mkdirSync(dirname(shimDest), { recursive: true });
writeFileSync(shimDest, "");
}
const shimStamp = resolve(targetDir, triple, "shim", "bun_shim_impl.stamp");
n.build({
outputs: [shimStamp],
rule: "rust_shim",
inputs: [],
// Same staleness signal as the main build (any .rs / Cargo.toml change
// re-invokes; cargo's own fingerprinting decides what actually
// recompiles). vendorStamps order the lol-html fetch first — the shim
// crate doesn't depend on lol-html, but cargo refuses to load the
// workspace manifest if any path-dep's `Cargo.toml` is missing.
// shimDest: rebuilt when a sibling build dir (other arch/profile)
// overwrote the shared exe.
implicitInputs: [cfg.cargo, ...inputs.rustSources, ...inputs.vendorStamps, shimDest],
vars: {
cwd: cfg.cwd,
args: quoteArgs(shimArgs, hostWin),
shim_src: quote(shimSrc, hostWin),
shim_dest: quote(shimDest, hostWin),
env: Object.entries(shimEnv)
.map(([k, v]) => `--env=${k}=${quote(v, hostWin)}`)
.join(" "),
},
});
n.phony("bun-shim", [shimStamp]);
shimInputs.push(shimStamp);
}
// ─── Emit build node ───
// When the toolchain is rustup-managed and pinned, route through
// `rust_build_cross`, which does `rustup toolchain install ...`
// before cargo. That makes the first build after a `rust-toolchain.toml`
// channel bump (and a partially auto-installed toolchain) self-heal —
// see the rule comment above. Tier 1/2 also pass `--target <triple>` so
// the prebuilt `rust-std` for the cross triple is installed; Tier 3 omits
// it (no prebuilt — cargo gets `-Zbuild-std` instead) and just gets
// `rust-src`. Local builds without rustup, or without a pinned channel,
// fall back to plain `rust_build` and trust whatever toolchain `cfg.cargo`
// resolves to.
const useCrossRule = findRustup(cfg) !== undefined && cfg.rustToolchain !== undefined;
n.build({
outputs: [lib],
rule: useCrossRule ? "rust_build_cross" : "rust_build",
inputs: [],
// Cargo binary itself + every .rs/Cargo.toml so editing one re-invokes
// (cargo's own fingerprinting then decides what to actually recompile).
// Codegen `.rs` outputs are side effects of edges in `codegenInputs`,
// so depending on those orders the codegen step before cargo without
// ninja needing to know the `.rs` paths. vendorStamps orders the
// lol-html source fetch before cargo resolves the path dep.
implicitInputs: [cfg.cargo, ...inputs.rustSources, ...inputs.codegenInputs, ...inputs.vendorStamps, ...shimInputs],
orderOnlyInputs: inputs.codegenOrderOnly,
vars: {
cwd: cfg.cwd,
args: quoteArgs(args, hostWin),
...(useCrossRule ? { rust_target_arg: tier3 ? "" : `--target ${triple}` } : {}),
label: `${cfg.libPrefix}bun_rust${cfg.libSuffix}`,
env: Object.entries(env)
.map(([k, v]) => `--env=${k}=${quote(v, hostWin)}`)
.join(" "),
},
});
n.phony("bun-rust", [lib]);
n.blank();
return [lib];
}
/**
* Link inputs for the Rust side of the binary.
*
* On ELF cross-language LTO targets the fat Rust bitcode member can't go
* into the link as-is: it has no per-module summary, so lld reads it as
* EnableSplitLTOUnit=0 while every clang-produced full-LTO object (ours,
* the deps', the WebKit -lto prebuilts') says 1, and the link aborts with
* "inconsistent LTO Unit splitting". rustc has no way to emit a regular-LTO
* summary (clang hardcodes one in shouldEmitRegularLTOSummary()), so a
* build step rewrites the bitcode with rustc's own LLVM tools — see
* rust-lto-fix-cli.ts and the `rustc-no-regular-lto-summary` workaround
* entry.
*
* Returns [fixed bitcode .o, original .a]: the .o defines every Rust symbol
* (so the archive's bitcode member is never pulled), and the archive still
* supplies its native members (compiler_builtins). On every other config
* this is the identity function.
*/
export function rustLtoLinkInputs(n: Ninja, cfg: Config, rustObjects: string[]): string[] {
const rustLib = rustObjects[0];
// All LTO platforms now use ThinLTO with -fno-split-lto-unit and per-CGU
// rust bitcode (CARGO_PROFILE_RELEASE_LTO=off), so the regular-LTO summary
// fix-up below is never needed. Delete this function once confirmed.
if (cfg.lto || !cfg.crossLangLto || cfg.darwin || cfg.windows || rustLib === undefined) return rustObjects;
assert(
cfg.rustSysroot !== undefined && cfg.host.rustTriple !== undefined,
"ELF cross-language LTO needs rustc's sysroot to locate its LLVM tools (llvm-link/opt) for the regular-LTO summary fix-up, but rustc wasn't found",
{ hint: "Install the pinned rust toolchain (rustup show active-toolchain), or build with --lto=off" },
);
const llvmBin = join(cfg.rustSysroot, "lib", "rustlib", cfg.host.rustTriple, "bin");
const out = resolve(cfg.buildDir, "bun_rust.lto.o");
n.build({
outputs: [out],
rule: "rust_lto_fix",
inputs: [rustLib],
implicitInputs: [rustLtoFixCliPath],
vars: { llvm_bin: llvmBin, ar: cfg.ar },
});
return [out, ...rustObjects];
}
/** `${buildDir}/${exe}.linker-map` — lld's `-Wl,-Map=` output (see flags.ts). */
export function linkerMapPath(cfg: Config): string {
return join(cfg.buildDir, `${bunExeName(cfg)}.linker-map`);
}
/**
* Linker flags to wrap the Rust staticlib so every `#[no_mangle]` member
* reaches the final image (the dynamic-list / NAPI surface has no inbound
* static ref, so plain archive extraction would drop those `.o` members).
* Functionally equivalent to feeding a single merged `.o`.
*
* Returned flags reference `libs` by absolute path; the caller must also
* list them in the link's `implicitInputs` so ninja relinks on change.
*/
export function rustLinkFlags(cfg: Config, libs: string[]): string[] {
if (libs.length === 0) return [];
if (cfg.windows) {
return libs.map(l => `/WHOLEARCHIVE:${l}`);
}
if (cfg.darwin) {
return libs.flatMap(l => ["-Wl,-force_load", l]);
}
// ELF (Linux/FreeBSD/Android)
return ["-Wl,--whole-archive", ...libs, "-Wl,--no-whole-archive"];
}