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

827 lines
33 KiB
TypeScript
Raw Permalink 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.
/**
* Toolchain discovery.
*
* Finds compilers/tools in PATH + known platform-specific locations.
* Version-checks when a constraint is given. Throws BuildError with a helpful
* hint when a required tool is missing.
*/
import { execSync, spawnSync } from "node:child_process";
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
import type { Arch, OS, Toolchain } from "./config.ts";
import { BuildError } from "./error.ts";
// ───────────────────────────────────────────────────────────────────────────
// Version range checking
// ───────────────────────────────────────────────────────────────────────────
/**
* Parse a version like "21.1.8" out of arbitrary text (tool --version output).
* Returns the first X.Y.Z found, or undefined.
*/
function parseVersion(text: string): string | undefined {
const m = text.match(/(\d+)\.(\d+)\.(\d+)/);
return m ? `${m[1]}.${m[2]}.${m[3]}` : undefined;
}
/**
* Compare two X.Y.Z version strings. Returns -1, 0, 1.
*/
function compareVersions(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
const ai = pa[i] ?? 0;
const bi = pb[i] ?? 0;
if (ai !== bi) return ai < bi ? -1 : 1;
}
return 0;
}
/**
* Check if a version satisfies a range string.
*
* Range syntax: `>=X.Y.Z <A.B.C` (space-separated constraints, all must pass).
* Single version without operator = exact match.
* Empty/undefined range = always satisfied.
*/
export function satisfiesRange(version: string, range: string | undefined): boolean {
if (range === undefined || range === "" || range === "ignore") return true;
const v = parseVersion(version);
if (v === undefined) return false;
for (const part of range.split(/\s+/)) {
if (part === "") continue;
const m = part.match(/^(>=|<=|>|<|=)?(\d+\.\d+\.\d+)$/);
if (!m) return false; // malformed range
const op = m[1] ?? "=";
const target = m[2];
if (target === undefined) return false;
const cmp = compareVersions(v, target);
const ok =
op === ">="
? cmp >= 0
: op === ">"
? cmp > 0
: op === "<="
? cmp <= 0
: op === "<"
? cmp < 0
: /* "=" */ cmp === 0;
if (!ok) return false;
}
return true;
}
// ───────────────────────────────────────────────────────────────────────────
// Tool discovery
// ───────────────────────────────────────────────────────────────────────────
export interface ToolSpec {
/** Names to try, in order. On Windows `.exe` is appended automatically. */
names: string[];
/** Extra search paths beyond $PATH. Tried FIRST (more specific). */
paths?: string[];
/** Version constraint, e.g. `">=21.1.0 <22.0.0"`. */
version?: string;
/** How to get the version. `"--version"` (default) or `"version"` (go/zig style). */
versionArg?: string;
/** If true, throws BuildError when not found. */
required: boolean;
/** Extra hint text for the error message. */
hint?: string;
}
export interface FoundTool {
path: string;
/** Parsed X.Y.Z, if `spec.version` was set and the check ran. */
version?: string;
}
/**
* Rejection log for a single tool search — used in error messages.
*/
interface Rejection {
path: string;
reason: string;
}
/**
* Find the bun executable for codegen (`bun install`, `bun build`, scripts
* using Bun APIs). Must be the actual bun binary — NOT process.execPath,
* since configure may run under node.
*
* Search order: ~/.bun/bin (curl-install location), then PATH, then
* process.execPath only if it's actually bun. CI agents pin an old system
* bun but codegen scripts need newer CLI flags, so the user install goes
* first.
*/
export function findBun(os: OS): string {
const exe = os === "windows" ? "bun.exe" : "bun";
const userBun = join(homedir(), ".bun", "bin", exe);
if (isExecutable(userBun)) return userBun;
// Running under bun at a non-standard path — use that.
if (process.versions.bun !== undefined) return process.execPath;
return findTool({
names: ["bun"],
required: true,
hint: "Codegen requires bun (for `bun install`, `bun build`, and scripts using Bun APIs). Install: curl -fsSL https://bun.sh/install | bash",
})!.path;
}
/**
* Check if a file exists and is executable.
*/
function isExecutable(p: string): boolean {
try {
// Must check isFile(): X_OK on a directory means "traversable", not
// "runnable". Without this, a `cmake/` dir in a PATH entry would shadow
// the real cmake binary.
if (!statSync(p).isFile()) return false;
accessSync(p, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Get the version of a tool. Returns the parsed X.Y.Z, or a diagnostic
* string describing why parsing failed (starts with a digit → version,
* otherwise → failure reason for the rejection log).
*/
function getToolVersion(exe: string, versionArg: string): { version: string } | { reason: string } {
// stdio ignore on stdin: on Windows CI the parent's stdin can be a
// handle that blocks the child's CRT init. --version never reads stdin.
// 30s timeout: cold start of a large binary (clang is 100+ MB) through
// Defender scan-on-access can legitimately exceed 5s on a busy CI box.
const result = spawnSync(exe, [versionArg], {
encoding: "utf8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { reason: `spawn failed: ${result.error.message}` };
}
// Some tools print their version to stderr instead of stdout. Check both.
const version = parseVersion(result.stdout ?? "") ?? parseVersion(result.stderr ?? "");
if (version !== undefined) return { version };
// Parse failed — include what we saw (truncated) so the error is
// actionable instead of just "could not parse".
const output = ((result.stdout ?? "") + (result.stderr ?? "")).trim().slice(0, 200);
if (result.status !== 0) {
return { reason: `exited ${result.status}: ${output || "(no output)"}` };
}
return { reason: `no X.Y.Z in output: ${output || "(empty)"}` };
}
/**
* Ask clang what arch it targets by default. Parses `Target:` from
* `--version` output. Returns undefined if unparseable.
*
* CMake does this during compiler detection (project()) to set
* CMAKE_SYSTEM_PROCESSOR — that's how the old cmake build knew arm64
* even when cmake.exe itself was x64. process.arch reflects the running
* process (may be emulated); the compiler's target is what we actually
* build for.
*/
export function clangTargetArch(clang: string): Arch | undefined {
const result = spawnSync(clang, ["--version"], {
encoding: "utf8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error || result.status !== 0) return undefined;
const m = (result.stdout ?? "").match(/^Target:\s*(\S+)/m);
if (!m) return undefined;
const triple = m[1]!;
// aarch64-pc-windows-msvc, arm64-apple-darwin, x86_64-unknown-linux-gnu, ...
if (/^(aarch64|arm64)/.test(triple)) return "aarch64";
if (/^(x86_64|x64|amd64)/i.test(triple)) return "x64";
return undefined;
}
/**
* Find a tool. Searches provided paths first, then $PATH.
* Returns the absolute path or undefined (if not required).
*/
export function findTool(spec: ToolSpec): FoundTool | undefined {
const exeSuffix = process.platform === "win32" ? ".exe" : "";
const searchPaths = [...(spec.paths ?? []), ...(process.env.PATH ?? "").split(delimiter).filter(p => p.length > 0)];
const versionArg = spec.versionArg ?? "--version";
const rejections: Rejection[] = [];
for (const name of spec.names) {
const candidate = name.endsWith(exeSuffix) ? name : name + exeSuffix;
for (const dir of searchPaths) {
const full = join(dir, candidate);
if (!isExecutable(full)) continue;
if (spec.version !== undefined) {
const v = getToolVersion(full, versionArg);
if ("reason" in v) {
rejections.push({ path: full, reason: v.reason });
continue;
}
if (!satisfiesRange(v.version, spec.version)) {
rejections.push({ path: full, reason: `version ${v.version} does not satisfy ${spec.version}` });
continue;
}
return { path: full, version: v.version };
}
return { path: full };
}
}
if (spec.required) {
const primaryName = spec.names[0] ?? "<unknown>";
let msg = `Could not find ${primaryName}`;
if (spec.version !== undefined) msg += ` (version ${spec.version})`;
let hint = spec.hint ?? "";
if (rejections.length > 0) {
hint += (hint ? "\n" : "") + "Found but rejected:\n" + rejections.map(r => ` ${r.path}: ${r.reason}`).join("\n");
}
if (rejections.length === 0 && searchPaths.length > 0) {
hint +=
(hint ? "\n" : "") + `Searched: ${searchPaths.slice(0, 5).join(", ")}${searchPaths.length > 5 ? ", ..." : ""}`;
}
throw new BuildError(msg, hint ? { hint } : {});
}
return undefined;
}
// ───────────────────────────────────────────────────────────────────────────
// LLVM-specific discovery
// ───────────────────────────────────────────────────────────────────────────
/**
* LLVM version constraint. Any version in the same major.minor range is
* accepted (e.g. Alpine 3.23 ships 21.1.2 while we target 21.1.8).
*/
export const LLVM_VERSION = "21.1.8";
const LLVM_MAJOR = "21";
const LLVM_MINOR = "1";
const LLVM_VERSION_RANGE = `>=${LLVM_MAJOR}.${LLVM_MINOR}.0 <${LLVM_MAJOR}.${LLVM_MINOR}.99`;
/**
* Known LLVM install locations per platform. Call ONCE from
* resolveLlvmToolchain — it contains a spawn on macOS (brew --prefix as
* fallback) which takes ~100ms, so calling it per-tool would dominate
* configure time.
*/
function llvmSearchPaths(os: OS, arch: Arch): string[] {
const paths: string[] = [];
if (os === "darwin") {
// Try the arch-default prefix first (correct for standard homebrew
// installs — /opt/homebrew on Apple Silicon, /usr/local on Intel).
// Only spawn `brew --prefix` as a last resort for custom installs —
// brew's startup is slow and this runs on every configure.
const defaultPrefix = arch === "aarch64" ? "/opt/homebrew" : "/usr/local";
let brewPrefix: string;
if (isExecutable(`${defaultPrefix}/bin/brew`)) {
brewPrefix = defaultPrefix;
} else {
try {
brewPrefix = execSync("brew --prefix", { encoding: "utf8", timeout: 3000 }).trim();
} catch {
brewPrefix = defaultPrefix;
}
}
paths.push(`${brewPrefix}/opt/llvm@${LLVM_MAJOR}/bin`);
paths.push(`${brewPrefix}/opt/llvm/bin`);
}
if (os === "windows") {
// Prefer standalone LLVM over VS-bundled
paths.push("C:\\Program Files\\LLVM\\bin");
}
if (os === "linux" || os === "darwin") {
paths.push("/usr/lib/llvm/bin");
// Debian/Ubuntu-style suffixed paths
paths.push(`/usr/lib/llvm-${LLVM_MAJOR}.${LLVM_MINOR}.0/bin`);
paths.push(`/usr/lib/llvm-${LLVM_MAJOR}.${LLVM_MINOR}/bin`);
paths.push(`/usr/lib/llvm-${LLVM_MAJOR}/bin`);
paths.push(`/usr/lib/llvm${LLVM_MAJOR}/bin`);
}
return paths;
}
/**
* Version-suffixed command names (e.g. clang-21, clang-21.1).
* Unix distros often only ship these suffixed versions.
*/
function llvmNameVariants(name: string): string[] {
return [
name,
`${name}-${LLVM_MAJOR}.${LLVM_MINOR}.0`,
`${name}-${LLVM_MAJOR}.${LLVM_MINOR}`,
`${name}-${LLVM_MAJOR}`,
];
}
function llvmInstallHint(os: OS): string {
if (os === "darwin") return `Install with: brew install llvm@${LLVM_MAJOR}`;
if (os === "linux")
return `Install with: apt install clang-${LLVM_MAJOR} lld-${LLVM_MAJOR} (or equivalent for your distro)`;
if (os === "windows") return `Install LLVM ${LLVM_VERSION} from https://github.com/llvm/llvm-project/releases`;
return "";
}
/**
* Find an LLVM tool with version checking. `paths` computed once by the
* caller (contains a slow brew spawn on macOS).
*/
function findLlvmTool(
baseName: string,
paths: string[],
os: OS,
opts: { checkVersion: boolean; required: boolean },
): FoundTool | undefined {
const spec: ToolSpec = {
names: llvmNameVariants(baseName),
paths,
required: opts.required,
hint: llvmInstallHint(os),
};
if (opts.checkVersion) spec.version = LLVM_VERSION_RANGE;
return findTool(spec);
}
// ───────────────────────────────────────────────────────────────────────────
// Full toolchain resolution
// ───────────────────────────────────────────────────────────────────────────
/**
* Resolve the entire toolchain for a target.
*
* Call this once at configure time. All tool paths are absolute.
* Throws BuildError if any required tool is missing.
*
* `os`/`arch` are the HOST (where to search, executable suffixes, install
* hints). `targetOs` is what we're building FOR — it decides which tool
* *family* is needed: a windows target wants the MSVC-style drivers
* (clang-cl, llvm-lib, lld-link, llvm-rc) even when the host is linux/macOS,
* since those all ship in every LLVM distribution and are inherently
* cross-capable. Defaults to the host (native build).
*
* zig/bun/esbuild are resolved separately (they come from cache/, not PATH)
* so pass them in as placeholders for now; they'll be filled by downloaders.
*/
export function resolveLlvmToolchain(
os: OS,
arch: Arch,
targetOs: OS = os,
): Pick<
Toolchain,
| "cc"
| "cxx"
| "hostCc"
| "hostCxx"
| "ar"
| "ranlib"
| "ld"
| "ld64Lld"
| "rustLld"
| "rustLlvmVersion"
| "rustSysroot"
| "rustHostTriple"
| "strip"
| "llvmStrip"
| "dsymutil"
| "ccache"
| "rc"
| "mt"
| "nasm"
| "clangVersion"
| "clangResourceDir"
> {
// Compute search paths ONCE. Contains a brew spawn on macOS (~100ms)
// so calling it per-tool would burn ~600ms. Every tool below gets
// the same paths; first-match-wins in findTool means whichever LLVM
// install is highest-priority wins consistently.
const paths = llvmSearchPaths(os, arch);
// The MSVC-style tool family is selected by the TARGET: building for
// windows needs clang-cl/llvm-lib/lld-link/llvm-rc regardless of host.
const msvcTarget = targetOs === "windows";
// clang — version-checked. clang++ is the same binary (hardlink or
// symlink) from the same install; a second version-check spawn would
// just return the same answer. We still locate it separately so the
// "not found" error names the right tool.
const ccResult = findLlvmTool(msvcTarget ? "clang-cl" : "clang", paths, os, {
checkVersion: true,
required: true,
});
const cxx = findLlvmTool(msvcTarget ? "clang-cl" : "clang++", paths, os, {
checkVersion: false,
required: true,
})?.path;
// Resource dir (builtin headers live at <resource-dir>/include). Needed by
// darwin cross-compiles, which rebuild the include search path explicitly
// (-nostdinc) so nothing from the build host can leak in. One ~10ms spawn;
// skipped for windows targets, where nothing consumes it (and cc is
// clang-cl, which takes MSVC-style flags).
let clangResourceDir: string | undefined;
if (!msvcTarget) {
const probe = spawnSync(ccResult!.path, ["-print-resource-dir"], {
encoding: "utf8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (!probe.error && probe.status === 0) {
const dir = (probe.stdout ?? "").trim();
if (dir.length > 0) clangResourceDir = dir;
}
}
// Host compiler for build-time codegen tools (dep_host_cc) and host-side
// cargo artifacts (.cargo/config.toml linker for the host triple). Normally
// the same as cc/cxx, but when cross-compiling for windows from a unix
// host, cc/cxx are clang-cl (which defaults to a *-windows-msvc triple,
// emits COFF, and can't drive an ELF link) — host tools must stay on plain
// clang/clang++.
let hostCc: string | undefined;
let hostCxx: string | undefined;
if (msvcTarget && os !== "windows") {
hostCc = findLlvmTool("clang", paths, os, { checkVersion: false, required: true })?.path;
hostCxx = findLlvmTool("clang++", paths, os, { checkVersion: false, required: true })?.path;
}
// ar: llvm-ar (or llvm-lib for windows targets)
// No version check — ar doesn't always print a parseable version,
// and any ar from the same LLVM install is fine.
const ar = findLlvmTool(msvcTarget ? "llvm-lib" : "llvm-ar", paths, os, {
checkVersion: false,
required: true,
})?.path;
// ranlib: llvm-ranlib (unix hosts only — llvm-lib targets don't need it).
// Needed for nested cmake builds (CMAKE_RANLIB). llvm-ar's `s` flag does the
// same thing for our direct archives, but deps may call ranlib explicitly.
let ranlib: string | undefined;
if (os !== "windows") {
ranlib = findLlvmTool("llvm-ranlib", paths, os, {
checkVersion: false,
required: true,
})?.path;
}
// ld: lld-link for windows targets, ld.lld on Linux (passed as --ld-path=).
// On Darwin clang drives the system linker directly.
let ld: string;
if (msvcTarget) {
ld = findLlvmTool("lld-link", paths, os, { checkVersion: false, required: true })?.path ?? "";
} else if (os === "linux") {
ld = findLlvmTool("ld.lld", paths, os, { checkVersion: true, required: true })?.path ?? "";
} else {
ld = ""; // darwin: unused
}
// ld64.lld: lld's Mach-O port. Only used when a non-darwin host
// cross-compiles FOR darwin (resolveConfig swaps it in as cfg.ld); the
// target isn't known here, so resolve it opportunistically — it ships in
// the same LLVM install as ld.lld, and the lookup is a handful of stats.
let ld64Lld: string | undefined;
if (os !== "darwin" && os !== "windows") {
ld64Lld = findLlvmTool("ld64.lld", paths, os, { checkVersion: false, required: false })?.path;
}
// strip: GNU strip on Linux (more features), llvm-strip elsewhere.
// llvm-strip is also resolved on Linux (optional) — GNU strip can't read
// Mach-O, so darwin cross-compiles need it (resolveConfig swaps it in).
let strip: string;
let llvmStrip: string | undefined;
if (os === "linux") {
strip = findTool({ names: ["strip"], required: true, hint: "Install binutils for your distro" })?.path ?? "";
llvmStrip = findLlvmTool("llvm-strip", paths, os, { checkVersion: false, required: false })?.path;
} else {
strip = findLlvmTool("llvm-strip", paths, os, { checkVersion: false, required: true })?.path ?? "";
llvmStrip = strip;
}
// dsymutil: required on darwin; optional elsewhere (needed only when
// cross-compiling a darwin release from a non-darwin host).
let dsymutil: string | undefined;
if (os === "darwin") {
dsymutil = findLlvmTool("dsymutil", paths, os, { checkVersion: false, required: true })?.path;
} else if (os !== "windows") {
dsymutil = findLlvmTool("dsymutil", paths, os, { checkVersion: false, required: false })?.path;
}
// rc/mt: windows targets only. Passed to nested cmake — when
// CMAKE_C_COMPILER is an explicit path, cmake's find_program for these
// may not search the compiler's directory, so we resolve them here and
// pass explicitly. rc is required (cmake's try_compile on windows uses
// it, and the final link embeds windows-app-info.res); mt is optional
// (not all LLVM distros ship it — source.ts sets
// CMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY as fallback).
let rc: string | undefined;
let mt: string | undefined;
if (msvcTarget) {
rc = findLlvmTool("llvm-rc", paths, os, { checkVersion: false, required: true })?.path;
mt = findLlvmTool("llvm-mt", paths, os, { checkVersion: false, required: false })?.path;
}
// nasm: windows-x64 targets only. BoringSSL's win-x64 assembly is NASM
// syntax (perlasm emits gas .S everywhere else, including win-aarch64).
// clang's integrated assembler can't read NASM, and OPENSSL_NO_ASM is a
// 5-10× crypto perf hit, so this is required when targeting win-x64.
let nasm: string | undefined;
if (msvcTarget) {
nasm = findTool({
names: ["nasm"],
// boringssl's win-x64 .asm needs nasm; win-aarch64 uses gas .S.
// `arch` here is the HOST arch — the target isn't known yet inside
// resolveToolchain(). compile.ts:nasm() asserts at the use site
// with the same hint, so a missing nasm still fails clearly.
required: false,
hint:
os === "windows"
? "Install from https://nasm.us or `winget install NASM.NASM`"
: "Install nasm from your distro (apt install nasm) or https://nasm.us",
})?.path;
}
// rust-lld: optional alternative linker for cross-language LTO when
// rustc's bundled LLVM is newer than clang's. See findRustLld().
const { rustLld, rustLlvmVersion, rustSysroot, rustHostTriple } = findRustLld(os);
// ccache: optional. If found, used as compiler launcher.
const ccache = findTool({ names: ["ccache"], required: false })?.path;
// These are definitely defined at this point (required=true throws otherwise),
// but TS can't see through that, so assert.
if (ccResult === undefined || cxx === undefined || ar === undefined) {
throw new BuildError("unreachable: required tool undefined");
}
if (strip === "") {
throw new BuildError("unreachable: strip undefined");
}
return {
cc: ccResult.path,
clangVersion: ccResult.version,
clangResourceDir,
cxx,
hostCc,
hostCxx,
ar,
ranlib,
ld,
ld64Lld,
rustLld,
rustLlvmVersion,
rustSysroot,
rustHostTriple,
strip,
llvmStrip,
dsymutil,
ccache,
rc,
mt,
nasm,
};
}
/**
* Find an arbitrary system tool (not LLVM-specific).
* Thin wrapper for convenience.
*/
export function findSystemTool(name: string, opts?: { required?: boolean; hint?: string }): string | undefined {
const spec: ToolSpec = {
names: [name],
required: opts?.required ?? false,
};
if (opts?.hint !== undefined) spec.hint = opts.hint;
return findTool(spec)?.path;
}
// ───────────────────────────────────────────────────────────────────────────
// Rust toolchain (cargo) — needed for lolhtml only
// ───────────────────────────────────────────────────────────────────────────
export interface CargoToolchain {
cargo: string;
cargoHome: string;
rustupHome: string;
}
/**
* Locate rustc's bundled lld and its LLVM version.
*
* rustc ships its own copy of lld (built against the same LLVM rustc emits
* bitcode with). When `-Clinker-plugin-lto` is on and rustc's LLVM is newer
* than clang's, clang's `ld.lld` can't read the rust bitcode ("Unknown
* attribute kind"). LLVM bitcode is forward-compatible only — a newer lld
* reads older bitcode, never the reverse — so the fix is to link with
* rust-lld instead, which reads both clang's (older) and rustc's (same)
* bitcode.
*
* The path under `gcc-ld/` is a wrapper that invokes the sibling
* `rust-lld` binary in the right "flavor" (ld.lld / ld64.lld / lld-link),
* matching what `--ld-path=` expects on each platform. On Windows we use
* `rust-lld.exe` directly since lld-link mode is selected by argv[0] there.
*
* Returns undefined for both fields if rustc isn't installed or its sysroot
* doesn't have the expected layout (e.g. distro-packaged rustc without the
* `rust-lld` component).
*/
export function findRustLld(os: OS): {
rustLld: string | undefined;
rustLlvmVersion: string | undefined;
/** `rustc --print sysroot` — needed for bundled `llvm-nm` even when rust-lld itself isn't used. */
rustSysroot: string | undefined;
/** `host:` line from `rustc -vV` — the rustlib subdirectory name. */
rustHostTriple: string | undefined;
} {
const none = { rustLld: undefined, rustLlvmVersion: undefined, rustSysroot: undefined, rustHostTriple: undefined };
// Look up rustc the same way findCargo does cargo: $CARGO_HOME/bin first.
const cargoHome = process.env.CARGO_HOME ?? join(homedir(), ".cargo");
const rustc = findTool({ names: ["rustc"], paths: [join(cargoHome, "bin")], required: false })?.path;
if (rustc === undefined) return none;
// The link-only CI mode runs `findRustLld()` on an agent that downloads
// `libbun_rust.a` rather than building it, so the pinned nightly may not be
// installed there yet. `rustc --print sysroot` (a rustup proxy invocation)
// would auto-install — but the download blows past a short spawnSync timeout
// and the silent failure leaves `rustLld` undefined, which falls back to the
// system lld. With cross-language LTO that means lld 21 reading rust-emitted
// LLVM 22 bitcode → `Invalid record`. Pre-flight a `rustup toolchain
// install` so the proxy resolves instantly: idempotent (~0.5s, it re-checks
// the channel manifest) when already installed, downloads on a stale agent.
// `-q` also hides the download progress, so say how long it took whenever
// it evidently did more than that check: every build job of CI build 91391
// spent 34-36s in here without a line of output. Skip when there's no
// pinned channel or no rustup — the `rustc` queries below will just use
// whatever's there.
const rustup = findTool({ names: ["rustup"], paths: [join(cargoHome, "bin")], required: false })?.path;
const channel = readRustToolchainChannel();
if (rustup !== undefined && channel !== undefined) {
const started = performance.now();
spawnSync(
rustup,
["-q", "toolchain", "install", channel, "--no-self-update", "--profile", "minimal", "--component", "rust-src"],
{
encoding: "utf8",
timeout: 300_000,
stdio: ["ignore", "ignore", "inherit"], // surface error output; `-q` hides `info:` noise
},
);
const seconds = (performance.now() - started) / 1000;
if (seconds >= 5) {
console.log(
`rustup spent ${seconds.toFixed(0)}s installing the pinned toolchain (${channel}); it was missing or incomplete on this machine`,
);
}
}
// One spawn for both sysroot and host triple / LLVM version. `-vV` prints
// `host: <triple>` and `LLVM version: X.Y.Z`; sysroot needs its own query.
//
// RUSTUP_TOOLCHAIN pins the proxy to the channel the pre-flight just
// ensured. Without it the proxy, running in the repo root, applies
// rust-toolchain.toml in full: besides selecting the channel it installs
// every entry of its `components` and `targets` lists that is missing
// (rustfmt, clippy, miri, llvm-tools and the std of 11 targets — ~2.4 GB),
// with its output piped into nowhere here. The build itself installs what
// it needs (rust-src above, the target's std in the rust_build_cross rule),
// and the toml still applies to anyone running cargo directly. Generous
// timeout: without rustup there is no pre-flight and this proxy invocation
// may still be the one that auto-installs the channel.
const env = channel !== undefined ? { ...process.env, RUSTUP_TOOLCHAIN: channel } : process.env;
const sysroot = spawnSync(rustc, ["--print", "sysroot"], {
encoding: "utf8",
timeout: 300_000,
stdio: ["ignore", "pipe", "pipe"],
env,
}).stdout?.trim();
const vv = spawnSync(rustc, ["-vV"], {
encoding: "utf8",
timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"],
env,
}).stdout;
if (!sysroot || !vv) return none;
const rustHostTriple = vv.match(/^host:\s*(\S+)/m)?.[1];
const rustLlvmVersion = vv.match(/^LLVM version:\s*(\d+\.\d+\.\d+)/m)?.[1];
if (rustHostTriple === undefined) return { ...none, rustSysroot: sysroot, rustLlvmVersion };
const bin = join(sysroot, "lib", "rustlib", rustHostTriple, "bin");
const candidate =
os === "windows"
? join(bin, "rust-lld.exe")
: os === "darwin"
? join(bin, "gcc-ld", "ld64.lld")
: join(bin, "gcc-ld", "ld.lld");
const rustLld = isExecutable(candidate) ? candidate : undefined;
return { rustLld, rustLlvmVersion, rustSysroot: sysroot, rustHostTriple };
}
/**
* Read the pinned channel from `rust-toolchain.toml` at the repo root.
* Mirrors `readRustToolchainChannel()` in config.ts but stays in `tools.ts`
* because `findRustLld()` runs during `resolveToolchain()` — *before*
* `resolveConfig()` reads the channel into `cfg.rustToolchain`. Both walk the
* same file; keeping the parse local avoids an import cycle.
*/
function readRustToolchainChannel(): string | undefined {
// tools.ts lives at `scripts/build/`; the toolchain file is two levels up.
const path = join(import.meta.dirname, "..", "..", "rust-toolchain.toml");
if (!existsSync(path)) return undefined;
const m = /^\s*channel\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf8"));
return m?.[1];
}
/**
* Find cargo + its home directories. Returns undefined if cargo isn't
* installed — caller decides whether to error (only needed when building
* rust deps from source).
*/
export function findCargo(hostOs: OS): CargoToolchain | undefined {
// Resolve CARGO_HOME and RUSTUP_HOME the same way rustup does:
// explicit env var → platform default. We don't probe %PROGRAMFILES%
// for MSI installs — rustup is overwhelmingly the common case.
const home = homedir();
const cargoHome = process.env.CARGO_HOME ?? join(home, ".cargo");
const rustupHome = process.env.RUSTUP_HOME ?? join(home, ".rustup");
// Search $CARGO_HOME/bin BEFORE $PATH. Some systems have an outdated
// distro cargo in /usr/bin that shadows rustup's — we want rustup's.
const cargo = findTool({
names: ["cargo"],
paths: [join(cargoHome, "bin")],
required: false,
})?.path;
if (cargo === undefined) return undefined;
// Suppress unused warning for hostOs — kept in signature for future
// host-specific path resolution (e.g. %PROGRAMFILES% probing on win32).
void hostOs;
return { cargo, cargoHome, rustupHome };
}
/**
* Find MSVC's link.exe. Windows only.
*
* Needed because on CI, Git Bash's `/usr/bin/link` (the GNU coreutils
* hard-link utility) can appear in PATH before MSVC's link.exe. Cargo
* invokes `link.exe` to link, and the wrong one silently fails.
*
* We probe the standard VS2022 install layout rather than trusting PATH.
* If VS is installed somewhere non-standard, set the CARGO_TARGET_*_LINKER
* env var yourself.
*/
export function findMsvcLinker(arch: Arch): string | undefined {
// VS2022 standard layout:
// C:/Program Files/Microsoft Visual Studio/2022/<edition>/VC/Tools/MSVC/<ver>/bin/<host>/<target>/link.exe
// Edition is Community|Professional|Enterprise|BuildTools.
const vsBase = "C:/Program Files/Microsoft Visual Studio/2022";
if (!existsSync(vsBase)) return undefined;
// Pick the latest MSVC toolset version across all editions. Usually
// there's only one edition installed, but BuildTools + Community can
// coexist on CI.
let latestVer: string | undefined;
let latestToolset: string | undefined;
for (const edition of readdirSync(vsBase)) {
const msvcDir = join(vsBase, edition, "VC/Tools/MSVC");
if (!existsSync(msvcDir)) continue;
for (const ver of readdirSync(msvcDir)) {
// Lexicographic comparison works for MSVC versions (14.xx.yyyyy).
if (latestVer === undefined || ver > latestVer) {
latestVer = ver;
latestToolset = join(msvcDir, ver);
}
}
}
if (latestToolset === undefined) return undefined;
// For arm64 targets, prefer the native arm64 host linker if available
// (faster), else cross from x64. For x64 targets, use the x64 host.
const candidates: string[] = [];
if (arch === "aarch64") {
candidates.push(join(latestToolset, "bin/HostARM64/arm64/link.exe"));
candidates.push(join(latestToolset, "bin/Hostx64/arm64/link.exe"));
} else {
candidates.push(join(latestToolset, "bin/Hostx64/x64/link.exe"));
}
for (const c of candidates) {
if (existsSync(c)) return c;
}
return undefined;
}