Files
bun-src/scripts/build/rust.ts
T
2026-08-27 21:09:14 +00:00

1004 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"];
}