From 98f80ec15dc44e36d3f52a8d09fa1e63e6a2c175 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 02:18:02 +0000 Subject: [PATCH 01/26] build: support cross-compiling Windows targets from Linux Add --os=windows cross-compilation support to the build system so bun.exe for both x64 and arm64 can be compiled and linked from a Linux host: - resolveConfig(): new Windows cross block that resolves a "winsysroot" (xwin splat of the MSVC CRT/STL + Windows SDK in Visual Studio layout, detected at /opt/winsysroot, /opt/xwin, WINDOWS_SYSROOT, or --winsysroot=) and sets crossTarget=-pc-windows-msvc. Sanitizers are forced off and cross builds get their own default build dir (build/-windows-). - tools.ts: toolchain selection is keyed on the *target* OS, so a windows target resolves clang-cl/llvm-lib/lld-link/llvm-rc/llvm-mt/nasm from the host LLVM on any host. A separate host clang/clang++ is resolved for build-time codegen tools and host-side cargo links. - flags.ts: pass /winsysroot to clang-cl and /winsysroot: to lld-link when cross-compiling. - bun.ts/rust.ts: llvm-rc gets explicit SDK include dirs, the windows strip (copy) and bun_shim_impl rules follow the host shell, and the shim's lld-link invocation gets /winsysroot for kernel32/ntdll import libs. - cargo-config.ts: the generated .cargo/config.toml uses the host clang++ for the host triple instead of clang-cl. - profiles: windows-x64, windows-arm64, windows-x64-release, windows-arm64-release. - CI image provisioning (scripts/bootstrap.sh, .buildkite/Dockerfile): install nasm, the windows-msvc rustup targets, and an xwin splat at /opt/winsysroot. - docs: cross-compiling section in building-windows.mdx. --- .buildkite/Dockerfile | 17 +++++ docs/project/building-windows.mdx | 36 ++++++++++ scripts/bootstrap.sh | 53 ++++++++++++++ scripts/build.ts | 1 + scripts/build/bun.ts | 48 +++++++++++-- scripts/build/cargo-config.ts | 25 ++++--- scripts/build/compile.ts | 4 +- scripts/build/config.ts | 111 +++++++++++++++++++++++++++--- scripts/build/configure.ts | 20 ++++-- scripts/build/deps/webkit.ts | 17 ++++- scripts/build/flags.ts | 22 +++++- scripts/build/profiles.ts | 34 +++++++++ scripts/build/rust.ts | 41 +++++++---- scripts/build/source.ts | 6 +- scripts/build/tools.ts | 76 ++++++++++++++------ 15 files changed, 439 insertions(+), 72 deletions(-) diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index 89d6b2e171a8..e476056e1917 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ software-properties-common apt-transport-https \ ca-certificates gnupg lsb-release unzip xz-utils \ libxml2-dev ruby ruby-dev bison gawk perl make golang ccache qemu-user-static \ + nasm \ && add-apt-repository ppa:ubuntu-toolchain-r/test \ && apt-get update \ && apt-get install -y gcc-13 g++-13 libgcc-13-dev libstdc++-13-dev \ @@ -127,6 +128,7 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ && rustup default nightly \ && rustup target add aarch64-linux-android x86_64-linux-android \ && rustup target add x86_64-unknown-freebsd \ + && rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc \ && rustup component add rust-src # Android NDK — sysroot/libc++/compiler-rt for --abi=android cross-compile. @@ -172,6 +174,21 @@ RUN FBSD_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "am && rm /tmp/base.txz ENV FREEBSD_SYSROOT=/opt/freebsd-sysroot +# Windows sysroot — xwin splat of the MSVC CRT/STL + Windows SDK (VS layout) +# for --os=windows cross-compile; clang-cl/lld-link consume it via +# /winsysroot (see scripts/build/config.ts `winsysroot`). Both target arches +# in one splat; --include-debug-libs so /MTd debug links work. +# --accept-license accepts the Microsoft license terms for the SDK/CRT +# components, same as the Windows CI images do when installing VS Build Tools. +ARG XWIN_VERSION="0.6.7" +RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo "x86_64"; fi) \ + && curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl.tar.gz" \ + | tar -xz -C /tmp \ + && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ + && rm -rf /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl /tmp/xwin-cache +ENV WINDOWS_SYSROOT=/opt/winsysroot + RUN ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "amd64"; fi) && \ echo "Downloading buildkite" && \ curl -fsSL "https://github.com/buildkite/agent/releases/download/v3.87.0/buildkite-agent-linux-${ARCH}-3.87.0.tar.gz" -o /tmp/buildkite-agent.tar.gz && \ diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index 10f6c2b41708..1f98da80eaa2 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -142,3 +142,39 @@ bun-debug test "C:\bun\test\js\bun\resolve\import-meta.test.js" ### failed to write output 'bun-debug.exe': permission denied you cannot overwrite `bun-debug.exe` if it is already open. you likely have a running instance, maybe in the vscode debugger? + +## Cross-compiling from Linux + +Windows binaries (both x64 and arm64) can also be built on a Linux host. The build uses the host LLVM's `clang-cl`, `lld-link`, `llvm-lib` and `llvm-rc` — which are part of every LLVM distribution — plus an "xwin splat" of the MSVC CRT/STL and Windows SDK for headers and import libraries. + +### Prerequisites + +1. The same LLVM version a native build uses (see `scripts/bootstrap.sh` `llvm_version_exact`), installed so that `clang-cl`, `lld-link`, `llvm-lib` and `llvm-rc` are available. On Debian/Ubuntu, `apt.llvm.org` packages provide all of them. +2. `nasm` (only needed for Windows x64 — BoringSSL's x64 assembly is NASM syntax). +3. Rust std for the Windows targets (`rust-toolchain.toml` lists them; `rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc` if missing). +4. A Windows sysroot: an [xwin](https://github.com/Jake-Shadle/xwin) splat of the MSVC CRT and Windows SDK laid out like a Visual Studio install. Note that downloading these components means accepting Microsoft's license terms for them. + +```bash +cargo install xwin # or download a release binary +xwin --accept-license --arch x86_64,aarch64 splat \ + --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + --output /opt/winsysroot +``` + +The build looks for the sysroot at `/opt/winsysroot` (or `/opt/xwin`) automatically; elsewhere, set `WINDOWS_SYSROOT=` or pass `--winsysroot=`. + +### Building + +```bash +# Debug builds +bun run build --profile=windows-x64 +bun run build --profile=windows-arm64 + +# Release builds +bun run build --profile=windows-x64-release +bun run build --profile=windows-arm64-release +``` + +Output lands in `build/debug-windows-x64/bun-debug.exe`, `build/release-windows-arm64/bun-profile.exe` + `bun.exe`, etc. Equivalent raw flags: `bun run build --os=windows --arch=aarch64`. + +Cross-compiled executables are not run on the host (the `--revision` smoke test is skipped), so test them on a Windows machine or under Wine. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 4656dc0276f0..b97c61d16dfd 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1128,6 +1128,7 @@ install_build_essentials() { linux) install_packages \ make \ + nasm \ python3 \ libtool \ ruby \ @@ -1142,6 +1143,7 @@ install_build_essentials() { install_rust install_android_ndk install_freebsd_sysroot + install_windows_sysroot install_ccache install_docker } @@ -1318,6 +1320,9 @@ install_rust() { # preinstalling keeps that step off the network. execute_as_user "$rustup" target add aarch64-apple-darwin execute_as_user "$rustup" target add x86_64-apple-darwin + # Windows cross-compile targets (--os=windows from a linux host). + execute_as_user "$rustup" target add x86_64-pc-windows-msvc + execute_as_user "$rustup" target add aarch64-pc-windows-msvc # rust-src for -Zbuild-std (Tier 3 targets without prebuilt std). execute_as_user "$rustup" component add rust-src ;; @@ -1401,6 +1406,54 @@ install_freebsd_sysroot() { # arch-appropriate /opt/freebsd-sysroot{,-arm64} by well-known path. } +xwin_version() { + print "0.6.7" +} + +install_windows_sysroot() { + case "$os" in + linux) ;; + *) return ;; + esac + + # MSVC CRT/STL + Windows SDK splat for --os=windows cross-compiles, + # laid out like a Visual Studio install so clang-cl/lld-link's + # /winsysroot flag works (see scripts/build/config.ts `winsysroot`). + # Fetched with xwin, which downloads the components from Microsoft's CDN; + # --accept-license accepts the Microsoft Software License Terms for the + # Build Tools/SDK on behalf of this machine (same terms the Windows CI + # images accept when installing VS Build Tools). + sysroot="/opt/winsysroot" + # Same sentinel detectWindowsSysroot() uses, plus an SDK import lib so a + # half-splatted (interrupted) sysroot isn't treated as complete. + if [ -d "$sysroot/Windows Kits/10/Include" ] && ls "$sysroot/Windows Kits/10/Lib"/*/um/x64/kernel32.lib >/dev/null 2>&1; then + return + fi + + xwin_ver="$(xwin_version)" + case "$arch" in + aarch64) xwin_triple="aarch64-unknown-linux-musl" ;; + *) xwin_triple="x86_64-unknown-linux-musl" ;; + esac + xwin_tar=$(download_file "https://github.com/Jake-Shadle/xwin/releases/download/${xwin_ver}/xwin-${xwin_ver}-${xwin_triple}.tar.gz") + xwin_dir="$(dirname "$xwin_tar")/xwin-extract" + execute mkdir -p "$xwin_dir" + execute tar -xzf "$xwin_tar" -C "$xwin_dir" --strip-components=1 + + execute_sudo rm -rf "$sysroot" + execute_sudo mkdir -p "$sysroot" + # Both target arches in one splat; --include-debug-libs so /MTd (debug + # CRT) links work; winsysroot-style + MS arch notation so clang-cl and + # lld-link resolve it with a single /winsysroot flag; symlinks stay ON + # (default) to fix include casing on a case-sensitive filesystem. + execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --cache-dir "$xwin_dir/cache" \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + --output "$sysroot" + execute_sudo rm -rf "$xwin_dir" + # No WINDOWS_SYSROOT export — detectWindowsSysroot() picks up + # /opt/winsysroot by well-known path. +} + install_docker() { case "$pm" in brew) diff --git a/scripts/build.ts b/scripts/build.ts index 378ac1e997a6..3b55b1abcad1 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -392,6 +392,7 @@ function parseArgs(argv: string[]): CliArgs { "androidNdk", "macosSdk", "osxDeploymentTarget", + "winsysroot", ]); for (let i = 0; i < argv.length; i++) { diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index 017b1d7830c1..8666605d394b 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -22,7 +22,7 @@ * build in parallel on separate machines then meet for linking. */ -import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { dirname, relative, resolve, sep } from "node:path"; import type { Sources } from "../glob-sources.ts"; import { emitCodegen, type CodegenOutputs } from "./codegen.ts"; @@ -710,11 +710,12 @@ function emitStrip(n: Ninja, cfg: Config, inputExe: string, stripflags: string[] const out = resolve(cfg.buildDir, "bun" + cfg.exeSuffix); // Windows: strip equivalent is handled at link time (/OPT:REF etc), no - // separate strip binary. The "stripped" bun is just a copy. + // separate strip binary. The "stripped" bun is just a copy. Copy command + // follows the HOST shell (cmd natively, cp when cross-compiling). if (cfg.windows) { // Copy as-is. /OPT:REF already applied at link. n.rule("strip", { - command: `cmd /c "copy /Y $in $out"`, + command: cfg.host.os === "windows" ? `cmd /c "copy /Y $in $out"` : `cp $in $out`, description: "copy $out (windows: no strip)", }); } else { @@ -831,10 +832,19 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { // ─── Compile .rc → .res (ninja time) ─── // llvm-rc: /FO sets output. `#include "windows.h"` in the .rc resolves - // via the INCLUDE env var set by the VS dev shell (vs-shell.ps1). + // via the INCLUDE env var set by the VS dev shell (vs-shell.ps1) on a + // Windows host; when cross-compiling there is no dev shell, so the SDK + // and MSVC include dirs from the winsysroot are passed explicitly. + const hostWin = cfg.host.os === "windows"; + const rcFlags: string[] = []; + if (cfg.winsysroot !== undefined) { + for (const dir of windowsSysrootIncludeDirs(cfg.winsysroot)) { + rcFlags.push("/I", quote(dir, hostWin)); + } + } const resFile = resolve(cfg.buildDir, "windows-app-info.res"); n.rule("rc", { - command: `${quote(cfg.rc, true)} /FO $out $in`, + command: `${quote(cfg.rc, hostWin)} $rcflags /FO $out $in`, description: "rc $out", }); n.build({ @@ -845,11 +855,39 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { // The template is NOT tracked here: it's substituted at configure // time, so template edits need a reconfigure (happens rarely). implicitInputs: [ico], + vars: { rcflags: rcFlags.join(" ") }, }); return resFile; } +/** + * Include dirs inside an xwin-style Windows sysroot, for tools that don't + * understand `/winsysroot` themselves (llvm-rc). Layout: + * /VC/Tools/MSVC//include + * /Windows Kits/10/Include//{ucrt,shared,um} + */ +function windowsSysrootIncludeDirs(winsysroot: string): string[] { + const dirs: string[] = []; + const msvcRoot = resolve(winsysroot, "VC", "Tools", "MSVC"); + if (existsSync(msvcRoot)) { + for (const ver of readdirSync(msvcRoot)) { + const d = resolve(msvcRoot, ver, "include"); + if (existsSync(d)) dirs.push(d); + } + } + const sdkInclude = resolve(winsysroot, "Windows Kits", "10", "Include"); + if (existsSync(sdkInclude)) { + for (const ver of readdirSync(sdkInclude)) { + for (const sub of ["ucrt", "shared", "um"]) { + const d = resolve(sdkInclude, ver, sub); + if (existsSync(d)) dirs.push(d); + } + } + } + return dirs; +} + /** * Linker flags to embed bun.exe.manifest into the executable. * The manifest enables longPathAware (paths > MAX_PATH) and SegmentHeap diff --git a/scripts/build/cargo-config.ts b/scripts/build/cargo-config.ts index 4263b1aabede..8c1c90919a4e 100644 --- a/scripts/build/cargo-config.ts +++ b/scripts/build/cargo-config.ts @@ -6,8 +6,8 @@ * machine-specific (CI puts the LLVM toolchain at a versioned path like * `/opt/llvm-N/bin/clang++`; a contributor's box has it wherever rustup / * the system package manager dropped it). `tools.ts` already discovers the - * real toolchain (`cfg.cxx`), so we write the per-target `linker = ` lines - * from that discovered path rather than hardcoding one. + * real toolchain (`cfg.hostCxx`), so we write the per-target `linker = ` + * lines from that discovered path rather than hardcoding one. * * For the `bun bd` / ninja build this file is purely advisory: `rust.ts` * passes `CARGO_TARGET__LINKER = cfg.cxx` (plus `CC`/`CXX`/`AR`) and @@ -36,21 +36,24 @@ function tripleOs(triple: string): "linux" | "darwin" | "windows" | "freebsd" { /** * Linker for `triple`. When the triple's OS matches the host OS we know the - * discovered `cfg.cxx` can drive the link (a linux clang++ links every linux - * arch's staticlib; macOS clang is multi-arch) — use the absolute path so a - * versioned/non-PATH toolchain still works. For a foreign OS we don't have a - * verified cross-linker path; `cargo check` never links and the ninja build - * sets the linker via env anyway, so fall back to the conventional driver - * name and let PATH resolve it if someone actually cross-links. + * discovered host clang++ can drive the link (a linux clang++ links every + * linux arch's staticlib; macOS clang is multi-arch) — use the absolute path + * so a versioned/non-PATH toolchain still works. For a foreign OS we don't + * have a verified cross-linker path; `cargo check` never links and the ninja + * build sets the linker via env anyway, so fall back to the conventional + * driver name and let PATH resolve it if someone actually cross-links. */ function linkerFor(triple: string, cfg: Config): string { // android needs the NDK sysroot/runtimes; musl needs a different libc/sysroot. - // The host's gnu clang++ (`cfg.cxx`) isn't a valid driver for either even on a - // linux host, so fall back to the conventional driver name (PATH-resolved) — + // The host's gnu clang++ isn't a valid driver for either even on a linux + // host, so fall back to the conventional driver name (PATH-resolved) — // matches the foreign-OS case below. `cargo check` never links, and the ninja // build sets the linker via `CARGO_TARGET__LINKER` env anyway. if (triple.includes("android") || triple.includes("musl")) return "clang++"; - return tripleOs(triple) === cfg.host.os ? cfg.cxx : "clang++"; + // cfg.hostCxx, not cfg.cxx: this entry covers HOST artifacts (build + // scripts, proc-macros) even during ninja builds, and when cross-compiling + // for windows cfg.cxx is clang-cl — not a valid posix link driver. + return tripleOs(triple) === cfg.host.os ? cfg.hostCxx : "clang++"; } /** diff --git a/scripts/build/compile.ts b/scripts/build/compile.ts index a2a757485c4f..bddae5acb190 100644 --- a/scripts/build/compile.ts +++ b/scripts/build/compile.ts @@ -28,7 +28,9 @@ export function registerCompileRules(n: Ninja, cfg: Config): void { // Quote tool paths — ninja passes commands through cmd/sh; a space in a // toolchain path (e.g. "C:\Program Files\LLVM\bin\clang-cl.exe") would // split argv without quoting. quote() passes through safe paths unchanged. - const q = (p: string) => quote(p, cfg.windows); + // Quoting style follows the HOST shell (cmd vs sh) — the target decides + // the command *shape* (clang-cl vs clang flags) below. + const q = (p: string) => quote(p, cfg.host.os === "windows"); const cc = q(cfg.cc); const cxx = q(cfg.cxx); const ar = q(cfg.ar); diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 2ce5b77dfd89..22889c477c44 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -177,6 +177,18 @@ export interface Config { // ─── Toolchain (resolved absolute paths) ─── cc: string; cxx: string; + /** + * Compiler for build-time host tools (dep_host_cc codegen helpers). + * Same as `cc` except when cross-compiling for windows from a unix host, + * where `cc` is clang-cl (emits COFF) and host tools need plain clang. + */ + hostCc: string; + /** + * C++ driver for host-side links (cargo's host-triple linker in + * `.cargo/config.toml` — build scripts, proc-macros). Same as `cxx` + * except when cross-compiling for windows from a unix host. + */ + hostCxx: string; /** Parsed X.Y.Z from clang --version. Captured once at resolve time. */ clangVersion: string | undefined; /** @@ -262,12 +274,21 @@ export interface Config { osxSysroot: string | undefined; // ─── Cross-compilation (set when host != target for C++) ─── - // Generic plumbing shared by every cross target (Android, FreeBSD, and - // macOS-from-Linux). + // Generic plumbing shared by every cross target (Android, FreeBSD, + // macOS-from-Linux, and Windows-from-unix). /** clang `--target=` triple, e.g. "aarch64-unknown-linux-android28". undefined = native. */ crossTarget: string | undefined; /** clang `--sysroot=` path. For Android: `/toolchains/llvm/prebuilt//sysroot`. */ sysroot: string | undefined; + /** + * Windows cross-compile only: root of an xwin-style splat of the MSVC + * CRT/STL + Windows SDK laid out like a Visual Studio install + * (`VC/Tools/MSVC/`, `Windows Kits/10`). Passed to clang-cl as + * `/winsysroot` and to lld-link as `/winsysroot:` — the cross equivalent + * of the INCLUDE/LIB env a VS dev shell provides on a Windows host. + * undefined on native Windows builds (VS dev shell supplies the SDK). + */ + winsysroot: string | undefined; /** Android NDK root. undefined when abi != "android". */ androidNdk: string | undefined; /** Android API level (the N in `__ANDROID_API__=N`). undefined when abi != "android". */ @@ -342,6 +363,8 @@ export interface PartialConfig { * it from the installed SDK / CI floor). Default: MIN_OSX_DEPLOYMENT_TARGET. */ osxDeploymentTarget?: string; + /** Windows sysroot (xwin splat, VS layout). Only used when cross-compiling for os=windows. */ + winsysroot?: string; // Version pins (defaults in versions.ts). nodejsVersion?: string; nodejsAbiVersion?: string; @@ -355,6 +378,14 @@ export interface PartialConfig { export interface Toolchain { cc: string; cxx: string; + /** + * Host compiler / C++ driver for build-time host tools and host-side + * cargo links. Only set when they differ from `cc`/`cxx` (windows + * cross-compile from a unix host, where cc/cxx are clang-cl); + * resolveConfig() falls back to `cc`/`cxx` otherwise. + */ + hostCc: string | undefined; + hostCxx: string | undefined; /** * Parsed clang --version (X.Y.Z). Captured during toolchain resolution * so downstream checks (workarounds.ts) don't re-spawn. undefined if @@ -510,6 +541,22 @@ export function detectFreebsdSysroot(arch: Arch): string | undefined { return undefined; } +/** + * Locate a Windows sysroot (xwin splat of the MSVC CRT/STL + Windows SDK in + * Visual Studio layout). Checks the env var then well-known install paths. + * The splat contains both x64 and arm64 CRT/SDK libs, so unlike FreeBSD + * there's no per-arch variant. Returns undefined if none found. + */ +export function detectWindowsSysroot(): string | undefined { + const looksValid = (p: string) => existsSync(join(p, "Windows Kits", "10", "Include")); + const env = process.env.WINDOWS_SYSROOT; + if (env && looksValid(env)) return env; + for (const p of ["/opt/winsysroot", "/opt/xwin"]) { + if (looksValid(p)) return p; + } + return undefined; +} + /** * Locate the Android NDK. Checks the conventional env vars in priority * order, then a couple of well-known install paths. Returns undefined if @@ -622,11 +669,12 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // ─── Target platform ─── const os = partial.os ?? host.os; - // Windows: process.arch can be wrong under emulation (x64 bun on arm64 - // hardware). Ask the compiler what it targets — CMake does the same in - // project() to set CMAKE_SYSTEM_PROCESSOR. The found clang's default - // target is what we actually build for. - const compilerArch = os === "windows" ? clangTargetArch(toolchain.cc) : undefined; + // Windows hosts: process.arch can be wrong under emulation (x64 bun on + // arm64 hardware). Ask the compiler what it targets — CMake does the same + // in project() to set CMAKE_SYSTEM_PROCESSOR. The found clang's default + // target is what we actually build for. Cross-compiles from a unix host + // skip this (the host clang-cl's default arch is just the host's). + const compilerArch = os === "windows" && host.os === "windows" ? clangTargetArch(toolchain.cc) : undefined; const arch = partial.arch ?? compilerArch ?? host.arch; const abi: Abi | undefined = os === "linux" ? (partial.abi ?? detectLinuxAbi()) : undefined; @@ -675,7 +723,12 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // libclang_rt.asan, and there's no -asan WebKit prebuilt for it. // Darwin cross: force off. The Linux LLVM toolchain doesn't ship the // darwin ASAN/UBSan runtime dylibs (libclang_rt.*_osx_dynamic.dylib). - const asan = abi === "android" || freebsd || darwinCross ? false : (partial.asan ?? asanDefault); + // Windows cross: force off. The host clang doesn't ship the windows + // clang_rt.asan runtime libs, so the link would fail. + const asan = + abi === "android" || freebsd || darwinCross || (windows && host.os !== "windows") + ? false + : (partial.asan ?? asanDefault); // Assertions: default on in debug OR asan. ASAN coupling is ABI-critical: // the -asan WebKit prebuilt is built with ASSERT_ENABLED=1, which gates @@ -769,7 +822,12 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // ─── Paths ─── const cwd = findRepoRoot(); - const defaultBuildDirName = computeBuildDirName({ debug, release, asan, assertions }); + // Windows cross-compiles get their own default build dir — the native + // build of the same profile (build/debug, build/release) already holds + // host-target objects at the same obj/ paths, and mixing COFF into an ELF + // build dir (or vice versa) forces a full rebuild each time you switch. + const crossWindowsSuffix = windows && host.os !== "windows" ? `-windows-${arch}` : ""; + const defaultBuildDirName = computeBuildDirName({ debug, release, asan, assertions }) + crossWindowsSuffix; const buildDir = partial.buildDir !== undefined ? isAbsolute(partial.buildDir) @@ -869,6 +927,38 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con } } + // ─── Cross-compilation (Windows) ─── + // Same pattern as Android/FreeBSD, with the MSVC spin: the host LLVM's + // clang-cl/lld-link/llvm-lib/llvm-rc are used (tools.ts picks them by + // target), and the "sysroot" is an xwin splat of the MSVC CRT/STL + + // Windows SDK in Visual Studio layout, passed via /winsysroot instead of + // --sysroot. Building ON Windows needs none of this — the VS dev shell + // provides INCLUDE/LIB. + let winsysroot: string | undefined; + if (windows && host.os !== "windows") { + winsysroot = + partial.winsysroot !== undefined + ? isAbsolute(partial.winsysroot) + ? partial.winsysroot + : resolve(cwd, partial.winsysroot) + : detectWindowsSysroot(); + if (winsysroot === undefined) { + throw new BuildError("--os=windows requires a Windows sysroot (MSVC CRT + Windows SDK) when cross-compiling", { + hint: + "Set WINDOWS_SYSROOT or pass --winsysroot=. Create one with xwin (https://github.com/Jake-Shadle/xwin):\n" + + " cargo install xwin (or download a release binary)\n" + + " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot", + }); + } + if (partial.webkit === "local") { + throw new BuildError("Cross-compiling for Windows requires the prebuilt WebKit (webkit=local needs msbuild)", { + hint: "Drop --webkit=local or build on a Windows host.", + }); + } + const llvmArch = arch === "x64" ? "x86_64" : "aarch64"; + crossTarget = `${llvmArch}-pc-windows-msvc`; + } + // ─── Versioning ─── const pkgJsonPath = resolve(cwd, "package.json"); const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { version: string }; @@ -994,6 +1084,8 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con vendorDir, cc: toolchain.cc, cxx: toolchain.cxx, + hostCc: toolchain.hostCc ?? toolchain.cc, + hostCxx: toolchain.hostCxx ?? toolchain.cxx, clangVersion: toolchain.clangVersion, clangResourceDir: toolchain.clangResourceDir, ar: toolchain.ar, @@ -1021,6 +1113,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con osxSysroot, crossTarget, sysroot, + winsysroot, androidNdk, androidApiLevel, androidNdkRuntimeDir, diff --git a/scripts/build/configure.ts b/scripts/build/configure.ts index 37674c19f140..523b9e30846d 100644 --- a/scripts/build/configure.ts +++ b/scripts/build/configure.ts @@ -11,7 +11,15 @@ import { dirname, resolve } from "node:path"; import { globAllSources } from "../glob-sources.ts"; import { type BunOutput, bunExeName, emitBun, shouldStrip, validateBunConfig } from "./bun.ts"; import { generateCargoConfig } from "./cargo-config.ts"; -import { type Config, type PartialConfig, type Toolchain, detectHost, findRepoRoot, resolveConfig } from "./config.ts"; +import { + type Config, + type OS, + type PartialConfig, + type Toolchain, + detectHost, + findRepoRoot, + resolveConfig, +} from "./config.ts"; import { BuildError } from "./error.ts"; import { mkdirAll, writeIfChanged } from "./fs.ts"; import { ensureMacosSdk } from "./macos-sdk.ts"; @@ -25,12 +33,16 @@ import { checkWorkarounds } from "./workarounds.ts"; /** * Full toolchain discovery. Returns absolute paths to all required tools. * + * `targetOs` (defaults to the host) decides which tool family is resolved — + * a windows target needs the MSVC-style drivers (clang-cl, llvm-lib, + * lld-link, llvm-rc) even from a linux/macOS host. + * * Throws BuildError with a hint if a required tool is missing. Optional * tools (ccache, cargo if no rust deps needed) become `undefined`. */ -export function resolveToolchain(): Toolchain { +export function resolveToolchain(targetOs?: OS): Toolchain { const host = detectHost(); - const llvm = resolveLlvmToolchain(host.os, host.arch); + const llvm = resolveLlvmToolchain(host.os, host.arch, targetOs ?? host.os); // cmake — required for nested dep builds. const cmake = findSystemTool("cmake", { required: true, hint: "Install cmake (>= 3.24)" }); @@ -245,7 +257,7 @@ export async function configure(input: ConfigureInput): Promise }); } - const toolchain = resolveToolchain(); + const toolchain = resolveToolchain(partial.os); mark("resolveToolchain"); const cfg = resolveConfig(partial, toolchain); diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index d7f2f1d49cb8..92a42ed033da 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -91,9 +91,20 @@ function prebuiltDestDir(cfg: Config): string { const v = cfg.webkitVersion; const version16 = v.startsWith("autobuild-") ? v.slice("autobuild-".length) : v.slice(0, 16); // Cross-compiled targets share a host (and cache dir) with native builds, - // so include os+arch in the key — otherwise a FreeBSD/arm64 or macOS/x64 - // extraction collides with a Linux/x64 one at the same WebKit version. - const osKey = cfg.freebsd ? "-freebsd" : cfg.darwin ? "-macos" : cfg.abi === "android" ? "-android" : ""; + // so include os+arch in the key — otherwise a FreeBSD/arm64, macOS/x64, or + // Windows-cross extraction collides with a Linux/x64 one at the same WebKit + // version. Windows is keyed only when cross-compiling so native Windows + // dev machines keep their existing cache dirs. + const osKey = + cfg.windows && cfg.host.os !== "windows" + ? "-windows" + : cfg.freebsd + ? "-freebsd" + : cfg.darwin + ? "-macos" + : cfg.abi === "android" + ? "-android" + : ""; const archKey = cfg.arm64 ? "-arm64" : ""; return resolve(cfg.cacheDir, `webkit-${version16}${osKey}${archKey}${prebuiltSuffix(cfg)}`); } diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 40fcf3671ff2..3ffe27019f8f 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { bunExeName, type Config } from "./config.ts"; -import { slash } from "./shell.ts"; +import { quote, slash } from "./shell.ts"; export type FlagValue = string | string[] | ((cfg: Config) => string | string[]); @@ -98,6 +98,16 @@ export const globalFlags: Flag[] = [ when: c => c.sysroot !== undefined, desc: "Cross-compile sysroot (target libc headers + libs)", }, + { + // Windows cross-compile: clang-cl can't read the VS dev shell's INCLUDE + // env on a non-Windows host. /winsysroot points it at an xwin-style + // splat laid out like a VS install (VC/Tools/MSVC + Windows Kits/10), + // covering the MSVC CRT/STL and Windows SDK headers + import libs. + // The lld-link equivalent (/winsysroot:) is added in linkerFlags below. + flag: c => ["/winsysroot", quote(c.winsysroot!, false)], + when: c => c.windows && c.winsysroot !== undefined, + desc: "Windows cross-compile: MSVC CRT + Windows SDK root (xwin splat)", + }, { // Same host-GCC #include_next leak as the FreeBSD block below: on // amazonlinux, clang's driver injects /usr/include/c++/N even with @@ -908,6 +918,16 @@ export const linkerFlags: Flag[] = [ when: c => c.windows, desc: "Target machine type for lld-link (required on arm64; x64 hosts default correctly but explicit is harmless)", }, + { + // Windows cross-compile: these ldflags go after /link, straight to + // lld-link, which doesn't see the compile-side `/winsysroot` from + // globalFlags — repeat it in lld-link's own spelling so the MSVC CRT + // and Windows SDK import libraries (libcmt, kernel32, ...) are found + // without a VS dev shell's LIB env. + flag: c => quote(`/winsysroot:${c.winsysroot!}`, false), + when: c => c.windows && c.winsysroot !== undefined, + desc: "Windows cross-compile: MSVC CRT + Windows SDK library search root (xwin splat)", + }, { flag: ["/STACK:0x1200000,0x200000", "/errorlimit:0"], when: c => c.windows, diff --git a/scripts/build/profiles.ts b/scripts/build/profiles.ts index 5895d40f9421..430e7f4a38e9 100644 --- a/scripts/build/profiles.ts +++ b/scripts/build/profiles.ts @@ -92,6 +92,40 @@ export const profiles = { webkit: "prebuilt", }, + /** + * Windows cross-compile from a non-Windows host: clang-cl + lld-link from + * the host LLVM plus an xwin-style Windows sysroot (see config.ts + * `winsysroot`). On a Windows host just use the regular debug/release + * profiles. Sanitizers are forced off in resolveConfig(). + */ + "windows-x64": { + buildType: "Debug", + os: "windows", + arch: "x64", + webkit: "prebuilt", + }, + + "windows-arm64": { + buildType: "Debug", + os: "windows", + arch: "aarch64", + webkit: "prebuilt", + }, + + "windows-x64-release": { + buildType: "Release", + os: "windows", + arch: "x64", + webkit: "prebuilt", + }, + + "windows-arm64-release": { + buildType: "Release", + os: "windows", + arch: "aarch64", + webkit: "prebuilt", + }, + /** Release build for local testing. No LTO (that's CI-only). */ release: { buildType: "Release", diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 8c5181d2d437..4fac1099378e 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -86,8 +86,12 @@ function cargoProfile(cfg: Config): { name: string; subdir: string } { * compiles C for the target; if one ever does, emitRust's * CFLAGS_/SDKROOT forwarding (set when the SDK is resolved) * points cc-rs at the macOS SDK. - * windows-msvc × {x64,aarch64}: NOT from linux without `cargo-xwin` - * (or wine + the MSVC SDK). CI runs these on a Windows agent. + * windows-msvc × {x64,aarch64}: yes *when a Windows sysroot (xwin splat) + * is present* — the staticlib itself needs no SDK, but the bun_shim_impl + * PE that emitRust() also builds links against kernel32/ntdll import + * libs via lld-link + /winsysroot (see config.ts `winsysroot`). The + * shared CI rust box doesn't carry the splat yet, so CI still runs these + * on a Windows agent. * * Unlike zig (which bundled its own libc/SDK for every target), cargo * delegates to a system C toolchain for any `cc`/`bindgen`/link step, so @@ -98,7 +102,8 @@ export function rustCanCrossFromLinux(cfg: Config): boolean { if (cfg.linux) return true; // gnu, musl, android — all archs if (cfg.freebsd) return true; if (cfg.darwin) return true; - // windows: native agent required. + // windows: possible with a winsysroot (see above), but the shared rust + // box isn't provisioned with one — windows rust-only still runs natively. return false; } @@ -242,21 +247,23 @@ export function registerRustRules(n: Ninja, cfg: Config): void { // `include_bytes!`. One rule does both so the declared output is the // source-tree path (cargo's own output path is an undeclared intermediate). // - // Copy is *content-conditional* (`fc /b` returns 0 iff bytes match) so - // `restat` actually prunes: any `.rs` edit re-invokes this rule (it shares - // `rustSources` with the main build), cargo no-ops, and a blind `copy /Y` - // would still bump $out'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 mtime stable and lets - // `restat` cut the edge. + // Copy is *content-conditional* (`fc /b` / `cmp -s` returns 0 iff bytes + // match) so `restat` actually prunes: any `.rs` edit re-invokes this rule + // (it shares `rustSources` with the main build), cargo no-ops, and a blind + // copy would still bump $out'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 mtime stable and + // lets `restat` cut the edge. // - // Windows-only — never registered elsewhere, so the rule body hard-assumes - // cmd.exe (`fc`, `copy`, `>nul`). + // 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: - `cmd /c "${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` + - `( fc /b $shim_src $out >nul 2>&1 || copy /Y /B $shim_src $out >nul )"`, + command: hostWin + ? `cmd /c "${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` + + `( fc /b $shim_src $out >nul 2>&1 || copy /Y /B $shim_src $out >nul )"` + : `${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` + + `( cmp -s $shim_src $out 2>/dev/null || cp $shim_src $out )`, description: "cargo bun_shim_impl → $out", pool: "console", restat: true, @@ -710,6 +717,10 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string "-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"); n.build({ outputs: [shimDest], diff --git a/scripts/build/source.ts b/scripts/build/source.ts index 70707a8d6e09..d43493aff481 100644 --- a/scripts/build/source.ts +++ b/scripts/build/source.ts @@ -628,9 +628,11 @@ export function registerDepRules(n: Ninja, cfg: Config): void { // DirectBuild host tool: compile+link in one clang invocation with NO // cfg target/arch flags — the tool runs on the build host. cc()/link() - // would add --target which breaks cross-compiles. + // would add --target which breaks cross-compiles. cfg.hostCc (not cfg.cc): + // when cross-compiling for windows, cc is clang-cl and defaults to a + // *-windows-msvc triple — host tools must stay plain clang. n.rule("dep_host_cc", { - command: `${q(cfg.cc)} $flags -o $out $in`, + command: `${q(cfg.hostCc)} $flags -o $out $in`, description: "host-cc $out", }); diff --git a/scripts/build/tools.ts b/scripts/build/tools.ts index 74c89b0533b1..ff216313129c 100644 --- a/scripts/build/tools.ts +++ b/scripts/build/tools.ts @@ -366,16 +366,26 @@ function findLlvmTool( * 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" @@ -400,15 +410,19 @@ export function resolveLlvmToolchain( // 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(os === "windows" ? "clang-cl" : "clang", paths, os, { + const ccResult = findLlvmTool(msvcTarget ? "clang-cl" : "clang", paths, os, { checkVersion: true, required: true, }); - const cxx = findLlvmTool(os === "windows" ? "clang-cl" : "clang++", paths, os, { + const cxx = findLlvmTool(msvcTarget ? "clang-cl" : "clang++", paths, os, { checkVersion: false, required: true, })?.path; @@ -416,9 +430,10 @@ export function resolveLlvmToolchain( // Resource dir (builtin headers live at /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 on Windows where nothing consumes it. + // skipped for windows targets, where nothing consumes it (and cc is + // clang-cl, which takes MSVC-style flags). let clangResourceDir: string | undefined; - if (os !== "windows") { + if (!msvcTarget) { const probe = spawnSync(ccResult!.path, ["-print-resource-dir"], { encoding: "utf8", timeout: 30_000, @@ -430,15 +445,28 @@ export function resolveLlvmToolchain( } } - // ar: llvm-ar (or llvm-lib on Windows) + // 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(os === "windows" ? "llvm-lib" : "llvm-ar", paths, os, { + const ar = findLlvmTool(msvcTarget ? "llvm-lib" : "llvm-ar", paths, os, { checkVersion: false, required: true, })?.path; - // ranlib: llvm-ranlib (unix only — Windows uses llvm-lib which doesn't need it) + // 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; @@ -449,10 +477,10 @@ export function resolveLlvmToolchain( })?.path; } - // ld: ld.lld on Linux (passed as --ld-path=), lld-link on Windows. + // 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 (os === "windows") { + 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 ?? ""; @@ -491,25 +519,26 @@ export function resolveLlvmToolchain( dsymutil = findLlvmTool("dsymutil", paths, os, { checkVersion: false, required: false })?.path; } - // rc/mt: windows 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); mt is optional (not all LLVM distros ship it — source.ts sets + // 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 (os === "windows") { + 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 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. + // 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 (os === "windows") { + if (msvcTarget) { nasm = findTool({ names: ["nasm"], // boringssl's win-x64 .asm needs nasm; win-aarch64 uses gas .S. @@ -517,7 +546,10 @@ export function resolveLlvmToolchain( // resolveToolchain(). compile.ts:nasm() asserts at the use site // with the same hint, so a missing nasm still fails clearly. required: false, - hint: "Install from https://nasm.us or `winget install NASM.NASM`", + 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; } @@ -542,6 +574,8 @@ export function resolveLlvmToolchain( clangVersion: ccResult.version, clangResourceDir, cxx, + hostCc, + hostCxx, ar, ranlib, ld, From eee64c453874dd4192b9c075232dcba0ed4e1f70 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 02:47:12 +0000 Subject: [PATCH 02/26] docs: fix cfg.hostCxx references and cross-compile build dir name --- docs/project/building-windows.mdx | 2 +- scripts/build/CLAUDE.md | 4 ++-- scripts/build/cargo-config.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index 1f98da80eaa2..ea32dcb387e2 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -175,6 +175,6 @@ bun run build --profile=windows-x64-release bun run build --profile=windows-arm64-release ``` -Output lands in `build/debug-windows-x64/bun-debug.exe`, `build/release-windows-arm64/bun-profile.exe` + `bun.exe`, etc. Equivalent raw flags: `bun run build --os=windows --arch=aarch64`. +Output lands in `build/debug-windows-x64/bun-debug.exe`, `build/release-windows-aarch64/bun-profile.exe` + `bun.exe`, etc. Equivalent raw flags: `bun run build --os=windows --arch=aarch64`. Cross-compiled executables are not run on the host (the `--revision` smoke test is skipped), so test them on a Windows machine or under Wine. diff --git a/scripts/build/CLAUDE.md b/scripts/build/CLAUDE.md index 7e76073b3165..84ef8ad0f2b9 100644 --- a/scripts/build/CLAUDE.md +++ b/scripts/build/CLAUDE.md @@ -142,7 +142,7 @@ Tables: `cpuTargetFlags` (`-march`/`-mcpu`/`-mtune` — also forwarded to local 1. `resolveToolchain()` — find clang/ar/lld/strip/cmake/cargo/bun/esbuild. Version-checked where it matters; paths stored on `Toolchain`. 2. `resolveConfig(partial, toolchain)` — produce the flat `Config`. Detect host, derive all target booleans, compute paths, read package.json version + git sha. 3. `validateBunConfig(cfg)` + `checkWorkarounds(cfg)` — fail early with clear errors. - - `generateCargoConfig(cfg)` — write the repo-root `.cargo/config.toml` (git-ignored) with the per-target `linker = ` from the discovered `cfg.cxx`. Advisory only for `bun bd` (the ninja cargo edge sets the linker via env); it's there for `cargo build`/`cargo check`/rust-analyzer run directly. + - `generateCargoConfig(cfg)` — write the repo-root `.cargo/config.toml` (git-ignored) with the per-target `linker = ` from the discovered `cfg.hostCxx`. Advisory only for `bun bd` (the ninja cargo edge sets the linker via env); it's there for `cargo build`/`cargo check`/rust-analyzer run directly. 4. `globAllSources()` — one filesystem snapshot of all `.cpp`/`.c`/`.zig`/codegen-input globs. 5. `new Ninja({buildDir})` + `registerAllRules(n, cfg)` — register every rule template. 6. `emitGeneratorRule(n, cfg, partial)` — persist `configure.json`, emit `regen` rule so editing any build script triggers reconfigure. @@ -188,7 +188,7 @@ Split CI modes: `rust-only` (lolhtml+codegen+cargo → libbun_rust.a), `cpp-only | `source.ts` | `Dependency` types, `resolveDep()`, fetch/configure/build emission | | `codegen.ts` | Code generation steps, `emitCodegen()`, `CodegenOutputs` | | `rust.ts` | `cargo build` step, `emitRust()`, `rustLibPath()`, cross-compile matrix | -| `cargo-config.ts` | Generates the git-ignored `.cargo/config.toml` (per-target `linker` from `cfg.cxx`) | +| `cargo-config.ts` | Generates the git-ignored `.cargo/config.toml` (per-target `linker` from `cfg.hostCxx`) | | `bun.ts` | `emitBun()` — assembles deps+codegen+rust+compile+link | | `shims.ts` | Platform/toolchain workaround dylibs, `emitShims()` | | `workarounds.ts` | Self-obsoleting workaround registry, `checkWorkarounds()` | diff --git a/scripts/build/cargo-config.ts b/scripts/build/cargo-config.ts index 8c1c90919a4e..cf70229d414c 100644 --- a/scripts/build/cargo-config.ts +++ b/scripts/build/cargo-config.ts @@ -75,8 +75,8 @@ export function generateCargoConfig(cfg: Config): string { "# pulled in by `bun_bin/build.rs` is built with clang and assumes lld", "# semantics, and libstdc++ is linked statically via the driver — neither", "# of which the default `cc` link handles cleanly. Paths come from the", - "# toolchain `scripts/build/tools.ts` discovered (`cfg.cxx`), so this file", - "# is correct on whatever machine ran configure.", + "# toolchain `scripts/build/tools.ts` discovered (`cfg.hostCxx`), so this", + "# file is correct on whatever machine ran configure.", ]; for (const triple of allRustTargets) { From b9c61d9ee96287b342bf98217ba3c40623cb5478 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 03:18:35 +0000 Subject: [PATCH 03/26] ci: add Windows cross-compile build lanes; always fetch the winsysroot in CI The two new soft-fail Buildkite steps (windows-{x64,aarch64}-cross-build) do a full compile + link of bun.exe from Linux agents. CI no longer relies on agent images carrying an xwin splat: when cross-compiling for Windows with --ci/--buildkite and no sysroot is configured, build.ts fetches one into the per-build cache via scripts/build/winsysroot.ts (pinned xwin release, same splat layout as the documented local setup). The image-level splat provisioning in bootstrap.sh/.buildkite/Dockerfile is dropped accordingly; nasm stays in the Dockerfile for BoringSSL's win-x64 assembly. --- .buildkite/Dockerfile | 17 +---- .buildkite/ci.mjs | 56 +++++++++++++++ scripts/bootstrap.sh | 50 -------------- scripts/build.ts | 7 ++ scripts/build/config.ts | 19 ++++-- scripts/build/winsysroot.ts | 133 ++++++++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 70 deletions(-) create mode 100644 scripts/build/winsysroot.ts diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index e476056e1917..987ee615dba6 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -174,20 +174,9 @@ RUN FBSD_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "am && rm /tmp/base.txz ENV FREEBSD_SYSROOT=/opt/freebsd-sysroot -# Windows sysroot — xwin splat of the MSVC CRT/STL + Windows SDK (VS layout) -# for --os=windows cross-compile; clang-cl/lld-link consume it via -# /winsysroot (see scripts/build/config.ts `winsysroot`). Both target arches -# in one splat; --include-debug-libs so /MTd debug links work. -# --accept-license accepts the Microsoft license terms for the SDK/CRT -# components, same as the Windows CI images do when installing VS Build Tools. -ARG XWIN_VERSION="0.6.7" -RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo "x86_64"; fi) \ - && curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl.tar.gz" \ - | tar -xz -C /tmp \ - && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ - && rm -rf /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl /tmp/xwin-cache -ENV WINDOWS_SYSROOT=/opt/winsysroot +# No Windows sysroot is baked into the image: --os=windows cross-compile +# builds always fetch their own MSVC CRT + Windows SDK splat at build time +# (scripts/build/winsysroot.ts), so CI doesn't depend on image contents. RUN ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "amd64"; fi) && \ echo "Downloading buildkite" && \ diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 1201ff86de3d..43c0bde99cd4 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -629,6 +629,45 @@ function getLinkBunStep(platform, options) { }; } +/** + * Cross-compiled Windows build (full compile + link on a Linux agent). + * Validates that bun.exe for the given arch can be built from Linux with + * clang-cl + lld-link + an xwin Windows sysroot, which build.ts fetches at + * the start of the step (scripts/build/winsysroot.ts). The produced binary + * is not consumed by tests or release — the native Windows lanes above stay + * authoritative — so the step is soft_fail until it has a green history. + * + * Runs on the same amazonlinux docker image the other Linux/cross builds + * use; `--buildkite=off` keeps the per-step artifact upload/download + * machinery (which assumes the cpp/rust/link split and native artifact + * names) out of the picture. + * + * @param {Arch} arch + * @param {PipelineOptions} options + * @returns {Step} + */ +function getWindowsCrossBuildStep(arch, options) { + const hostPlatform = { os: "linux", arch, distro: "amazonlinux", release: "2023", features: ["docker"] }; + return { + key: `windows-${arch}-cross-build`, + label: `${getBuildkiteEmoji("windows")} ${arch}-cross - build-bun`, + agents: getEc2Agent(hostPlatform, options, { + // Full build (deps + C++ + cargo + link) in one step — size for cores. + instanceType: arch === "aarch64" ? "r8g.4xlarge" : "r7i.4xlarge", + }), + retry: getRetry(), + cancel_on_build_failing: isMergeQueue(), + soft_fail: true, + timeout_in_minutes: 120, + command: [ + // BoringSSL's win-x64 assembly is NASM syntax; newer images carry nasm + // (Dockerfile), best-effort install on older ones. + ...(arch === "x64" ? ["which nasm || (apt-get update -qq && apt-get install -y -qq nasm) || true"] : []), + `node --experimental-strip-types scripts/build.ts --profile=ci-release --os=windows --arch=${arch} --buildkite=off`, + ], + }; +} + /** * Returns the artifact triplet for a platform, e.g. "bun-linux-aarch64" or "bun-linux-x64-musl-baseline". * Matches the naming convention in cmake/targets/BuildBun.cmake. @@ -1428,6 +1467,23 @@ async function getPipeline(options = {}) { ); }), ); + + // Windows cross-compile validation: full builds of bun.exe (x64 + arm64) + // from Linux agents. See getWindowsCrossBuildStep(). + const crossImageDependsOn = ["x64", "aarch64"] + .map(arch => getImageKey({ os: "linux", arch, distro: "amazonlinux", release: "2023", features: ["docker"] })) + .filter(imageKey => imagePlatforms.has(imageKey)) + .map(imageKey => `${imageKey}-build-image`); + steps.push( + getStepWithDependsOn( + { + key: "windows-cross", + group: `${getBuildkiteEmoji("windows")} cross (linux)`, + steps: [getWindowsCrossBuildStep("x64", options), getWindowsCrossBuildStep("aarch64", options)], + }, + ...crossImageDependsOn, + ), + ); } if (!isMainBranch()) { diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index b97c61d16dfd..0e855bf5371f 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1128,7 +1128,6 @@ install_build_essentials() { linux) install_packages \ make \ - nasm \ python3 \ libtool \ ruby \ @@ -1143,7 +1142,6 @@ install_build_essentials() { install_rust install_android_ndk install_freebsd_sysroot - install_windows_sysroot install_ccache install_docker } @@ -1406,54 +1404,6 @@ install_freebsd_sysroot() { # arch-appropriate /opt/freebsd-sysroot{,-arm64} by well-known path. } -xwin_version() { - print "0.6.7" -} - -install_windows_sysroot() { - case "$os" in - linux) ;; - *) return ;; - esac - - # MSVC CRT/STL + Windows SDK splat for --os=windows cross-compiles, - # laid out like a Visual Studio install so clang-cl/lld-link's - # /winsysroot flag works (see scripts/build/config.ts `winsysroot`). - # Fetched with xwin, which downloads the components from Microsoft's CDN; - # --accept-license accepts the Microsoft Software License Terms for the - # Build Tools/SDK on behalf of this machine (same terms the Windows CI - # images accept when installing VS Build Tools). - sysroot="/opt/winsysroot" - # Same sentinel detectWindowsSysroot() uses, plus an SDK import lib so a - # half-splatted (interrupted) sysroot isn't treated as complete. - if [ -d "$sysroot/Windows Kits/10/Include" ] && ls "$sysroot/Windows Kits/10/Lib"/*/um/x64/kernel32.lib >/dev/null 2>&1; then - return - fi - - xwin_ver="$(xwin_version)" - case "$arch" in - aarch64) xwin_triple="aarch64-unknown-linux-musl" ;; - *) xwin_triple="x86_64-unknown-linux-musl" ;; - esac - xwin_tar=$(download_file "https://github.com/Jake-Shadle/xwin/releases/download/${xwin_ver}/xwin-${xwin_ver}-${xwin_triple}.tar.gz") - xwin_dir="$(dirname "$xwin_tar")/xwin-extract" - execute mkdir -p "$xwin_dir" - execute tar -xzf "$xwin_tar" -C "$xwin_dir" --strip-components=1 - - execute_sudo rm -rf "$sysroot" - execute_sudo mkdir -p "$sysroot" - # Both target arches in one splat; --include-debug-libs so /MTd (debug - # CRT) links work; winsysroot-style + MS arch notation so clang-cl and - # lld-link resolve it with a single /winsysroot flag; symlinks stay ON - # (default) to fix include casing on a case-sensitive filesystem. - execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --cache-dir "$xwin_dir/cache" \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ - --output "$sysroot" - execute_sudo rm -rf "$xwin_dir" - # No WINDOWS_SYSROOT export — detectWindowsSysroot() picks up - # /opt/winsysroot by well-known path. -} - install_docker() { case "$pm" in brew) diff --git a/scripts/build.ts b/scripts/build.ts index 3b55b1abcad1..41b2f94ad6e0 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -37,6 +37,7 @@ import { configure, type ConfigureInput, type ConfigureResult } from "./build/co import { BuildError } from "./build/error.ts"; import { STREAM_FD } from "./build/stream.ts"; import { interactive, nameColor, status } from "./build/tty.ts"; +import { ensureWindowsSysroot } from "./build/winsysroot.ts"; // ─────────────────────────────────────────────────────────────────────────── // Main @@ -103,6 +104,12 @@ async function main(): Promise { const result = (await startGroup("Configure", () => configure(input))) as ConfigureResult; if (args.configureOnly) return; + // Windows cross-compile: CI always fetches its own MSVC CRT + Windows SDK + // splat (no-op when the resolved winsysroot is already complete). + if (result.cfg.windows && result.cfg.host.os !== "windows") { + await startGroup("Fetch Windows sysroot", () => ensureWindowsSysroot(result.cfg)); + } + // link-only: download cpp-only + rust-only artifacts before ninja. if (result.cfg.buildkite && result.cfg.mode === "link-only") { await startGroup("Download artifacts", () => downloadArtifacts(result.cfg)); diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 22889c477c44..a2d399bfe81d 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -943,12 +943,19 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con : resolve(cwd, partial.winsysroot) : detectWindowsSysroot(); if (winsysroot === undefined) { - throw new BuildError("--os=windows requires a Windows sysroot (MSVC CRT + Windows SDK) when cross-compiling", { - hint: - "Set WINDOWS_SYSROOT or pass --winsysroot=. Create one with xwin (https://github.com/Jake-Shadle/xwin):\n" + - " cargo install xwin (or download a release binary)\n" + - " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot", - }); + if (ci || buildkite) { + // CI always fetches its own sysroot into the per-build cache (see + // winsysroot.ts `ensureWindowsSysroot`, called from build.ts before + // ninja runs) instead of relying on agent image provisioning. + winsysroot = resolve(cacheDir, "winsysroot"); + } else { + throw new BuildError("--os=windows requires a Windows sysroot (MSVC CRT + Windows SDK) when cross-compiling", { + hint: + "Set WINDOWS_SYSROOT or pass --winsysroot=. Create one with xwin (https://github.com/Jake-Shadle/xwin):\n" + + " cargo install xwin (or download a release binary)\n" + + " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot", + }); + } } if (partial.webkit === "local") { throw new BuildError("Cross-compiling for Windows requires the prebuilt WebKit (webkit=local needs msbuild)", { diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts new file mode 100644 index 000000000000..5bbd9058ffd0 --- /dev/null +++ b/scripts/build/winsysroot.ts @@ -0,0 +1,133 @@ +/** + * Windows sysroot (xwin splat) fetcher — CI path for Windows cross-compiles. + * + * Cross-compiling for Windows needs the MSVC CRT/STL + Windows SDK headers + * and import libraries (see `Config.winsysroot`). Local builds point at a + * sysroot the developer created once (docs/project/building-windows.mdx); + * CI always fetches one into the per-build cache dir so the build doesn't + * depend on what the agent image happens to carry. + * + * The fetch is two steps, both pinned: + * 1. Download the xwin release binary for the build host (GitHub). + * 2. Run `xwin splat` — xwin downloads the CRT/SDK packages from + * Microsoft's CDN and lays them out like a Visual Studio install so a + * single `/winsysroot` flag works for clang-cl and lld-link. + * `--accept-license` accepts Microsoft's license terms for those + * components (the same terms the Windows CI images accept when + * installing VS Build Tools). + * + * Idempotent: a sentinel check (SDK include dir + kernel32.lib import libs + * for both arches) makes re-runs a no-op, so calling this on every CI build + * only costs time when the cache dir is fresh. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { Config } from "./config.ts"; +import { downloadWithRetry, extractTarGz } from "./download.ts"; +import { BuildError } from "./error.ts"; + +/** Pinned xwin release — https://github.com/Jake-Shadle/xwin/releases */ +export const XWIN_VERSION = "0.6.7"; + +/** + * Does `dir` look like a complete winsysroot? Checks the SDK include tree + * plus the kernel32 import lib for both target arches so an interrupted + * splat isn't treated as complete. Mirrors `detectWindowsSysroot()`'s + * sentinel (config.ts), with the extra per-arch lib check. + */ +export function isCompleteWindowsSysroot(dir: string): boolean { + const sdkLib = join(dir, "Windows Kits", "10", "Lib"); + if (!existsSync(join(dir, "Windows Kits", "10", "Include")) || !existsSync(sdkLib)) return false; + for (const arch of ["x64", "arm64"]) { + const found = sdkVersionDirs(sdkLib).some(ver => existsSync(join(sdkLib, ver, "um", arch, "kernel32.lib"))); + if (!found) return false; + } + return true; +} + +function sdkVersionDirs(sdkLib: string): string[] { + try { + return readdirSync(sdkLib); + } catch { + return []; + } +} + +/** xwin release triple for the machine running the build. */ +function xwinHostTriple(cfg: Config): string { + const arch = cfg.host.arch === "aarch64" ? "aarch64" : "x86_64"; + switch (cfg.host.os) { + case "linux": + return `${arch}-unknown-linux-musl`; + case "darwin": + return `${arch}-apple-darwin`; + default: + throw new BuildError(`No xwin release for host ${cfg.host.os}-${cfg.host.arch}`, { + hint: "Provide a Windows sysroot via WINDOWS_SYSROOT / --winsysroot instead.", + }); + } +} + +/** + * Ensure `cfg.winsysroot` exists and is complete, fetching it with xwin if + * not. No-op for native Windows builds and when the sysroot is already + * present (the common case locally). + */ +export async function ensureWindowsSysroot(cfg: Config): Promise { + if (!cfg.windows || cfg.host.os === "windows" || cfg.winsysroot === undefined) return; + const dest = cfg.winsysroot; + if (isCompleteWindowsSysroot(dest)) return; + + // ─── 1. xwin binary ─── + const triple = xwinHostTriple(cfg); + const xwinDir = resolve(cfg.cacheDir, `xwin-${XWIN_VERSION}`); + const xwinExe = join(xwinDir, `xwin-${XWIN_VERSION}-${triple}`, "xwin"); + if (!existsSync(xwinExe)) { + const url = `https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${triple}.tar.gz`; + const tarball = join(xwinDir, `xwin-${triple}.tar.gz`); + mkdirSync(xwinDir, { recursive: true }); + console.log(`downloading ${url}`); + await downloadWithRetry(url, tarball, "xwin"); + // Keep the release's top-level dir (strip=0) so the exe path is stable. + await extractTarGz(tarball, xwinDir, 0); + if (!existsSync(xwinExe)) { + throw new BuildError(`xwin binary not found after extraction: ${xwinExe}`); + } + } + + // ─── 2. Splat the MSVC CRT + Windows SDK ─── + // Both target arches in one splat; --include-debug-libs so /MTd (debug + // CRT) links work; winsysroot-style + MS arch notation so clang-cl and + // lld-link resolve it with a single /winsysroot flag; symlinks stay ON + // (default) to fix include casing on a case-sensitive filesystem. + console.log(`fetching MSVC CRT + Windows SDK into ${dest} (xwin splat)`); + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dest, { recursive: true }); + const args = [ + "--accept-license", + "--arch", + "x86_64,aarch64", + "--cache-dir", + join(cfg.cacheDir, "xwin-dl"), + "splat", + "--use-winsysroot-style", + "--preserve-ms-arch-notation", + "--include-debug-libs", + "--output", + dest, + ]; + const result = spawnSync(xwinExe, args, { stdio: "inherit" }); + if (result.error || result.status !== 0) { + throw new BuildError(`xwin splat failed${result.status !== null ? ` (exit ${result.status})` : ""}`, { + cause: result.error, + hint: "The MSVC CRT / Windows SDK download from Microsoft's CDN failed — check network access, or provide a sysroot via WINDOWS_SYSROOT / --winsysroot.", + }); + } + if (!isCompleteWindowsSysroot(dest)) { + throw new BuildError(`xwin splat finished but ${dest} is missing expected SDK files`, { + hint: "Delete the directory and retry, or provide a sysroot via WINDOWS_SYSROOT / --winsysroot.", + }); + } +} From d29906712ab76e68dc078d90148163f407498426 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 03:46:30 +0000 Subject: [PATCH 04/26] Address review feedback on Windows cross-compile support - ci.mjs: make the nasm fallback install distro-aware (apt/dnf/yum) - winsysroot.ts: refuse to wipe directories that don't look like a Windows sysroot before re-splatting - rust.ts: key the bun_shim_impl edge on a per-build-dir stamp and treat the shared source-tree exe as an input, so alternating x64/arm64 builds in one checkout can't embed a stale wrong-arch shim - compile.ts/boringssl.ts: host-aware nasm hint and nasm -I quoting - config.ts: never swap cfg.ld to rust-lld for windows targets - build.ts: document os/arch/abi/winsysroot and the windows cross profiles in --help --- .buildkite/ci.mjs | 11 ++++++-- scripts/build.ts | 7 +++-- scripts/build/compile.ts | 5 +++- scripts/build/config.ts | 6 ++++ scripts/build/deps/boringssl.ts | 8 +++++- scripts/build/rust.ts | 49 +++++++++++++++++++++++---------- scripts/build/winsysroot.ts | 21 +++++++++++++- 7 files changed, 86 insertions(+), 21 deletions(-) diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 43c0bde99cd4..0d6fb509c99f 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -661,8 +661,15 @@ function getWindowsCrossBuildStep(arch, options) { timeout_in_minutes: 120, command: [ // BoringSSL's win-x64 assembly is NASM syntax; newer images carry nasm - // (Dockerfile), best-effort install on older ones. - ...(arch === "x64" ? ["which nasm || (apt-get update -qq && apt-get install -y -qq nasm) || true"] : []), + // (Dockerfile), best-effort install on older ones. Distro-aware: the + // agent may be the Ubuntu-based build container (apt) or an Amazon + // Linux host (dnf/yum). `|| true` keeps a missing package manager from + // failing the step — the build's own "nasm not found" error is clearer. + ...(arch === "x64" + ? [ + "which nasm || (apt-get update -qq && apt-get install -y -qq nasm) || dnf install -y -q nasm || yum install -y -q nasm || sudo dnf install -y -q nasm || true", + ] + : []), `node --experimental-strip-types scripts/build.ts --profile=ci-release --os=windows --arch=${arch} --buildkite=off`, ], }; diff --git a/scripts/build.ts b/scripts/build.ts index 41b2f94ad6e0..a996e4389195 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -509,13 +509,16 @@ Options: --profile= Build profile (default: debug) Profiles: debug, debug-local, debug-no-asan, release, release-local, release-asan, - release-assertions, ci-* + release-assertions, ci-*, + windows-{x64,arm64}[-release] (cross-compile + from a non-Windows host) --= Override a config field. Boolean fields take on/off/true/false/yes/no/1/0. Fields: asan, lto, assertions, logs, baseline, canary, valgrind, webkit (prebuilt|local), buildDir, mode (full|cpp-only|link-only), - unifiedSources, timeTrace + unifiedSources, timeTrace, os, arch, abi, + winsysroot (Windows cross-compile SDK root) --target= Build a specific ninja target (repeatable) --configure-only Emit build.ninja, don't run it -j, -v, -k Passed through to ninja diff --git a/scripts/build/compile.ts b/scripts/build/compile.ts index bddae5acb190..1e520084cc2a 100644 --- a/scripts/build/compile.ts +++ b/scripts/build/compile.ts @@ -251,7 +251,10 @@ export function nasm( ): string { assert(extname(src) === ".asm", `nasm() expects .asm source, got: ${src}`); assert(cfg.nasm !== undefined, "nasm not found in toolchain", { - hint: "Install from https://nasm.us or `winget install NASM.NASM`", + hint: + cfg.host.os === "windows" + ? "Install from https://nasm.us or `winget install NASM.NASM`" + : "Install nasm from your distro (apt/dnf/brew install nasm) or https://nasm.us", }); const out = objectPath(cfg, src); n.build({ diff --git a/scripts/build/config.ts b/scripts/build/config.ts index a2d399bfe81d..77f3653cbbb3 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -780,7 +780,13 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // Shared with the darwin-cross ld64 swap below: for darwin targets // findRustLld() resolves rustc's `gcc-ld/ld64.lld` (the Mach-O flavor of // the same rust-lld), so the swap composes with the cross toolchain. + // Never swap for windows targets: `ld` there is lld-link (COFF driver) + // and `findRustLld()` resolves the HOST-flavored gcc-ld/ld.lld, which + // can't stand in for it (cargo's msvc linker + nested cmake would both + // receive the wrong flavor). Windows builds don't use cross-language + // LTO anyway (no -lto WebKit prebuilt), so nothing is lost. const wantRustLld = + !windows && crossLangLto && toolchain.rustLld !== undefined && clangMajor !== undefined && diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 9bdf58e3e1a9..0b367a71a445 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -70,7 +70,13 @@ export const boringssl: Dependency = { // nasm needs -I with a trailing slash and CodeView debug info to // match cmake's `-gcv8`. Absolute path quoted — a checkout root // with a space (C:\Users\John Doe\bun) would otherwise split argv. - nasmflags: ["-fwin64", "-gcv8", `-I${quote(depSourceDir(cfg, "boringssl") + "/gen/", true)}`], + // Quote style follows the HOST shell (cmd natively, sh when + // cross-compiling win-x64 from linux). + nasmflags: [ + "-fwin64", + "-gcv8", + `-I${quote(depSourceDir(cfg, "boringssl") + "/gen/", cfg.host.os === "windows")}`, + ], }; return spec; }, diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 4fac1099378e..18709b221aed 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -24,7 +24,7 @@ * the dynamic-list / NAPI surface (no inbound static ref) are retained too. */ -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { bunExeName, type Config } from "./config.ts"; import { assert } from "./error.ts"; @@ -248,12 +248,20 @@ export function registerRustRules(n: Ninja, cfg: Config): void { // source-tree path (cargo's own output path is an undeclared intermediate). // // Copy is *content-conditional* (`fc /b` / `cmp -s` returns 0 iff bytes - // match) so `restat` actually prunes: any `.rs` edit re-invokes this rule - // (it shares `rustSources` with the main build), cargo no-ops, and a blind - // copy would still bump $out'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 mtime stable and - // lets `restat` cut the edge. + // 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). @@ -261,10 +269,10 @@ export function registerRustRules(n: Ninja, cfg: Config): void { n.rule("rust_shim", { command: hostWin ? `cmd /c "${stream} --cwd=$cwd $env ${q(cfg.cargo)} build $args && ` + - `( fc /b $shim_src $out >nul 2>&1 || copy /Y /B $shim_src $out >nul )"` + `( 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 $out 2>/dev/null || cp $shim_src $out )`, - description: "cargo bun_shim_impl → $out", + `( 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", restat: true, }); @@ -722,8 +730,18 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string // 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: [shimDest], + outputs: [shimStamp], rule: "rust_shim", inputs: [], // Same staleness signal as the main build (any .rs / Cargo.toml change @@ -731,18 +749,21 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string // 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. - implicitInputs: [cfg.cargo, ...inputs.rustSources, ...inputs.vendorStamps], + // 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", [shimDest]); - shimInputs.push(shimDest); + n.phony("bun-shim", [shimStamp]); + shimInputs.push(shimStamp); } // ─── Emit build node ─── diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts index 5bbd9058ffd0..49807891b8d0 100644 --- a/scripts/build/winsysroot.ts +++ b/scripts/build/winsysroot.ts @@ -23,7 +23,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import type { Config } from "./config.ts"; import { downloadWithRetry, extractTarGz } from "./download.ts"; import { BuildError } from "./error.ts"; @@ -102,6 +102,25 @@ export async function ensureWindowsSysroot(cfg: Config): Promise { // CRT) links work; winsysroot-style + MS arch notation so clang-cl and // lld-link resolve it with a single /winsysroot flag; symlinks stay ON // (default) to fix include casing on a case-sensitive filesystem. + // + // The incomplete previous attempt is wiped before re-splatting, but only + // when `dest` actually looks like a (partial) sysroot — a mistyped + // WINDOWS_SYSROOT / --winsysroot pointing at real data should error, not + // be deleted. dirname(dest) === dest catches "/" and drive roots. + if (!isAbsolute(dest) || dirname(dest) === dest) { + throw new BuildError(`Refusing to create a Windows sysroot at ${JSON.stringify(dest)}`, { + hint: "WINDOWS_SYSROOT / --winsysroot must be an absolute, non-root directory.", + }); + } + if (existsSync(dest)) { + const looksLikeSysroot = + existsSync(join(dest, "Windows Kits")) || existsSync(join(dest, "VC")) || readdirSync(dest).length === 0; + if (!looksLikeSysroot) { + throw new BuildError(`Refusing to replace ${dest}: it exists but does not look like a Windows sysroot`, { + hint: "Point WINDOWS_SYSROOT / --winsysroot at an xwin splat (or an empty directory), or delete it manually if it should be re-created.", + }); + } + } console.log(`fetching MSVC CRT + Windows SDK into ${dest} (xwin splat)`); rmSync(dest, { recursive: true, force: true }); mkdirSync(dest, { recursive: true }); From 4f53a8a5293a78ade69af4f5318b7b6a7f9ebcec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 03:53:20 +0000 Subject: [PATCH 05/26] Fetch the Windows sysroot during configure, before the graph is emitted emitBun() bakes the winsysroot's MSVC/SDK include dirs into the llvm-rc edge at configure time, so fetching the sysroot after configure (as the previous commit did) left the rc edge without /I flags on a fresh CI agent. Move ensureWindowsSysroot() into configure() (CI only, still a no-op when the sysroot is complete) and fail configure loudly if the sysroot yields no include dirs. --- scripts/build.ts | 7 ------- scripts/build/bun.ts | 11 ++++++++++- scripts/build/configure.ts | 12 ++++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index a996e4389195..193146cc6938 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -37,7 +37,6 @@ import { configure, type ConfigureInput, type ConfigureResult } from "./build/co import { BuildError } from "./build/error.ts"; import { STREAM_FD } from "./build/stream.ts"; import { interactive, nameColor, status } from "./build/tty.ts"; -import { ensureWindowsSysroot } from "./build/winsysroot.ts"; // ─────────────────────────────────────────────────────────────────────────── // Main @@ -104,12 +103,6 @@ async function main(): Promise { const result = (await startGroup("Configure", () => configure(input))) as ConfigureResult; if (args.configureOnly) return; - // Windows cross-compile: CI always fetches its own MSVC CRT + Windows SDK - // splat (no-op when the resolved winsysroot is already complete). - if (result.cfg.windows && result.cfg.host.os !== "windows") { - await startGroup("Fetch Windows sysroot", () => ensureWindowsSysroot(result.cfg)); - } - // link-only: download cpp-only + rust-only artifacts before ninja. if (result.cfg.buildkite && result.cfg.mode === "link-only") { await startGroup("Download artifacts", () => downloadArtifacts(result.cfg)); diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index 8666605d394b..4235465195f5 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -838,7 +838,16 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { const hostWin = cfg.host.os === "windows"; const rcFlags: string[] = []; if (cfg.winsysroot !== undefined) { - for (const dir of windowsSysrootIncludeDirs(cfg.winsysroot)) { + const includeDirs = windowsSysrootIncludeDirs(cfg.winsysroot); + // The include dirs are baked into the rc edge at configure time, so the + // sysroot must already be populated (configure.ts fetches it in CI + // before emitBun). An empty set would only surface later as a cryptic + // llvm-rc "windows.h not found" — fail here with the real cause instead. + assert( + includeDirs.length > 0, + `Windows sysroot at ${cfg.winsysroot} has no MSVC/SDK include dirs — is it a complete xwin splat?`, + ); + for (const dir of includeDirs) { rcFlags.push("/I", quote(dir, hostWin)); } } diff --git a/scripts/build/configure.ts b/scripts/build/configure.ts index 523b9e30846d..5359730192a9 100644 --- a/scripts/build/configure.ts +++ b/scripts/build/configure.ts @@ -28,6 +28,7 @@ import { getProfile } from "./profiles.ts"; import { registerAllRules } from "./rules.ts"; import { quote } from "./shell.ts"; import { findBun, findCargo, findMsvcLinker, findSystemTool, resolveLlvmToolchain } from "./tools.ts"; +import { ensureWindowsSysroot } from "./winsysroot.ts"; import { checkWorkarounds } from "./workarounds.ts"; /** @@ -273,6 +274,17 @@ export async function configure(input: ConfigureInput): Promise checkWorkarounds(cfg); + // Windows cross-compile, CI only: fetch the MSVC CRT + Windows SDK splat + // into the per-build cache BEFORE the graph is emitted — emitBun() + // enumerates its include dirs (llvm-rc's /I flags) at configure time, so + // the sysroot must exist by then, not just before ninja runs. No-op when + // the resolved winsysroot is already complete. Local builds never fetch: + // resolveConfig() already required a provisioned sysroot (or errored). + if (cfg.windows && cfg.host.os !== "windows" && (cfg.ci || cfg.buildkite)) { + await ensureWindowsSysroot(cfg); + mark("ensureWindowsSysroot"); + } + // Generated `.cargo/config.toml` — written at configure time (not a ninja // rule), like `bun_dependency_versions.h`. Holds the per-target `linker = ` // (the discovered clang++ from `tools.ts`) so a contributor running `cargo` From 75f44c569c9f01bfd54ef15b20366138541ff682 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 04:18:04 +0000 Subject: [PATCH 06/26] Gate cross-language LTO off for Windows targets An explicit --lto=on on a Windows cross profile previously resolved crossLangLto=true while the rust-lld swap was suppressed, tripping validateBunConfig's rust-lld check with a misleading error. Windows never uses cross-language LTO (the C++ -flto flags are unix-only and there's no -lto WebKit prebuilt), so resolve crossLangLto=false there and drop the now-redundant !windows guard on the ld swap. --- scripts/build/config.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 77f3653cbbb3..d2e47a490f53 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -752,15 +752,20 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // Cross-language LTO normally tracks `lto`. Gated off for aarch64-musl // where LLVM's `globalopt` pass segfaults on the `bun_runtime` bitcode - // module during the merged link (CI build #53109). Both halves still LTO - // independently when this is false — only the Rust↔C++ inlining is lost. + // module during the merged link (CI build #53109), and for Windows + // targets — the C++ side's `-flto` flags are unix-only, there's no -lto + // WebKit prebuilt for Windows, and the rust-lld swap below resolves the + // HOST-flavored ld.lld which can't stand in for lld-link (an explicit + // `--lto=on` would otherwise trip validateBunConfig's rust-lld check + // with a misleading error). Both halves still LTO independently when + // this is false — only the Rust↔C++ inlining is lost. // Tracked in workarounds.ts ("globalopt-crash-aarch64-musl"). // Darwin cross uses the same rust-lld swap as ELF: rustc's sysroot ships // `gcc-ld/ld64.lld` (rust-lld in the Mach-O flavor, built against rustc's // LLVM), which findRustLld() already resolves for darwin targets, so the // newer-LLVM bitcode rustc emits under -Clinker-plugin-lto is readable at // link time. - const crossLangLto = lto && !(arm64 && abi === "musl"); + const crossLangLto = lto && !windows && !(arm64 && abi === "musl"); // Cross-language LTO bitcode-version skew: `-Clinker-plugin-lto` makes // rustc emit raw LLVM bitcode into libbun_rust.a. LLVM bitcode is @@ -780,13 +785,10 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // Shared with the darwin-cross ld64 swap below: for darwin targets // findRustLld() resolves rustc's `gcc-ld/ld64.lld` (the Mach-O flavor of // the same rust-lld), so the swap composes with the cross toolchain. - // Never swap for windows targets: `ld` there is lld-link (COFF driver) - // and `findRustLld()` resolves the HOST-flavored gcc-ld/ld.lld, which - // can't stand in for it (cargo's msvc linker + nested cmake would both - // receive the wrong flavor). Windows builds don't use cross-language - // LTO anyway (no -lto WebKit prebuilt), so nothing is lost. + // (No explicit windows guard needed: crossLangLto is already false for + // windows targets, and `ld` there is lld-link — the COFF driver — which + // the host-flavored rust-lld could not stand in for.) const wantRustLld = - !windows && crossLangLto && toolchain.rustLld !== undefined && clangMajor !== undefined && From f7c75a1fa23c031f429fe9683a0d714b17802ea7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 04:28:33 +0000 Subject: [PATCH 07/26] docs: list winsysroot.ts in the build-script inventory and fix a stale comment --- scripts/build/CLAUDE.md | 1 + scripts/build/config.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/build/CLAUDE.md b/scripts/build/CLAUDE.md index 84ef8ad0f2b9..38ea9bbf2a0f 100644 --- a/scripts/build/CLAUDE.md +++ b/scripts/build/CLAUDE.md @@ -201,6 +201,7 @@ Split CI modes: `rust-only` (lolhtml+codegen+cargo → libbun_rust.a), `cpp-only | `fs.ts` | `writeIfChanged()`, `mkdirAll()` | | `error.ts` | `BuildError` with hint/file/cause, `assert()` | | `download.ts` | `downloadWithRetry()`, archive extraction | +| `winsysroot.ts` | Fetches the Windows MSVC CRT + SDK sysroot via xwin (CI cross-compile) | | `fetch-cli.ts` | Build-time CLI ninja invokes for downloads | | `ci.ts` | CI integration — annotations, artifacts, log groups | | `clean.ts` | `bun run clean` preset-based cleanup | diff --git a/scripts/build/config.ts b/scripts/build/config.ts index d2e47a490f53..d70465b9667b 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -953,8 +953,9 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con if (winsysroot === undefined) { if (ci || buildkite) { // CI always fetches its own sysroot into the per-build cache (see - // winsysroot.ts `ensureWindowsSysroot`, called from build.ts before - // ninja runs) instead of relying on agent image provisioning. + // winsysroot.ts `ensureWindowsSysroot`, called from configure.ts + // before the graph is emitted) instead of relying on agent image + // provisioning. winsysroot = resolve(cacheDir, "winsysroot"); } else { throw new BuildError("--os=windows requires a Windows sysroot (MSVC CRT + Windows SDK) when cross-compiling", { From 626b84a9818e6dab2868437772d93a2de1fc73a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 04:52:05 +0000 Subject: [PATCH 08/26] Drop the vestigial restat flag on the rust_shim rule --- scripts/build/rust.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 18709b221aed..b0d31b027aaa 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -274,7 +274,9 @@ export function registerRustRules(n: Ninja, cfg: Config): void { `( 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", - restat: true, + // 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. }); } From 00a0df41f08ff2782bbe2cb5973ce7d588df2070 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 06:06:08 +0000 Subject: [PATCH 09/26] Fix Windows sysroot layout handling and bake the splat into CI images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-cross CI lanes failed right after a successful xwin splat: the post-splat completeness check — and clang-cl/lld-link themselves (llvm WindowsDriver/MSVCPaths.cpp, lld COFF/Driver.cpp) — look up the SDK's title-case "Include"/"Lib" directories, while xwin's winsysroot-style output writes them lowercase and only creates title-case aliases for its non-winsysroot layout. - Make the sysroot sentinel case-tolerant and key it on the target arch - Create the Include/Lib aliases at configure time (clear error if the directory isn't writable), for fetched and pre-provisioned sysroots - Give local builds a clear configure-time error when the sysroot is incomplete instead of a cryptic compile failure later - Stop xwin's progress bars from flooding CI logs (stdout is dropped when it isn't a terminal; errors go to stderr and stay visible) Also restore the agent-image provisioning: .buildkite/Dockerfile and scripts/bootstrap.sh bake the same splat at /opt/winsysroot (plus nasm and the aliases); the configure-time fetch stays as the fallback for agents that don't have one baked yet. --- .buildkite/Dockerfile | 24 +++++- .buildkite/ci.mjs | 7 +- docs/project/building-windows.mdx | 2 +- scripts/bootstrap.sh | 60 +++++++++++++ scripts/build/CLAUDE.md | 2 +- scripts/build/bun.ts | 7 +- scripts/build/config.ts | 6 +- scripts/build/configure.ts | 15 ++-- scripts/build/winsysroot.ts | 138 +++++++++++++++++++++++------- 9 files changed, 213 insertions(+), 48 deletions(-) diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index 987ee615dba6..b0ad029b958a 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -174,9 +174,27 @@ RUN FBSD_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "am && rm /tmp/base.txz ENV FREEBSD_SYSROOT=/opt/freebsd-sysroot -# No Windows sysroot is baked into the image: --os=windows cross-compile -# builds always fetch their own MSVC CRT + Windows SDK splat at build time -# (scripts/build/winsysroot.ts), so CI doesn't depend on image contents. +# Windows sysroot — xwin splat of the MSVC CRT/STL + Windows SDK (VS layout) +# for --os=windows cross-compile; clang-cl/lld-link consume it via +# /winsysroot (see scripts/build/config.ts `winsysroot`). Both target arches +# in one splat; --include-debug-libs so /MTd debug links work. +# --accept-license accepts the Microsoft license terms for the SDK/CRT +# components, same as the Windows CI images do when installing VS Build Tools. +# If the image predates this layer, configure falls back to fetching the same +# splat at build time (scripts/build/winsysroot.ts — keep XWIN_VERSION in sync). +# The Include/Lib aliases exist because clang-cl/lld-link compose SDK paths +# in title case while the winsysroot-style splat writes lowercase. +ARG XWIN_VERSION="0.6.7" +RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo "x86_64"; fi) \ + && curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl.tar.gz" \ + | tar -xz -C /tmp \ + && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ + > /dev/null \ + && ln -s include "/opt/winsysroot/Windows Kits/10/Include" \ + && ln -s lib "/opt/winsysroot/Windows Kits/10/Lib" \ + && rm -rf /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl /tmp/xwin-cache +ENV WINDOWS_SYSROOT=/opt/winsysroot RUN ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "amd64"; fi) && \ echo "Downloading buildkite" && \ diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 0d6fb509c99f..215f3543a417 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -632,9 +632,10 @@ function getLinkBunStep(platform, options) { /** * Cross-compiled Windows build (full compile + link on a Linux agent). * Validates that bun.exe for the given arch can be built from Linux with - * clang-cl + lld-link + an xwin Windows sysroot, which build.ts fetches at - * the start of the step (scripts/build/winsysroot.ts). The produced binary - * is not consumed by tests or release — the native Windows lanes above stay + * clang-cl + lld-link + an xwin Windows sysroot — baked into newer agent + * images (.buildkite/Dockerfile), fetched at configure time on agents that + * don't have one (scripts/build/winsysroot.ts). The produced binary is not + * consumed by tests or release — the native Windows lanes above stay * authoritative — so the step is soft_fail until it has a green history. * * Runs on the same amazonlinux docker image the other Linux/cross builds diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index ea32dcb387e2..9608df183b4b 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -161,7 +161,7 @@ xwin --accept-license --arch x86_64,aarch64 splat \ --output /opt/winsysroot ``` -The build looks for the sysroot at `/opt/winsysroot` (or `/opt/xwin`) automatically; elsewhere, set `WINDOWS_SYSROOT=` or pass `--winsysroot=`. +The build looks for the sysroot at `/opt/winsysroot` (or `/opt/xwin`) automatically; elsewhere, set `WINDOWS_SYSROOT=` or pass `--winsysroot=`. Configure validates the splat and adds the title-case `Include`/`Lib` aliases clang-cl and lld-link expect, so the command above is all that's needed. CI agents bake the same splat into their images (`.buildkite/Dockerfile`, `scripts/bootstrap.sh`); when an agent doesn't have one, the build fetches it into its cache dir at configure time. ### Building diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 0e855bf5371f..0a53cd228c2e 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1128,6 +1128,7 @@ install_build_essentials() { linux) install_packages \ make \ + nasm \ python3 \ libtool \ ruby \ @@ -1142,6 +1143,7 @@ install_build_essentials() { install_rust install_android_ndk install_freebsd_sysroot + install_windows_sysroot install_ccache install_docker } @@ -1404,6 +1406,64 @@ install_freebsd_sysroot() { # arch-appropriate /opt/freebsd-sysroot{,-arm64} by well-known path. } +xwin_version() { + # Keep in sync with XWIN_VERSION in scripts/build/winsysroot.ts and + # .buildkite/Dockerfile. + print "0.6.7" +} + +install_windows_sysroot() { + case "$os" in + linux) ;; + *) return ;; + esac + + # MSVC CRT/STL + Windows SDK splat for --os=windows cross-compiles, + # laid out like a Visual Studio install so clang-cl/lld-link's + # /winsysroot flag works (see scripts/build/config.ts `winsysroot`). + # Fetched with xwin, which downloads the components from Microsoft's CDN; + # --accept-license accepts the Microsoft Software License Terms for the + # Build Tools/SDK on behalf of this machine (same terms the Windows CI + # images accept when installing VS Build Tools). Machines that skip this + # step still work: configure fetches the same splat at build time when + # none is present (scripts/build/winsysroot.ts). + sysroot="/opt/winsysroot" + # Same sentinel scripts/build/winsysroot.ts isCompleteWindowsSysroot() + # uses: the SDK lib tree plus a kernel32 import lib, so a half-splatted + # (interrupted) sysroot isn't treated as complete. xwin writes the SDK + # dirs/files lowercase; a copied VS install is title-case — accept both. + if ls "$sysroot/Windows Kits/10/"[Ll]ib/*/um/x64/kernel32.[Ll]ib >/dev/null 2>&1; then + return + fi + + xwin_ver="$(xwin_version)" + case "$arch" in + aarch64) xwin_triple="aarch64-unknown-linux-musl" ;; + *) xwin_triple="x86_64-unknown-linux-musl" ;; + esac + xwin_tar=$(download_file "https://github.com/Jake-Shadle/xwin/releases/download/${xwin_ver}/xwin-${xwin_ver}-${xwin_triple}.tar.gz") + xwin_dir="$(dirname "$xwin_tar")/xwin-extract" + execute mkdir -p "$xwin_dir" + execute tar -xzf "$xwin_tar" -C "$xwin_dir" --strip-components=1 + + execute_sudo rm -rf "$sysroot" + execute_sudo mkdir -p "$sysroot" + # Both target arches in one splat; --include-debug-libs so /MTd (debug + # CRT) links work; winsysroot-style + MS arch notation so clang-cl and + # lld-link resolve it with a single /winsysroot flag; symlinks stay ON + # (default) to fix include/lib casing on a case-sensitive filesystem. + execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --cache-dir "$xwin_dir/cache" \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + --output "$sysroot" + # clang-cl/lld-link compose SDK paths as "Include"/"Lib" (title case); + # the winsysroot-style splat writes lowercase — alias both spellings. + execute_sudo ln -s include "$sysroot/Windows Kits/10/Include" + execute_sudo ln -s lib "$sysroot/Windows Kits/10/Lib" + execute_sudo rm -rf "$xwin_dir" + # No WINDOWS_SYSROOT export — detectWindowsSysroot() picks up + # /opt/winsysroot by well-known path. +} + install_docker() { case "$pm" in brew) diff --git a/scripts/build/CLAUDE.md b/scripts/build/CLAUDE.md index 38ea9bbf2a0f..86b89f9e38c9 100644 --- a/scripts/build/CLAUDE.md +++ b/scripts/build/CLAUDE.md @@ -201,7 +201,7 @@ Split CI modes: `rust-only` (lolhtml+codegen+cargo → libbun_rust.a), `cpp-only | `fs.ts` | `writeIfChanged()`, `mkdirAll()` | | `error.ts` | `BuildError` with hint/file/cause, `assert()` | | `download.ts` | `downloadWithRetry()`, archive extraction | -| `winsysroot.ts` | Fetches the Windows MSVC CRT + SDK sysroot via xwin (CI cross-compile) | +| `winsysroot.ts` | Windows MSVC CRT + SDK sysroot (xwin): validates, adds case aliases, CI fetch | | `fetch-cli.ts` | Build-time CLI ninja invokes for downloads | | `ci.ts` | CI integration — annotations, artifacts, log groups | | `clean.ts` | `bun run clean` preset-based cleanup | diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index 4235465195f5..cd5aed3d386a 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -875,6 +875,8 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { * understand `/winsysroot` themselves (llvm-rc). Layout: * /VC/Tools/MSVC//include * /Windows Kits/10/Include//{ucrt,shared,um} + * The SDK "Include" dir is title-case in a real VS/SDK copy and lowercase + * in an xwin winsysroot-style splat — accept either. */ function windowsSysrootIncludeDirs(winsysroot: string): string[] { const dirs: string[] = []; @@ -885,8 +887,9 @@ function windowsSysrootIncludeDirs(winsysroot: string): string[] { if (existsSync(d)) dirs.push(d); } } - const sdkInclude = resolve(winsysroot, "Windows Kits", "10", "Include"); - if (existsSync(sdkInclude)) { + const sdkRoot = resolve(winsysroot, "Windows Kits", "10"); + const sdkInclude = ["Include", "include"].map(name => resolve(sdkRoot, name)).find(existsSync); + if (sdkInclude !== undefined) { for (const ver of readdirSync(sdkInclude)) { for (const sub of ["ucrt", "shared", "um"]) { const d = resolve(sdkInclude, ver, sub); diff --git a/scripts/build/config.ts b/scripts/build/config.ts index d70465b9667b..991e727abf2b 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -548,7 +548,11 @@ export function detectFreebsdSysroot(arch: Arch): string | undefined { * there's no per-arch variant. Returns undefined if none found. */ export function detectWindowsSysroot(): string | undefined { - const looksValid = (p: string) => existsSync(join(p, "Windows Kits", "10", "Include")); + // Case-tolerant: a real VS/SDK copy uses "Include", an xwin splat in + // winsysroot-style mode writes "include" (winsysroot.ts adds the + // title-case alias the LLVM toolchain needs at configure time). + const looksValid = (p: string) => + existsSync(join(p, "Windows Kits", "10", "Include")) || existsSync(join(p, "Windows Kits", "10", "include")); const env = process.env.WINDOWS_SYSROOT; if (env && looksValid(env)) return env; for (const p of ["/opt/winsysroot", "/opt/xwin"]) { diff --git a/scripts/build/configure.ts b/scripts/build/configure.ts index 5359730192a9..8de9d7da99a5 100644 --- a/scripts/build/configure.ts +++ b/scripts/build/configure.ts @@ -274,13 +274,14 @@ export async function configure(input: ConfigureInput): Promise checkWorkarounds(cfg); - // Windows cross-compile, CI only: fetch the MSVC CRT + Windows SDK splat - // into the per-build cache BEFORE the graph is emitted — emitBun() - // enumerates its include dirs (llvm-rc's /I flags) at configure time, so - // the sysroot must exist by then, not just before ninja runs. No-op when - // the resolved winsysroot is already complete. Local builds never fetch: - // resolveConfig() already required a provisioned sysroot (or errored). - if (cfg.windows && cfg.host.os !== "windows" && (cfg.ci || cfg.buildkite)) { + // Windows cross-compile: make sure the MSVC CRT + Windows SDK splat is + // usable BEFORE the graph is emitted — emitBun() enumerates its include + // dirs (llvm-rc's /I flags) at configure time, so the sysroot must exist + // by then, not just before ninja runs. CI fetches a missing sysroot into + // the per-build cache; local builds require a provisioned one (the fetch + // would be a surprise multi-GB download) and only get the case-alias + // fixup + completeness check. + if (cfg.windows && cfg.host.os !== "windows") { await ensureWindowsSysroot(cfg); mark("ensureWindowsSysroot"); } diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts index 49807891b8d0..00359c3f4c65 100644 --- a/scripts/build/winsysroot.ts +++ b/scripts/build/winsysroot.ts @@ -1,11 +1,13 @@ /** - * Windows sysroot (xwin splat) fetcher — CI path for Windows cross-compiles. + * Windows sysroot (xwin splat) handling for Windows cross-compiles. * * Cross-compiling for Windows needs the MSVC CRT/STL + Windows SDK headers - * and import libraries (see `Config.winsysroot`). Local builds point at a - * sysroot the developer created once (docs/project/building-windows.mdx); - * CI always fetches one into the per-build cache dir so the build doesn't - * depend on what the agent image happens to carry. + * and import libraries (see `Config.winsysroot`). Provisioned sysroots come + * from the agent image (.buildkite/Dockerfile / scripts/bootstrap.sh bake an + * xwin splat at /opt/winsysroot) or from a developer-created splat + * (docs/project/building-windows.mdx). When none is present, CI builds fetch + * one into the per-build cache dir at configure time — the build never + * depends on what the agent image happens to carry. * * The fetch is two steps, both pinned: * 1. Download the xwin release binary for the build host (GitHub). @@ -16,15 +18,15 @@ * components (the same terms the Windows CI images accept when * installing VS Build Tools). * - * Idempotent: a sentinel check (SDK include dir + kernel32.lib import libs - * for both arches) makes re-runs a no-op, so calling this on every CI build - * only costs time when the cache dir is fresh. + * Idempotent: a sentinel check (SDK include + lib trees with the target + * arch's kernel32 import lib) makes re-runs a no-op, so calling this on + * every build only costs time when the sysroot is genuinely absent. */ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import type { Config } from "./config.ts"; +import type { Arch, Config } from "./config.ts"; import { downloadWithRetry, extractTarGz } from "./download.ts"; import { BuildError } from "./error.ts"; @@ -32,29 +34,83 @@ import { BuildError } from "./error.ts"; export const XWIN_VERSION = "0.6.7"; /** - * Does `dir` look like a complete winsysroot? Checks the SDK include tree - * plus the kernel32 import lib for both target arches so an interrupted - * splat isn't treated as complete. Mirrors `detectWindowsSysroot()`'s - * sentinel (config.ts), with the extra per-arch lib check. + * Resolve a directory entry whose on-disk casing varies. A real Visual + * Studio / Windows SDK copy uses title-case ("Include", "Lib", + * "kernel32.Lib"); an xwin splat in winsysroot-style mode writes lowercase + * and relies on symlink aliases for the rest (see ensureSdkCaseAliases). */ -export function isCompleteWindowsSysroot(dir: string): boolean { - const sdkLib = join(dir, "Windows Kits", "10", "Lib"); - if (!existsSync(join(dir, "Windows Kits", "10", "Include")) || !existsSync(sdkLib)) return false; - for (const arch of ["x64", "arm64"]) { - const found = sdkVersionDirs(sdkLib).some(ver => existsSync(join(sdkLib, ver, "um", arch, "kernel32.lib"))); - if (!found) return false; +function joinIgnoreCase(parent: string, name: string): string | undefined { + for (const candidate of [name, name.toLowerCase()]) { + const p = join(parent, candidate); + if (existsSync(p)) return p; } - return true; + return undefined; } -function sdkVersionDirs(sdkLib: string): string[] { +function listDir(dir: string): string[] { try { - return readdirSync(sdkLib); + return readdirSync(dir); } catch { return []; } } +/** MS arch notation used for per-arch lib dirs inside the SDK. */ +function msArchName(arch: Arch): string { + return arch === "x64" ? "x64" : "arm64"; +} + +/** + * Does `dir` look like a winsysroot usable for an `arch` build? Checks the + * SDK include tree plus the kernel32 import lib for the target arch so an + * interrupted splat isn't treated as complete. Mirrors + * `detectWindowsSysroot()`'s sentinel (config.ts), with the extra lib check. + * Case-tolerant: accepts both the SDK's title-case layout and xwin's + * lowercase winsysroot-style layout. + */ +export function isCompleteWindowsSysroot(dir: string, arch: Arch): boolean { + const sdkRoot = join(dir, "Windows Kits", "10"); + const sdkInclude = joinIgnoreCase(sdkRoot, "Include"); + const sdkLib = joinIgnoreCase(sdkRoot, "Lib"); + if (sdkInclude === undefined || sdkLib === undefined) return false; + // The SDK ships the file as "kernel32.Lib"; xwin adds a lowercase symlink. + return listDir(sdkLib).some(ver => + listDir(join(sdkLib, ver, "um", msArchName(arch))).some(f => f.toLowerCase() === "kernel32.lib"), + ); +} + +/** + * clang-cl and lld-link compose SDK paths under /winsysroot with title-case + * "Include" and "Lib" (llvm/lib/WindowsDriver/MSVCPaths.cpp, + * lld/COFF/Driver.cpp), but xwin's winsysroot-style splat writes lowercase + * "include"/"lib" and only creates title-case aliases for its non-winsysroot + * layout. On a case-sensitive filesystem the toolchain would find nothing, + * so make sure both spellings resolve, whichever one the sysroot shipped + * with. No-op when the alias (or a real title-case dir) already exists. + */ +function ensureSdkCaseAliases(dir: string): void { + const sdkRoot = join(dir, "Windows Kits", "10"); + if (!existsSync(sdkRoot)) return; + for (const [alias, real] of [ + ["Include", "include"], + ["Lib", "lib"], + ] as const) { + const aliasPath = join(sdkRoot, alias); + if (existsSync(aliasPath) || !existsSync(join(sdkRoot, real))) continue; + try { + symlinkSync(real, aliasPath); + } catch (error) { + // EEXIST: another configure raced us (or a dangling alias is present) — + // either way the path resolves or the compile error will say so. + if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; + throw new BuildError(`Could not create the "${alias}" alias in ${sdkRoot}`, { + cause: error as Error, + hint: `clang-cl/lld-link look up Windows SDK paths as "${alias}". Create the alias manually: ln -s ${real} "${aliasPath}"`, + }); + } + } +} + /** xwin release triple for the machine running the build. */ function xwinHostTriple(cfg: Config): string { const arch = cfg.host.arch === "aarch64" ? "aarch64" : "x86_64"; @@ -71,15 +127,32 @@ function xwinHostTriple(cfg: Config): string { } /** - * Ensure `cfg.winsysroot` exists and is complete, fetching it with xwin if - * not. No-op for native Windows builds and when the sysroot is already - * present (the common case locally). + * Ensure `cfg.winsysroot` exists, is complete for the target arch, and has + * the case aliases the LLVM toolchain needs. Fetches the sysroot with xwin + * when it's missing — CI only; local builds get a clear error instead of a + * surprise multi-GB download into a directory they configured themselves. + * No-op for native Windows builds. */ export async function ensureWindowsSysroot(cfg: Config): Promise { if (!cfg.windows || cfg.host.os === "windows" || cfg.winsysroot === undefined) return; const dest = cfg.winsysroot; - if (isCompleteWindowsSysroot(dest)) return; + if (!isCompleteWindowsSysroot(dest, cfg.arch)) { + if (!cfg.ci && !cfg.buildkite) { + throw new BuildError(`Windows sysroot at ${dest} is missing the MSVC CRT / Windows SDK for ${cfg.arch}`, { + hint: + "Re-create it with xwin (see docs/project/building-windows.mdx):\n" + + ` xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output ${dest}`, + }); + } + await fetchWindowsSysroot(cfg, dest); + } + + ensureSdkCaseAliases(dest); +} + +/** Download xwin and splat the MSVC CRT + Windows SDK into `dest`. */ +async function fetchWindowsSysroot(cfg: Config, dest: string): Promise { // ─── 1. xwin binary ─── const triple = xwinHostTriple(cfg); const xwinDir = resolve(cfg.cacheDir, `xwin-${XWIN_VERSION}`); @@ -101,7 +174,7 @@ export async function ensureWindowsSysroot(cfg: Config): Promise { // Both target arches in one splat; --include-debug-libs so /MTd (debug // CRT) links work; winsysroot-style + MS arch notation so clang-cl and // lld-link resolve it with a single /winsysroot flag; symlinks stay ON - // (default) to fix include casing on a case-sensitive filesystem. + // (default) to fix include/lib casing on a case-sensitive filesystem. // // The incomplete previous attempt is wiped before re-splatting, but only // when `dest` actually looks like a (partial) sysroot — a mistyped @@ -137,14 +210,19 @@ export async function ensureWindowsSysroot(cfg: Config): Promise { "--output", dest, ]; - const result = spawnSync(xwinExe, args, { stdio: "inherit" }); + // xwin draws progress bars to stdout even when it isn't a terminal, which + // floods CI logs with megabytes of redraws. Keep stderr (real errors); + // only show the progress locally where it's actually a progress bar. + const result = spawnSync(xwinExe, args, { + stdio: ["ignore", process.stdout.isTTY ? "inherit" : "ignore", "inherit"], + }); if (result.error || result.status !== 0) { throw new BuildError(`xwin splat failed${result.status !== null ? ` (exit ${result.status})` : ""}`, { cause: result.error, hint: "The MSVC CRT / Windows SDK download from Microsoft's CDN failed — check network access, or provide a sysroot via WINDOWS_SYSROOT / --winsysroot.", }); } - if (!isCompleteWindowsSysroot(dest)) { + if (!isCompleteWindowsSysroot(dest, cfg.arch)) { throw new BuildError(`xwin splat finished but ${dest} is missing expected SDK files`, { hint: "Delete the directory and retry, or provide a sysroot via WINDOWS_SYSROOT / --winsysroot.", }); From 0b899049d604e36c438730b635e0ecce97f61ed0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 06:47:06 +0000 Subject: [PATCH 10/26] Keep host include-path env vars out of Windows cross-compiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI build containers export C_INCLUDE_PATH / CPLUS_INCLUDE_PATH for the host gcc toolchain. clang adds those directories for every compile regardless of --target, so the windows-cross lanes resolved & co. from the host libstdc++ instead of the MSVC STL and failed with "'bits/c++config.h' file not found" on the first vendor C++ file. Scrub CPATH / C_INCLUDE_PATH / CPLUS_INCLUDE_PATH / OBJC_INCLUDE_PATH from the ninja environment for Windows cross builds only — they are host-targeted by definition. Native Windows builds (INCLUDE/LIB from the VS dev shell) and every other target keep their environment as provisioned. --- scripts/build.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 193146cc6938..6818debb2305 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -95,7 +95,23 @@ async function main(): Promise { : { profile: args.profile, overrides: args.overrides }; const ninjaArgv = (cfg: { buildDir: string }) => ["-C", cfg.buildDir, ...args.ninjaArgs, ...args.ninjaTargets]; - const ninjaEnv = (env: Record) => ({ ...process.env, ...env }); + // GNU-style include-path vars (CPATH, C_INCLUDE_PATH, CPLUS_INCLUDE_PATH, + // OBJC_INCLUDE_PATH) apply to every clang invocation regardless of + // --target. The CI build containers set them for the *host* gcc toolchain + // (.buildkite/Dockerfile), which hijacks & co. away from the MSVC + // STL when cross-compiling for Windows ("'bits/c++config.h' file not + // found"). Scrub them for Windows cross builds — they are host-targeted by + // definition. Native Windows builds (INCLUDE/LIB from the VS dev shell) and + // every other target keep the environment as provisioned. + const ninjaEnv = (cfg: { windows: boolean; host: { os: string } }, env: Record) => { + const merged: NodeJS.ProcessEnv = { ...process.env, ...env }; + if (cfg.windows && cfg.host.os !== "windows") { + for (const name of ["CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "OBJC_INCLUDE_PATH"]) { + delete merged[name]; + } + } + return merged; + }; if (isCI) { // CI: machine/env dump + collapsible groups + annotation-on-failure. @@ -109,7 +125,7 @@ async function main(): Promise { } await startGroup("Build", () => - spawnWithAnnotations("ninja", ninjaArgv(result.cfg), { label: "ninja", env: ninjaEnv(result.env) }), + spawnWithAnnotations("ninja", ninjaArgv(result.cfg), { label: "ninja", env: ninjaEnv(result.cfg, result.env) }), ); // cpp-only/rust-only: upload build outputs for downstream link-only. @@ -182,7 +198,7 @@ async function main(): Promise { } const ninja = spawnSync("ninja", ninjaArgv(result.cfg), { stdio, - env: ninjaEnv(result.env), + env: ninjaEnv(result.cfg, result.env), // cargo's compile output (now part of the ninja graph via emitRust) can // be tens of MB on a cold build; the default 1 MB maxBuffer ENOBUFSes. maxBuffer: 1024 * 1024 * 1024, From c5cce666bcf6492a8fbc48f20a0988d9e9fce4d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 07:18:49 +0000 Subject: [PATCH 11/26] Include in image_wic_shim.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file uses int32_t/uint8_t but only includes and . A Visual Studio install's headers happen to pull the fixed-width types in transitively, the xwin-splatted SDK used for Linux→Windows cross-compiles does not, so the cross lanes failed with "unknown type name 'int32_t'". Include the header that declares what the file uses. --- src/jsc/bindings/image_wic_shim.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsc/bindings/image_wic_shim.cpp b/src/jsc/bindings/image_wic_shim.cpp index 83562d265ac3..7aa68389a8f0 100644 --- a/src/jsc/bindings/image_wic_shim.cpp +++ b/src/jsc/bindings/image_wic_shim.cpp @@ -14,6 +14,7 @@ #include #include // IPropertyBag2, PROPBAG2 +#include // int32_t/uint8_t — not guaranteed by the SDK headers above static int32_t write1(void* props, const wchar_t* name, VARTYPE vt, void (*set)(VARIANT&)) { From 30e006766be7e1ee08e5f12d1af1eba07c05f88b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 07:18:49 +0000 Subject: [PATCH 12/26] Address review feedback on the cross-compile CI plumbing - ci.mjs: only emit the windows-cross group when the build-platform selection includes Windows, matching the filtering the per-target groups honour for manual platform-subset builds - Dockerfile: download the xwin tarball to a file instead of piping curl into tar, so a failed download fails the layer (the default /bin/sh has no pipefail) - winsysroot.ts: cross-reference the other XWIN_VERSION copies - rust.ts: drop the stale "declared output is the source-tree path" clause that contradicted the per-build-dir stamp design below it --- .buildkite/Dockerfile | 5 +++-- .buildkite/ci.mjs | 35 ++++++++++++++++++++--------------- scripts/build/rust.ts | 4 ++-- scripts/build/winsysroot.ts | 6 +++++- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index b0ad029b958a..44647fff3d6e 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -187,13 +187,14 @@ ENV FREEBSD_SYSROOT=/opt/freebsd-sysroot ARG XWIN_VERSION="0.6.7" RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo "x86_64"; fi) \ && curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl.tar.gz" \ - | tar -xz -C /tmp \ + -o /tmp/xwin.tar.gz \ + && tar -xzf /tmp/xwin.tar.gz -C /tmp \ && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ > /dev/null \ && ln -s include "/opt/winsysroot/Windows Kits/10/Include" \ && ln -s lib "/opt/winsysroot/Windows Kits/10/Lib" \ - && rm -rf /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl /tmp/xwin-cache + && rm -rf /tmp/xwin.tar.gz /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl /tmp/xwin-cache ENV WINDOWS_SYSROOT=/opt/winsysroot RUN ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "amd64"; fi) && \ diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index 215f3543a417..a9378bd87906 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -1477,21 +1477,26 @@ async function getPipeline(options = {}) { ); // Windows cross-compile validation: full builds of bun.exe (x64 + arm64) - // from Linux agents. See getWindowsCrossBuildStep(). - const crossImageDependsOn = ["x64", "aarch64"] - .map(arch => getImageKey({ os: "linux", arch, distro: "amazonlinux", release: "2023", features: ["docker"] })) - .filter(imageKey => imagePlatforms.has(imageKey)) - .map(imageKey => `${imageKey}-build-image`); - steps.push( - getStepWithDependsOn( - { - key: "windows-cross", - group: `${getBuildkiteEmoji("windows")} cross (linux)`, - steps: [getWindowsCrossBuildStep("x64", options), getWindowsCrossBuildStep("aarch64", options)], - }, - ...crossImageDependsOn, - ), - ); + // from Linux agents. See getWindowsCrossBuildStep(). Honours the same + // platform filtering as the per-target groups above, so a manual build + // that narrows `build-platforms` to a non-windows subset doesn't spawn + // the cross lanes. + if (relevantBuildPlatforms.some(({ os }) => os === "windows")) { + const crossImageDependsOn = ["x64", "aarch64"] + .map(arch => getImageKey({ os: "linux", arch, distro: "amazonlinux", release: "2023", features: ["docker"] })) + .filter(imageKey => imagePlatforms.has(imageKey)) + .map(imageKey => `${imageKey}-build-image`); + steps.push( + getStepWithDependsOn( + { + key: "windows-cross", + group: `${getBuildkiteEmoji("windows")} cross (linux)`, + steps: [getWindowsCrossBuildStep("x64", options), getWindowsCrossBuildStep("aarch64", options)], + }, + ...crossImageDependsOn, + ), + ); + } } if (!isMainBranch()) { diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index b0d31b027aaa..887921474b04 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -244,8 +244,8 @@ export function registerRustRules(n: Ninja, cfg: Config): void { // 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 so the declared output is the - // source-tree path (cargo's own output path is an undeclared intermediate). + // `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` diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts index 00359c3f4c65..7036bc51ad24 100644 --- a/scripts/build/winsysroot.ts +++ b/scripts/build/winsysroot.ts @@ -30,7 +30,11 @@ import type { Arch, Config } from "./config.ts"; import { downloadWithRetry, extractTarGz } from "./download.ts"; import { BuildError } from "./error.ts"; -/** Pinned xwin release — https://github.com/Jake-Shadle/xwin/releases */ +/** + * Pinned xwin release — https://github.com/Jake-Shadle/xwin/releases + * Keep in sync with the baked splat in .buildkite/Dockerfile (ARG + * XWIN_VERSION) and scripts/bootstrap.sh (xwin_version). + */ export const XWIN_VERSION = "0.6.7"; /** From 425220df9cda324bb0b5f22696743c94daf049f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 07:30:21 +0000 Subject: [PATCH 13/26] Add a source check for explicit in the SDK shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bindings shims are compiled against the platform SDK rather than WTF/JSC, and a Visual Studio header set provides the fixed-width integer types transitively while the xwin-splatted SDK used for Linux→Windows cross-compiles does not. Guard the explicit include so it can't be dropped as a seemingly redundant cleanup, which would only surface as a cross-compile failure. --- test/internal/shim-stdint-includes.test.ts | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/internal/shim-stdint-includes.test.ts diff --git a/test/internal/shim-stdint-includes.test.ts b/test/internal/shim-stdint-includes.test.ts new file mode 100644 index 000000000000..8bf4f4878415 --- /dev/null +++ b/test/internal/shim-stdint-includes.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +// The platform shims in src/jsc/bindings are deliberately header-light: they +// are compiled against the platform SDK (a Visual Studio install or an xwin +// splat for Windows, the macOS SDK for CoreGraphics) rather than against +// WTF/JSC, so nothing else pulls in the C standard headers for them. +// +// Fixed-width integer types are the trap: a Visual Studio header set happens +// to provide int32_t & co. transitively, while the xwin-splatted SDK used for +// Linux→Windows cross-compiles does not. A shim that uses them without +// including compiles natively and only breaks the cross build — +// and the "redundant-looking" include is an easy target for cleanup. Keep the +// dependency explicit. +test("SDK shims that use fixed-width integer types include ", () => { + const bindingsDir = path.resolve(import.meta.dir, "..", "..", "src", "jsc", "bindings"); + const shims = readdirSync(bindingsDir).filter(name => name.endsWith("_shim.cpp")); + expect(shims.length).toBeGreaterThan(0); + + const fixedWidthType = /\b(?:u?int(?:8|16|32|64)_t|intptr_t|uintptr_t)\b/; + const stdintInclude = /#include\s*<(?:cstdint|stdint\.h)>/; + + const violations = shims + .filter(name => { + const source = readFileSync(path.join(bindingsDir, name), "utf8"); + return fixedWidthType.test(source) && !stdintInclude.test(source); + }) + .map(name => `src/jsc/bindings/${name} uses fixed-width integer types but does not include `); + + expect(violations).toEqual([]); +}); From 8e45ba33450b791b72f6fcfd69a226dfeac42877 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 08:07:23 +0000 Subject: [PATCH 14/26] docs: create the Include/Lib aliases in the manual splat example A splat into /opt/winsysroot is usually root-owned, so configure's automatic alias creation hits EACCES for a regular user. Put the two ln -s commands next to the splat (matching bootstrap.sh and the Dockerfile) and stop claiming the splat alone is sufficient. --- docs/project/building-windows.mdx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index 9608df183b4b..1a92cb4c80c7 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -159,9 +159,14 @@ cargo install xwin # or download a release binary xwin --accept-license --arch x86_64,aarch64 splat \ --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ --output /opt/winsysroot +# clang-cl/lld-link look up SDK paths as "Include"/"Lib"; the splat writes +# them lowercase, so alias both spellings (needs the same privileges as the +# splat — configure creates these itself when the directory is writable). +ln -s include "/opt/winsysroot/Windows Kits/10/Include" +ln -s lib "/opt/winsysroot/Windows Kits/10/Lib" ``` -The build looks for the sysroot at `/opt/winsysroot` (or `/opt/xwin`) automatically; elsewhere, set `WINDOWS_SYSROOT=` or pass `--winsysroot=`. Configure validates the splat and adds the title-case `Include`/`Lib` aliases clang-cl and lld-link expect, so the command above is all that's needed. CI agents bake the same splat into their images (`.buildkite/Dockerfile`, `scripts/bootstrap.sh`); when an agent doesn't have one, the build fetches it into its cache dir at configure time. +The build looks for the sysroot at `/opt/winsysroot` (or `/opt/xwin`) automatically; elsewhere, set `WINDOWS_SYSROOT=` or pass `--winsysroot=` (a user-writable path also lets configure manage the aliases for you). Configure validates the splat at the start of every cross build. CI agents bake the same splat into their images (`.buildkite/Dockerfile`, `scripts/bootstrap.sh`); when an agent doesn't have one, the build fetches it into its cache dir at configure time. ### Building From 9801dc5a02144405e190d7de4b66144dc5cf9464 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 08:40:24 +0000 Subject: [PATCH 15/26] Include ATL in the Windows cross-compile sysroot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rescle.cpp (PE resource editing for bun build --compile) includes , which comes from the optional ATL component rather than the CRT or Windows SDK, so the cross lanes stopped there once everything else compiled. Pass --include-atl to every xwin splat (configure-time fetch, Dockerfile, bootstrap.sh, docs) — xwin merges the ATL headers and libs into the VC include/lib dirs, so clang-cl and lld-link pick them up through /winsysroot with no extra flags — and teach the sysroot sentinel to require the ATL headers so cached pre-ATL splats are re-fetched. --- .buildkite/Dockerfile | 9 ++--- docs/project/building-windows.mdx | 4 +-- scripts/bootstrap.sh | 19 ++++++----- scripts/build/config.ts | 2 +- scripts/build/winsysroot.ts | 57 +++++++++++++++++++------------ 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index 44647fff3d6e..97f982738bd1 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -174,10 +174,11 @@ RUN FBSD_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "arm64"; else echo "am && rm /tmp/base.txz ENV FREEBSD_SYSROOT=/opt/freebsd-sysroot -# Windows sysroot — xwin splat of the MSVC CRT/STL + Windows SDK (VS layout) -# for --os=windows cross-compile; clang-cl/lld-link consume it via +# Windows sysroot — xwin splat of the MSVC CRT/STL + Windows SDK + ATL (VS +# layout) for --os=windows cross-compile; clang-cl/lld-link consume it via # /winsysroot (see scripts/build/config.ts `winsysroot`). Both target arches -# in one splat; --include-debug-libs so /MTd debug links work. +# in one splat; --include-debug-libs so /MTd debug links work; --include-atl +# for (rescle.cpp). # --accept-license accepts the Microsoft license terms for the SDK/CRT # components, same as the Windows CI images do when installing VS Build Tools. # If the image predates this layer, configure falls back to fetching the same @@ -190,7 +191,7 @@ RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo " -o /tmp/xwin.tar.gz \ && tar -xzf /tmp/xwin.tar.gz -C /tmp \ && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output /opt/winsysroot \ > /dev/null \ && ln -s include "/opt/winsysroot/Windows Kits/10/Include" \ && ln -s lib "/opt/winsysroot/Windows Kits/10/Lib" \ diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index 1a92cb4c80c7..b1697c98140f 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -152,12 +152,12 @@ Windows binaries (both x64 and arm64) can also be built on a Linux host. The bui 1. The same LLVM version a native build uses (see `scripts/bootstrap.sh` `llvm_version_exact`), installed so that `clang-cl`, `lld-link`, `llvm-lib` and `llvm-rc` are available. On Debian/Ubuntu, `apt.llvm.org` packages provide all of them. 2. `nasm` (only needed for Windows x64 — BoringSSL's x64 assembly is NASM syntax). 3. Rust std for the Windows targets (`rust-toolchain.toml` lists them; `rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc` if missing). -4. A Windows sysroot: an [xwin](https://github.com/Jake-Shadle/xwin) splat of the MSVC CRT and Windows SDK laid out like a Visual Studio install. Note that downloading these components means accepting Microsoft's license terms for them. +4. A Windows sysroot: an [xwin](https://github.com/Jake-Shadle/xwin) splat of the MSVC CRT, Windows SDK, and ATL laid out like a Visual Studio install. Note that downloading these components means accepting Microsoft's license terms for them. ```bash cargo install xwin # or download a release binary xwin --accept-license --arch x86_64,aarch64 splat \ - --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl \ --output /opt/winsysroot # clang-cl/lld-link look up SDK paths as "Include"/"Lib"; the splat writes # them lowercase, so alias both spellings (needs the same privileges as the diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 0a53cd228c2e..86cc4e519d7b 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1429,10 +1429,12 @@ install_windows_sysroot() { # none is present (scripts/build/winsysroot.ts). sysroot="/opt/winsysroot" # Same sentinel scripts/build/winsysroot.ts isCompleteWindowsSysroot() - # uses: the SDK lib tree plus a kernel32 import lib, so a half-splatted - # (interrupted) sysroot isn't treated as complete. xwin writes the SDK - # dirs/files lowercase; a copied VS install is title-case — accept both. - if ls "$sysroot/Windows Kits/10/"[Ll]ib/*/um/x64/kernel32.[Ll]ib >/dev/null 2>&1; then + # uses: the SDK lib tree plus a kernel32 import lib plus the ATL headers + # (--include-atl), so a half-splatted or pre-ATL sysroot isn't treated as + # complete. xwin writes the SDK dirs/files lowercase; a copied VS install + # is title-case — accept both. + if ls "$sysroot/Windows Kits/10/"[Ll]ib/*/um/x64/kernel32.[Ll]ib >/dev/null 2>&1 && + ls "$sysroot"/VC/Tools/MSVC/*/include/atlstr.h >/dev/null 2>&1; then return fi @@ -1449,11 +1451,12 @@ install_windows_sysroot() { execute_sudo rm -rf "$sysroot" execute_sudo mkdir -p "$sysroot" # Both target arches in one splat; --include-debug-libs so /MTd (debug - # CRT) links work; winsysroot-style + MS arch notation so clang-cl and - # lld-link resolve it with a single /winsysroot flag; symlinks stay ON - # (default) to fix include/lib casing on a case-sensitive filesystem. + # CRT) links work; --include-atl for (rescle.cpp); + # winsysroot-style + MS arch notation so clang-cl and lld-link resolve it + # with a single /winsysroot flag; symlinks stay ON (default) to fix + # include/lib casing on a case-sensitive filesystem. execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --cache-dir "$xwin_dir/cache" \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl \ --output "$sysroot" # clang-cl/lld-link compose SDK paths as "Include"/"Lib" (title case); # the winsysroot-style splat writes lowercase — alias both spellings. diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 991e727abf2b..4da1079b3ef2 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -966,7 +966,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con hint: "Set WINDOWS_SYSROOT or pass --winsysroot=. Create one with xwin (https://github.com/Jake-Shadle/xwin):\n" + " cargo install xwin (or download a release binary)\n" + - " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot", + " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output /opt/winsysroot", }); } } diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts index 7036bc51ad24..aa393cb2836f 100644 --- a/scripts/build/winsysroot.ts +++ b/scripts/build/winsysroot.ts @@ -1,17 +1,17 @@ /** * Windows sysroot (xwin splat) handling for Windows cross-compiles. * - * Cross-compiling for Windows needs the MSVC CRT/STL + Windows SDK headers - * and import libraries (see `Config.winsysroot`). Provisioned sysroots come - * from the agent image (.buildkite/Dockerfile / scripts/bootstrap.sh bake an - * xwin splat at /opt/winsysroot) or from a developer-created splat - * (docs/project/building-windows.mdx). When none is present, CI builds fetch - * one into the per-build cache dir at configure time — the build never - * depends on what the agent image happens to carry. + * Cross-compiling for Windows needs the MSVC CRT/STL + Windows SDK + ATL + * headers and import libraries (see `Config.winsysroot`). Provisioned + * sysroots come from the agent image (.buildkite/Dockerfile / + * scripts/bootstrap.sh bake an xwin splat at /opt/winsysroot) or from a + * developer-created splat (docs/project/building-windows.mdx). When none is + * present, CI builds fetch one into the per-build cache dir at configure + * time — the build never depends on what the agent image happens to carry. * * The fetch is two steps, both pinned: * 1. Download the xwin release binary for the build host (GitHub). - * 2. Run `xwin splat` — xwin downloads the CRT/SDK packages from + * 2. Run `xwin splat` — xwin downloads the CRT/SDK/ATL packages from * Microsoft's CDN and lays them out like a Visual Studio install so a * single `/winsysroot` flag works for clang-cl and lld-link. * `--accept-license` accepts Microsoft's license terms for those @@ -19,8 +19,9 @@ * installing VS Build Tools). * * Idempotent: a sentinel check (SDK include + lib trees with the target - * arch's kernel32 import lib) makes re-runs a no-op, so calling this on - * every build only costs time when the sysroot is genuinely absent. + * arch's kernel32 import lib, plus the ATL headers) makes re-runs a no-op, + * so calling this on every build only costs time when the sysroot is + * genuinely absent or incomplete. */ import { spawnSync } from "node:child_process"; @@ -66,11 +67,11 @@ function msArchName(arch: Arch): string { /** * Does `dir` look like a winsysroot usable for an `arch` build? Checks the - * SDK include tree plus the kernel32 import lib for the target arch so an - * interrupted splat isn't treated as complete. Mirrors - * `detectWindowsSysroot()`'s sentinel (config.ts), with the extra lib check. - * Case-tolerant: accepts both the SDK's title-case layout and xwin's - * lowercase winsysroot-style layout. + * SDK include tree, the kernel32 import lib for the target arch, and the ATL + * headers so an interrupted or pre-ATL splat isn't treated as complete. + * Mirrors `detectWindowsSysroot()`'s sentinel (config.ts), with the extra + * lib/ATL checks. Case-tolerant: accepts both the SDK's title-case layout + * and xwin's lowercase winsysroot-style layout. */ export function isCompleteWindowsSysroot(dir: string, arch: Arch): boolean { const sdkRoot = join(dir, "Windows Kits", "10"); @@ -78,9 +79,19 @@ export function isCompleteWindowsSysroot(dir: string, arch: Arch): boolean { const sdkLib = joinIgnoreCase(sdkRoot, "Lib"); if (sdkInclude === undefined || sdkLib === undefined) return false; // The SDK ships the file as "kernel32.Lib"; xwin adds a lowercase symlink. - return listDir(sdkLib).some(ver => + const hasKernel32 = listDir(sdkLib).some(ver => listDir(join(sdkLib, ver, "um", msArchName(arch))).some(f => f.toLowerCase() === "kernel32.lib"), ); + if (!hasKernel32) return false; + // ATL (, needed by src/jsc/bindings/windows/rescle.cpp): xwin's + // --include-atl merges the ATL headers into the VC include dir; a real + // Visual Studio copy keeps them under atlmfc/include. + const msvcRoot = join(dir, "VC", "Tools", "MSVC"); + return listDir(msvcRoot).some(ver => + [join(msvcRoot, ver, "include"), join(msvcRoot, ver, "atlmfc", "include")].some(incDir => + listDir(incDir).some(f => f.toLowerCase() === "atlstr.h"), + ), + ); } /** @@ -143,10 +154,10 @@ export async function ensureWindowsSysroot(cfg: Config): Promise { if (!isCompleteWindowsSysroot(dest, cfg.arch)) { if (!cfg.ci && !cfg.buildkite) { - throw new BuildError(`Windows sysroot at ${dest} is missing the MSVC CRT / Windows SDK for ${cfg.arch}`, { + throw new BuildError(`Windows sysroot at ${dest} is missing the MSVC CRT / Windows SDK / ATL for ${cfg.arch}`, { hint: "Re-create it with xwin (see docs/project/building-windows.mdx):\n" + - ` xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output ${dest}`, + ` xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output ${dest}`, }); } await fetchWindowsSysroot(cfg, dest); @@ -174,11 +185,12 @@ async function fetchWindowsSysroot(cfg: Config, dest: string): Promise { } } - // ─── 2. Splat the MSVC CRT + Windows SDK ─── + // ─── 2. Splat the MSVC CRT + Windows SDK + ATL ─── // Both target arches in one splat; --include-debug-libs so /MTd (debug - // CRT) links work; winsysroot-style + MS arch notation so clang-cl and - // lld-link resolve it with a single /winsysroot flag; symlinks stay ON - // (default) to fix include/lib casing on a case-sensitive filesystem. + // CRT) links work; --include-atl for (rescle.cpp); + // winsysroot-style + MS arch notation so clang-cl and lld-link resolve it + // with a single /winsysroot flag; symlinks stay ON (default) to fix + // include/lib casing on a case-sensitive filesystem. // // The incomplete previous attempt is wiped before re-splatting, but only // when `dest` actually looks like a (partial) sysroot — a mistyped @@ -211,6 +223,7 @@ async function fetchWindowsSysroot(cfg: Config, dest: string): Promise { "--use-winsysroot-style", "--preserve-ms-arch-notation", "--include-debug-libs", + "--include-atl", "--output", dest, ]; From 3a9650ad159c95435cf5d212673baf0e8a62b1d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 10:12:57 +0000 Subject: [PATCH 16/26] Pass --include-atl as a top-level xwin option xwin takes --include-atl before the splat subcommand (it selects which payloads are downloaded), so the previous placement made every splat fail with "unexpected argument". Move it in all four invocations and the two error hints; verified the exact argument order against the pinned xwin 0.6.7 binary. Also drop the bootstrap splat's stdout like the other two callers so image-build logs don't fill with progress-bar redraws. --- .buildkite/Dockerfile | 4 ++-- docs/project/building-windows.mdx | 4 ++-- scripts/bootstrap.sh | 8 +++++--- scripts/build/config.ts | 2 +- scripts/build/winsysroot.ts | 5 +++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.buildkite/Dockerfile b/.buildkite/Dockerfile index 97f982738bd1..32c0b1fe20ec 100644 --- a/.buildkite/Dockerfile +++ b/.buildkite/Dockerfile @@ -190,8 +190,8 @@ RUN XWIN_ARCH=$(if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64"; else echo " && curl -fsSL "https://github.com/Jake-Shadle/xwin/releases/download/${XWIN_VERSION}/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl.tar.gz" \ -o /tmp/xwin.tar.gz \ && tar -xzf /tmp/xwin.tar.gz -C /tmp \ - && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --cache-dir /tmp/xwin-cache \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output /opt/winsysroot \ + && /tmp/xwin-${XWIN_VERSION}-${XWIN_ARCH}-unknown-linux-musl/xwin --accept-license --arch x86_64,aarch64 --include-atl --cache-dir /tmp/xwin-cache \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot \ > /dev/null \ && ln -s include "/opt/winsysroot/Windows Kits/10/Include" \ && ln -s lib "/opt/winsysroot/Windows Kits/10/Lib" \ diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index b1697c98140f..948cd87c5c14 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -156,8 +156,8 @@ Windows binaries (both x64 and arm64) can also be built on a Linux host. The bui ```bash cargo install xwin # or download a release binary -xwin --accept-license --arch x86_64,aarch64 splat \ - --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl \ +xwin --accept-license --arch x86_64,aarch64 --include-atl splat \ + --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ --output /opt/winsysroot # clang-cl/lld-link look up SDK paths as "Include"/"Lib"; the splat writes # them lowercase, so alias both spellings (needs the same privileges as the diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 86cc4e519d7b..923ad78661db 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1455,9 +1455,11 @@ install_windows_sysroot() { # winsysroot-style + MS arch notation so clang-cl and lld-link resolve it # with a single /winsysroot flag; symlinks stay ON (default) to fix # include/lib casing on a case-sensitive filesystem. - execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --cache-dir "$xwin_dir/cache" \ - splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl \ - --output "$sysroot" + # stdout is dropped: xwin draws progress bars there even without a TTY, + # which floods the image-build log. Errors stay on stderr. + execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --include-atl --cache-dir "$xwin_dir/cache" \ + splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ + --output "$sysroot" >/dev/null # clang-cl/lld-link compose SDK paths as "Include"/"Lib" (title case); # the winsysroot-style splat writes lowercase — alias both spellings. execute_sudo ln -s include "$sysroot/Windows Kits/10/Include" diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 4da1079b3ef2..e9a72283fdb5 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -966,7 +966,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con hint: "Set WINDOWS_SYSROOT or pass --winsysroot=. Create one with xwin (https://github.com/Jake-Shadle/xwin):\n" + " cargo install xwin (or download a release binary)\n" + - " xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output /opt/winsysroot", + " xwin --accept-license --arch x86_64,aarch64 --include-atl splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output /opt/winsysroot", }); } } diff --git a/scripts/build/winsysroot.ts b/scripts/build/winsysroot.ts index aa393cb2836f..e4a852e9ca5f 100644 --- a/scripts/build/winsysroot.ts +++ b/scripts/build/winsysroot.ts @@ -157,7 +157,7 @@ export async function ensureWindowsSysroot(cfg: Config): Promise { throw new BuildError(`Windows sysroot at ${dest} is missing the MSVC CRT / Windows SDK / ATL for ${cfg.arch}`, { hint: "Re-create it with xwin (see docs/project/building-windows.mdx):\n" + - ` xwin --accept-license --arch x86_64,aarch64 splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --include-atl --output ${dest}`, + ` xwin --accept-license --arch x86_64,aarch64 --include-atl splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs --output ${dest}`, }); } await fetchWindowsSysroot(cfg, dest); @@ -217,13 +217,14 @@ async function fetchWindowsSysroot(cfg: Config, dest: string): Promise { "--accept-license", "--arch", "x86_64,aarch64", + // Top-level option (payload selection), not a `splat` option. + "--include-atl", "--cache-dir", join(cfg.cacheDir, "xwin-dl"), "splat", "--use-winsysroot-style", "--preserve-ms-arch-notation", "--include-debug-libs", - "--include-atl", "--output", dest, ]; From 130eb2d0e2b78bdae8ee1029946491ba192b616a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 12:38:53 +0000 Subject: [PATCH 17/26] ci: retrigger From 0b8742cd84bc54e6c42cef2b3e7b625a530cef8f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 01:38:03 +0000 Subject: [PATCH 18/26] Enable ThinLTO with cross-language LTO for Windows x64 cross builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows x64 cross-compiles (the ci-release cross lanes) now default to the same LTO setup as the darwin cross builds: - compile with -flto=thin -fno-split-lto-unit (clang-cl accepts both directly); no -fwhole-program-vtables on COFF — WPD drops vtable symbols that associative COMDAT sections still reference and the LTO codegen aborts - consume the bun-webkit-windows-amd64-lto ThinLTO prebuilt - rustc emits bitcode (-Clinker-plugin-lto, no -Zsplit-lto-unit: every COFF module is split=0) so lld-link runs one ThinLTO graph across Rust, bun C++, and JSC - the final link uses rustc's gcc-ld/lld-link sibling (its LLVM is newer than clang's, and bitcode is forward-compatible only); the link rule pins the lld-link choice via /clang:-B since clang-cl has no working --ld-path spelling. Cargo-driven links (bun_shim_impl.exe) keep the host lld-link — rustc mis-drives its own gcc-ld wrapper when it's named as the target linker. LTO stays off for windows arm64 (no -lto WebKit prebuilt: LLVM's CodeView emitter aborts on ARM64 NEON tuple registers), for baseline (no -baseline-lto variant), and for native Windows hosts. --- scripts/build/bun.ts | 4 +- scripts/build/compile.ts | 11 +- scripts/build/config.ts | 75 +++++--- scripts/build/flags.ts | 25 ++- scripts/build/rust.ts | 6 +- test/internal/windows-cross-config.test.ts | 208 +++++++++++++++++++++ 6 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 test/internal/windows-cross-config.test.ts diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index cd5aed3d386a..6df723f52d1c 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -986,8 +986,8 @@ export function validateBunConfig(cfg: Config): void { const clangMajor = Number.parseInt(cfg.clangVersion.split(".")[0] ?? "", 10); if (Number.isFinite(rustMajor) && Number.isFinite(clangMajor) && rustMajor > clangMajor) { // `cfg.ld` must be one of rustc's bundled lld flavors. On ELF targets - // it's `cfg.rustLld` exactly; on darwin cross targets it's the - // ld64.lld sibling from the same gcc-ld/ directory. + // it's `cfg.rustLld` exactly; on darwin/windows cross targets it's the + // ld64.lld / lld-link sibling from the same gcc-ld/ directory. assert( cfg.rustLld !== undefined && (cfg.ld === cfg.rustLld || dirname(cfg.ld) === dirname(cfg.rustLld)), `Cross-language LTO is on and rustc's LLVM (${cfg.rustLlvmVersion}) is newer than clang's ` + diff --git a/scripts/build/compile.ts b/scripts/build/compile.ts index 1e520084cc2a..57df6cc9a2ad 100644 --- a/scripts/build/compile.ts +++ b/scripts/build/compile.ts @@ -153,6 +153,15 @@ export function registerCompileRules(n: Ninja, cfg: Config): void { // pure linker options (/STACK, /DEF, /OPT, /errorlimit, system libs) // that clang-cl's driver doesn't recognize. // + // /clang:-B pins WHICH lld-link `-fuse-ld=lld` resolves: + // -B program-prefix dirs are searched before the driver's own InstalledDir + // and PATH. Normally that's the same host-LLVM lld-link the driver would + // pick anyway; under cross-language LTO resolveConfig() swaps cfg.ld to + // rustc's gcc-ld/lld-link (newer LLVM, able to read rustc's bitcode), and + // this is what makes the link actually use it — clang-cl has no working + // --ld-path= spelling, and `-fuse-ld=` mangles the path with the + // target triple. + // // Darwin cross links append `&& macho-postlink $out ...` (the suffix is // empty everywhere else): ninja runs the whole command through `sh -c`, // so the fixup runs after the link succeeds and the declared output is @@ -160,7 +169,7 @@ export function registerCompileRules(n: Ninja, cfg: Config): void { const wrap = `${cfg.jsRuntime} ${q(streamPath)} link --console`; n.rule("link", { command: cfg.windows - ? `${wrap} ${cxx} /nologo -fuse-ld=lld @$out.rsp /Fe$out /link $ldflags` + ? `${wrap} ${cxx} /nologo -fuse-ld=lld ${q(`/clang:-B${dirname(cfg.ld)}`)} @$out.rsp /Fe$out /link $ldflags` : `${wrap} ${cxx} @$out.rsp $ldflags -o $out${machoPostlinkCommand(cfg)}`, description: "link $out", rspfile: "$out.rsp", diff --git a/scripts/build/config.ts b/scripts/build/config.ts index e9a72283fdb5..c4e39e5064b3 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -694,6 +694,9 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // binaries). Same host-clang + --target/-isysroot model as Android/FreeBSD, // with ld64.lld doing the Mach-O link. See the cross block further down. const darwinCross = darwin && host.os !== "darwin"; + // Windows target on a non-Windows host (clang-cl + lld-link + xwin + // sysroot). See the cross block further down. + const windowsCross = windows && host.os !== "windows"; // Platform file conventions — MSVC style on Windows, Unix everywhere else. const exeSuffix = windows ? ".exe" : ""; @@ -741,35 +744,43 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // build:asan always set ENABLE_ASSERTIONS=ON for this reason. const assertions = partial.assertions ?? (debug || asan); - // LTO: default on for CI release non-asan non-assertions builds on Linux - // and on darwin cross-compiles. The -lto WebKit prebuilt for macOS only - // exists for the cross toolchain (Apple's ld on the native lanes was never - // set up to consume bitcode archives), so the native darwin lanes stay - // non-LTO. - const ltoDefault = release && (linux || darwinCross) && ci && !assertions && !asan; + // Resolved early because the LTO defaults below need it (the windows + // -baseline WebKit prebuilt has no -lto variant). + const baseline = partial.baseline ?? false; + + // LTO: default on for CI release non-asan non-assertions builds on Linux, + // on darwin cross-compiles, and on windows x64 cross-compiles. The -lto + // WebKit prebuilts for macOS and Windows only exist for the cross + // toolchain (Apple's ld / MSVC link.exe on the native lanes were never set + // up to consume bitcode archives), so the native darwin and windows lanes + // stay non-LTO. + const ltoDefault = + release && (linux || darwinCross || (windowsCross && x64 && !baseline)) && ci && !assertions && !asan; let lto = partial.lto ?? ltoDefault; // ASAN and LTO don't mix — ASAN wins (silently, no warn — config is explicit). // Android: no LTO prebuilt WebKit exists; force off so the right tarball is fetched. - if ((asan && lto) || abi === "android") { + // Windows arm64 / baseline: same — oven-sh/WebKit ships no + // bun-webkit-windows-arm64-lto (LLVM's CodeView emitter aborts on ARM64 + // NEON tuple registers during LTO codegen) and no -baseline-lto variant. + if ((asan && lto) || abi === "android" || (windows && (arm64 || baseline))) { lto = false; } // Cross-language LTO normally tracks `lto`. Gated off for aarch64-musl // where LLVM's `globalopt` pass segfaults on the `bun_runtime` bitcode - // module during the merged link (CI build #53109), and for Windows - // targets — the C++ side's `-flto` flags are unix-only, there's no -lto - // WebKit prebuilt for Windows, and the rust-lld swap below resolves the - // HOST-flavored ld.lld which can't stand in for lld-link (an explicit - // `--lto=on` would otherwise trip validateBunConfig's rust-lld check - // with a misleading error). Both halves still LTO independently when - // this is false — only the Rust↔C++ inlining is lost. + // module during the merged link (CI build #53109), and for native Windows + // hosts — there `ld` is the host LLVM's lld-link and no rust-lld swap is + // wired up, so rustc's newer-LLVM bitcode would be unreadable at link + // time. Both halves still LTO independently when this is false — only the + // Rust↔C++ inlining is lost. // Tracked in workarounds.ts ("globalopt-crash-aarch64-musl"). // Darwin cross uses the same rust-lld swap as ELF: rustc's sysroot ships // `gcc-ld/ld64.lld` (rust-lld in the Mach-O flavor, built against rustc's // LLVM), which findRustLld() already resolves for darwin targets, so the // newer-LLVM bitcode rustc emits under -Clinker-plugin-lto is readable at - // link time. - const crossLangLto = lto && !windows && !(arm64 && abi === "musl"); + // link time. Windows cross does the same with the `gcc-ld/lld-link` + // sibling (COFF flavor) — see the wantRustLld swap below. + const crossLangLto = lto && !(windows && host.os === "windows") && !(arm64 && abi === "musl"); // Cross-language LTO bitcode-version skew: `-Clinker-plugin-lto` makes // rustc emit raw LLVM bitcode into libbun_rust.a. LLVM bitcode is @@ -789,9 +800,6 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // Shared with the darwin-cross ld64 swap below: for darwin targets // findRustLld() resolves rustc's `gcc-ld/ld64.lld` (the Mach-O flavor of // the same rust-lld), so the swap composes with the cross toolchain. - // (No explicit windows guard needed: crossLangLto is already false for - // windows targets, and `ld` there is lld-link — the COFF driver — which - // the host-flavored rust-lld could not stand in for.) const wantRustLld = crossLangLto && toolchain.rustLld !== undefined && @@ -799,7 +807,21 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con rustLlvmMajor !== undefined && rustLlvmMajor > clangMajor; if (wantRustLld) { - ld = toolchain.rustLld!; + if (windows) { + // Windows cross: `ld` must stay a COFF driver. `toolchain.rustLld` is + // the flavor matching the *host* (gcc-ld/ld.lld on a Linux box); + // rustc's gcc-ld/ directory ships every flavor of the same rust-lld, + // so use the lld-link sibling. If rustc ever stops shipping it, fall + // back to the host LLVM's lld-link — validateBunConfig() then fails + // at configure time with the bitcode-version-skew message instead of + // an opaque "Invalid record" at link time. + const rustLldLink = join(dirname(toolchain.rustLld!), "lld-link"); + if (existsSync(rustLldLink)) { + ld = rustLldLink; + } + } else { + ld = toolchain.rustLld!; + } } // PGO: paths resolved to absolute. generate/use are mutually exclusive. @@ -812,7 +834,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // Logs: on by default in debug non-test const logs = partial.logs ?? debug; - const baseline = partial.baseline ?? false; + // (`baseline` is resolved earlier, next to the LTO defaults.) const canary = partial.canary ?? true; const canaryRevision = canary ? "1" : "0"; @@ -1125,7 +1147,16 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con cargoHome: toolchain.cargoHome, rustupHome: toolchain.rustupHome, rustToolchain: readRustToolchainChannel(cwd), - msvcLinker: toolchain.msvcLinker, + // Cargo-driven links (the bun_shim_impl.exe edge, any future target + // cdylib) must keep using a real lld-link/link.exe, not the gcc-ld/ + // lld-link wrapper `ld` may have been swapped to above: rustc treats a + // linker living in its own sysroot's gcc-ld/ as the bundled rust-lld and + // prepends `-flavor link`, which the wrapper forwards into the COFF + // driver as bogus input args ("could not open 'link'"). Those links have + // no LLVM bitcode in them, so the host LLVM's lld-link is always + // sufficient — only the final clang-cl-driven bun.exe link needs the + // newer rust-lld (and reaches it via the link rule's /clang:-B). + msvcLinker: toolchain.msvcLinker ?? (windows && ld !== toolchain.ld ? toolchain.ld : undefined), rc: toolchain.rc, mt: toolchain.mt, nasm: toolchain.nasm, diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 3ffe27019f8f..4a2f4d4d07f9 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -494,11 +494,22 @@ export const globalFlags: Flag[] = [ desc: "Full link-time optimization (linux: ThinLTO miscompiles JSC, see comment)", }, { - flag: "-flto", + // Windows (cross) uses ThinLTO like darwin: clang-cl accepts -flto=thin + // directly (core option), the WebKit windows-amd64-lto prebuilt is + // ThinLTO-summaried bitcode, and rustc's -Clinker-plugin-lto bitcode is + // too, so lld-link runs one uniform ThinLTO graph with cross-language + // importing. lld-link does LTO automatically when it sees bitcode + // inputs — no link-side -flto spelling exists or is needed there. + flag: "-flto=thin", when: c => c.windows && c.lto, - desc: "Link-time optimization", + desc: "Thin link-time optimization (clang-cl)", }, { + // Unix only (not windows): on COFF, whole-program vtable opt drops + // vtable symbols that associative COMDAT sections still name as their + // parent and the LTO codegen aborts ("Associative COMDAT symbol + // '??_7...' does not exist"). The WebKit windows-amd64-lto prebuilt is + // built without it for the same reason. flag: ["-fforce-emit-vtables", "-fwhole-program-vtables"], when: c => c.unix && c.lto, lang: "cxx", @@ -517,11 +528,13 @@ export const globalFlags: Flag[] = [ // (typeidCompatibleVTable entries) and whole-program devirtualization // runs in index-based mode via --lto-whole-program-visibility at link // time. 0 is also the default for rustc, for Apple targets, and for the - // WebKit macos -lto prebuilts, so this is the configuration that can't - // drift. Darwin only: linux uses full LTO (no per-module summaries, so - // the flag is meaningless there). + // WebKit macos/windows -lto prebuilts, so this is the configuration that + // can't drift. Windows: -fwhole-program-vtables is never passed there + // (see above) so 0 is already the default — kept explicit so the + // ThinLTO graph can't drift if that ever changes. Not linux: full LTO + // (no per-module summaries, so the flag is meaningless there). flag: "-fno-split-lto-unit", - when: c => c.darwin && c.lto, + when: c => (c.darwin || c.windows) && c.lto, desc: "Index-based WPD: keep type metadata in the ThinLTO summaries, no regular-LTO half", }, diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 887921474b04..698e4e18e2b2 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -552,6 +552,10 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string // default is also 0 — pass nothing. Adding -Zsplit-lto-unit here // would make the Rust modules the inconsistent ones and abort the // link. + // - windows cross (ThinLTO): same as darwin — clang-cl never gets + // -fwhole-program-vtables (COFF associative-COMDAT abort) and + // -fno-split-lto-unit is passed explicitly, so every C/C++ module is + // 0 and rustc's default 0 matches — pass nothing. // - linux (full LTO): -fwhole-program-vtables on ELF defaults the // split ON for C++, so every C++ module (ours and the WebKit -lto // prebuilts) carries EnableSplitLTOUnit=1. The Rust ThinLTO @@ -565,7 +569,7 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string // (`-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) { + if (!cfg.darwin && !cfg.windows) { rustflags.push("-Zsplit-lto-unit"); } } diff --git a/test/internal/windows-cross-config.test.ts b/test/internal/windows-cross-config.test.ts new file mode 100644 index 000000000000..a46a78d693c2 --- /dev/null +++ b/test/internal/windows-cross-config.test.ts @@ -0,0 +1,208 @@ +/** + * Build-config regression tests for cross-compiling Windows binaries from a + * non-Windows host (scripts/build/config.ts + flags.ts), with a focus on the + * LTO configuration: Windows x64 cross builds use ThinLTO with cross-language + * (Rust↔C++) LTO through rustc's bundled lld-link. + * + * These exercise the configure-time logic only — no compiler, sysroot, or + * WebKit download is involved — so they run on every platform. Scenarios that + * specifically cover the "windows target on a non-windows host" path are + * skipped on Windows, where the same inputs intentionally resolve to the + * native toolchain instead. + */ +import { describe, expect, test } from "bun:test"; +import { isWindows, tempDir } from "harness"; +import { join } from "node:path"; + +import { resolveConfig, type Config, type PartialConfig, type Toolchain } from "../../scripts/build/config.ts"; +import { webkit } from "../../scripts/build/deps/webkit.ts"; +import { computeFlags } from "../../scripts/build/flags.ts"; +import { rustCanCrossFromLinux, rustTarget } from "../../scripts/build/rust.ts"; + +/** A fully-populated fake toolchain — resolveConfig never spawns any of these. */ +function mockToolchain(overrides: Partial = {}): Toolchain { + return { + cc: "/fake/llvm/bin/clang-cl", + cxx: "/fake/llvm/bin/clang-cl", + clangVersion: "21.1.8", + clangResourceDir: "/fake/llvm/lib/clang/21", + ar: "/fake/llvm/bin/llvm-lib", + ranlib: undefined, + ld: "/fake/llvm/bin/lld-link", + ld64Lld: undefined, + rustLld: undefined, + rustLlvmVersion: "22.1.4", + rustSysroot: undefined, + rustHostTriple: undefined, + strip: "/fake/llvm/bin/llvm-strip", + llvmStrip: "/fake/llvm/bin/llvm-strip", + dsymutil: undefined, + bun: "/fake/bin/bun", + jsRuntime: "/fake/bin/bun", + esbuild: "/fake/bin/esbuild", + ccache: undefined, + cmake: "/fake/bin/cmake", + cargo: undefined, + cargoHome: undefined, + rustupHome: undefined, + msvcLinker: undefined, + rc: "/fake/llvm/bin/llvm-rc", + mt: undefined, + nasm: "/fake/bin/nasm", + ...overrides, + }; +} + +/** + * Shorthand: resolve a config for a Windows target the way the CI cross lane + * does (`--profile=ci-release --os=windows --arch=`): Release + ci so + * the LTO default applies, with an explicit fake sysroot so the local-build + * "create one with xwin" error never triggers. + */ +function resolveWindowsCross(partial: PartialConfig = {}, toolchain = mockToolchain()): Config { + return resolveConfig( + { + os: "windows", + arch: "x64", + buildType: "Release", + ci: true, + buildkite: false, + winsysroot: "/fake/winsysroot", + ...partial, + }, + toolchain, + ); +} + +describe.skipIf(isWindows)("Windows cross-compile LTO config (non-windows host)", () => { + test("ci release x64 cross builds default to ThinLTO with cross-language LTO", () => { + const cfg = resolveWindowsCross(); + expect(cfg.windows).toBe(true); + expect(cfg.crossTarget).toBe("x86_64-pc-windows-msvc"); + expect(cfg.lto).toBe(true); + // Rust↔C++ inlining: rustc emits bitcode (-Clinker-plugin-lto) and the + // final lld-link runs one ThinLTO graph across both halves. + expect(cfg.crossLangLto).toBe(true); + }); + + test("no -lto WebKit prebuilt exists for arm64 or baseline — LTO is forced off there", () => { + // arm64: LLVM's CodeView emitter aborts on ARM64 NEON tuple registers + // during LTO codegen, so oven-sh/WebKit ships no windows-arm64-lto. + const arm64 = resolveWindowsCross({ arch: "aarch64" }); + expect(arm64.lto).toBe(false); + expect(arm64.crossLangLto).toBe(false); + + // baseline: no windows-amd64-baseline-lto variant. + const baseline = resolveWindowsCross({ baseline: true }); + expect(baseline.lto).toBe(false); + + // Forced off even when explicitly requested, so the WebKit fetch never + // 404s on a tarball that doesn't exist. + expect(resolveWindowsCross({ arch: "aarch64", lto: true }).lto).toBe(false); + expect(resolveWindowsCross({ baseline: true, lto: true }).lto).toBe(false); + }); + + test("local (non-ci) release builds stay non-LTO unless asked", () => { + const local = resolveWindowsCross({ ci: false }); + expect(local.lto).toBe(false); + const explicit = resolveWindowsCross({ ci: false, lto: true }); + expect(explicit.lto).toBe(true); + expect(explicit.crossLangLto).toBe(true); + }); + + test("compile flags use clang-cl ThinLTO without whole-program vtables", () => { + const flags = computeFlags(resolveWindowsCross()); + expect(flags.cxxflags).toContain("-flto=thin"); + expect(flags.cflags).toContain("-flto=thin"); + // Every summaried module must agree on EnableSplitLTOUnit; rustc's + // bitcode says 0, so the C/C++ side must too. + expect(flags.cxxflags).toContain("-fno-split-lto-unit"); + expect(flags.cflags).toContain("-fno-split-lto-unit"); + // WPD drops vtable symbols that COFF associative COMDAT sections still + // reference and the LTO codegen aborts — never passed on Windows. + expect(flags.cxxflags).not.toContain("-fwhole-program-vtables"); + expect(flags.cxxflags).not.toContain("-fforce-emit-vtables"); + // The unix link-side LTO spellings must not leak into lld-link's flags + // (everything after /link is parsed as MSVC-style options). + expect(flags.ldflags.some(f => f.includes("-flto"))).toBe(false); + expect(flags.ldflags.some(f => f.includes("--lto-O"))).toBe(false); + + // Non-LTO windows configs get none of the LTO flags. + const plain = computeFlags(resolveWindowsCross({ lto: false })); + expect(plain.cxxflags.some(f => f.includes("-flto"))).toBe(false); + expect(plain.cxxflags).not.toContain("-fno-split-lto-unit"); + }); + + test("the link uses rustc's lld-link sibling when rustc's LLVM is newer than clang's", () => { + // resolveConfig swaps cfg.ld so lld-link can read the LLVM-22 bitcode + // rustc emits under -Clinker-plugin-lto (bitcode is forward-compatible + // only). rustc's gcc-ld/ ships every lld flavor; windows needs the + // lld-link sibling of the host-flavored rust-lld that findRustLld() + // resolves. + using dir = tempDir("win-cross-rust-lld", { + "gcc-ld/ld.lld": "", + "gcc-ld/lld-link": "", + }); + const rustLld = join(String(dir), "gcc-ld", "ld.lld"); + const cfg = resolveWindowsCross({}, mockToolchain({ rustLld, rustLlvmVersion: "22.1.4" })); + expect(cfg.ld).toBe(join(String(dir), "gcc-ld", "lld-link")); + // Cargo-driven links (bun_shim_impl.exe) must NOT follow the swap: rustc + // treats a linker inside its own gcc-ld/ as rust-lld and prepends + // `-flavor link`, which breaks the wrapper. They keep the host lld-link. + expect(cfg.msvcLinker).toBe("/fake/llvm/bin/lld-link"); + + // Without LTO there's no bitcode skew to work around — keep the host + // LLVM's lld-link. + const plain = resolveWindowsCross({ lto: false }, mockToolchain({ rustLld, rustLlvmVersion: "22.1.4" })); + expect(plain.ld).toBe("/fake/llvm/bin/lld-link"); + + // If rustc's gcc-ld/ ever stops shipping lld-link, fall back to the host + // lld-link — validateBunConfig() then reports the version skew at + // configure time instead of an opaque "Invalid record" at link time. + using bare = tempDir("win-cross-rust-lld-bare", { "gcc-ld/ld.lld": "" }); + const bareCfg = resolveWindowsCross( + {}, + mockToolchain({ rustLld: join(String(bare), "gcc-ld", "ld.lld"), rustLlvmVersion: "22.1.4" }), + ); + expect(bareCfg.ld).toBe("/fake/llvm/bin/lld-link"); + }); + + test("LTO selects the -lto WebKit prebuilt with a windows-keyed cache dir", () => { + const lto = webkit.source(resolveWindowsCross()); + if (lto.kind !== "prebuilt") throw new Error(`expected prebuilt WebKit source, got ${lto.kind}`); + expect(lto.url).toContain("bun-webkit-windows-amd64-lto.tar.gz"); + expect(lto.destDir).toContain("-windows"); + expect(lto.destDir).toEndWith("-lto"); + + const plain = webkit.source(resolveWindowsCross({ lto: false })); + if (plain.kind !== "prebuilt") throw new Error(`expected prebuilt WebKit source, got ${plain.kind}`); + expect(plain.url).toContain("bun-webkit-windows-amd64.tar.gz"); + expect(plain.destDir).not.toEndWith("-lto"); + + const arm64 = webkit.source(resolveWindowsCross({ arch: "aarch64" })); + if (arm64.kind !== "prebuilt") throw new Error(`expected prebuilt WebKit source, got ${arm64.kind}`); + expect(arm64.url).toContain("bun-webkit-windows-arm64.tar.gz"); + }); + + test("rust side targets pc-windows-msvc triples", () => { + const cfg = resolveWindowsCross(); + expect(rustTarget(cfg)).toBe("x86_64-pc-windows-msvc"); + expect(rustTarget(resolveWindowsCross({ arch: "aarch64" }))).toBe("aarch64-pc-windows-msvc"); + // The shared CI rust-only box intentionally does NOT take windows targets + // (no winsysroot provisioned there) — the cross lanes do the full build, + // including the cargo step, on their own agent. + expect(rustCanCrossFromLinux(cfg)).toBe(false); + }); + + test("linux LTO config is unaffected", () => { + const linux = resolveConfig( + { os: "linux", arch: "x64", abi: "gnu", buildType: "Release", ci: true, buildkite: false }, + mockToolchain({ cc: "/fake/llvm/bin/clang", cxx: "/fake/llvm/bin/clang++", ld: "/fake/llvm/bin/ld.lld" }), + ); + expect(linux.lto).toBe(true); + const linuxFlags = computeFlags(linux); + expect(linuxFlags.cxxflags).toContain("-flto=full"); + expect(linuxFlags.cxxflags).toContain("-fwhole-program-vtables"); + expect(linuxFlags.cxxflags).not.toContain("-fno-split-lto-unit"); + }); +}); From a93ffb353585eedb97749bb45a0ef4a46f903fbb Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 01:38:03 +0000 Subject: [PATCH 19/26] docs/ci: note the Windows x64 cross LTO configuration --- .buildkite/ci.mjs | 10 +++++++--- docs/project/building-windows.mdx | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index a9378bd87906..0018520f45af 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -634,9 +634,13 @@ function getLinkBunStep(platform, options) { * Validates that bun.exe for the given arch can be built from Linux with * clang-cl + lld-link + an xwin Windows sysroot — baked into newer agent * images (.buildkite/Dockerfile), fetched at configure time on agents that - * don't have one (scripts/build/winsysroot.ts). The produced binary is not - * consumed by tests or release — the native Windows lanes above stay - * authoritative — so the step is soft_fail until it has a green history. + * don't have one (scripts/build/winsysroot.ts). The x64 lane additionally + * exercises the ThinLTO + cross-language LTO configuration (the ci-release + * default for windows x64 cross — see config.ts), which the native Windows + * lanes never had: clang-cl/rustc bitcode + the -lto WebKit prebuilt linked + * by rustc's lld-link. The produced binary is not consumed by tests or + * release — the native Windows lanes above stay authoritative — so the step + * is soft_fail until it has a green history. * * Runs on the same amazonlinux docker image the other Linux/cross builds * use; `--buildkite=off` keeps the per-step artifact upload/download diff --git a/docs/project/building-windows.mdx b/docs/project/building-windows.mdx index 948cd87c5c14..bce7fca8f47d 100644 --- a/docs/project/building-windows.mdx +++ b/docs/project/building-windows.mdx @@ -183,3 +183,13 @@ bun run build --profile=windows-arm64-release Output lands in `build/debug-windows-x64/bun-debug.exe`, `build/release-windows-aarch64/bun-profile.exe` + `bun.exe`, etc. Equivalent raw flags: `bun run build --os=windows --arch=aarch64`. Cross-compiled executables are not run on the host (the `--revision` smoke test is skipped), so test them on a Windows machine or under Wine. + +### LTO + +x64 release cross builds support ThinLTO with cross-language (Rust↔C++) LTO, and CI's windows x64 cross lane builds with it by default. Locally it's opt-in: + +```bash +bun run build --profile=windows-x64-release --lto=on +``` + +This compiles bun's C/C++ with `-flto=thin`, makes rustc emit LLVM bitcode (`-Clinker-plugin-lto`), pulls the `bun-webkit-windows-amd64-lto` ThinLTO prebuilt, and links everything with rustc's bundled `lld-link` (its LLVM is new enough to read both compilers' bitcode). There is no LTO for arm64 (no `-lto` WebKit prebuilt — LLVM's CodeView emitter can't handle ARM64 NEON tuple registers during LTO codegen) or for `--baseline`. From 0e446536bfd855ba7a6ce63c43293049a3453307 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 01:50:26 +0000 Subject: [PATCH 20/26] windows: embed bun.exe.manifest via the resource compiler instead of /MANIFEST:EMBED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lld-link only handles /MANIFEST:EMBED itself when it was built with libxml2 and otherwise shells out to mt.exe. The host LLVM's lld-link has libxml2, but rustc's bundled lld-link (used for the cross-language-LTO links so it can read rustc's newer bitcode) has neither, and mt.exe does not exist on non-Windows hosts — the LTO link died with "unable to find mt.exe in PATH". The windows-app-info.rc → llvm-rc → .res step already exists for the icon and VERSIONINFO; add the manifest there as the RT_MANIFEST id-1 resource and drop the linker flags. Same resource in the final PE, produced the same way with any linker (verified: MANIFEST type-24 resource present, longPathAware/SegmentHeap content intact). --- scripts/build/bun.ts | 46 +++++++++++++++++++---------------------- src/windows-app-info.rc | 6 ++++++ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index 6df723f52d1c..c3ca2101f673 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -468,7 +468,7 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { // wrapping helper. const shims = emitShims(n, cfg); const linkObjects = [...allObjects, ...rustObjects, ...windowsRes]; - const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags]; + const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags]; const exe = link(n, cfg, exeName, linkObjects, { libs: depLibs, flags: ldflags, @@ -614,7 +614,7 @@ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { const shims = emitShims(n, cfg); const linkObjects = [archive, ...rustObjects, ...windowsRes]; - const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...manifestLinkFlags(cfg), ...shims.ldflags]; + const ldflags = [...flags.ldflags, ...systemLibs(cfg), ...shims.ldflags]; const exe = link(n, cfg, exeName, linkObjects, { libs: depLibs, flags: ldflags, @@ -797,13 +797,18 @@ function emitDsymutil(n: Ninja, cfg: Config, inputExe: string, exeName: string): * The .rc file provides: * - Icon (bun.ico) * - VS_VERSION_INFO resource (ProductName, FileVersion, CompanyName, ...) + * - The application manifest (longPathAware + SegmentHeap) as an + * RT_MANIFEST resource. Embedding it here instead of via the linker's + * /MANIFEST:EMBED keeps the link independent of the linker's manifest + * tooling: lld-link only handles /MANIFEST:EMBED itself when built with + * libxml2 and otherwise shells out to mt.exe — rustc's bundled lld-link + * (used for the cross-language-LTO links) has neither, and mt.exe does + * not exist on non-Windows hosts. The resource route produces the same + * RT_MANIFEST id-1 resource with any linker. * * This resource section is what rescle's ResourceUpdater modifies when * `bun build --compile --windows-title ...` runs. Without it, the copied * bun.exe has no VersionInfo to update and rescle silently does nothing. - * - * The manifest (longPathAware + SegmentHeap) is embedded at link time via - * /MANIFESTINPUT — see manifestLinkFlags(). */ function emitWindowsResources(n: Ninja, cfg: Config): string { assert(cfg.windows, "emitWindowsResources is windows-only"); @@ -816,6 +821,7 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { // substituted content hasn't changed. const rcTemplate = resolve(cfg.cwd, "src/windows-app-info.rc"); const ico = resolve(cfg.cwd, "src/bun.ico"); + const manifest = resolve(cfg.cwd, "src/bun.exe.manifest"); const rcIn = readFileSync(rcTemplate, "utf8"); const [major = "0", minor = "0", patch = "0"] = cfg.version.split("."); const versionWithTag = cfg.canary ? `${cfg.version}-canary.${cfg.canaryRevision}` : cfg.version; @@ -826,7 +832,8 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { .replace(/@Bun_VERSION_MINOR@/g, minor) .replace(/@Bun_VERSION_PATCH@/g, patch) .replace(/@Bun_VERSION_WITH_TAG@/g, versionWithTag) - .replace(/@BUN_ICO_PATH@/g, slash(ico)); + .replace(/@BUN_ICO_PATH@/g, slash(ico)) + .replace(/@BUN_MANIFEST_PATH@/g, slash(manifest)); const rcFile = resolve(cfg.buildDir, "windows-app-info.rc"); writeIfChanged(rcFile, rcOut); @@ -860,10 +867,10 @@ function emitWindowsResources(n: Ninja, cfg: Config): string { outputs: [resFile], rule: "rc", inputs: [rcFile], - // .ico is embedded by rc at compile time — rebuild if it changes. - // The template is NOT tracked here: it's substituted at configure - // time, so template edits need a reconfigure (happens rarely). - implicitInputs: [ico], + // .ico and the manifest are embedded by rc at compile time — rebuild if + // they change. The template is NOT tracked here: it's substituted at + // configure time, so template edits need a reconfigure (happens rarely). + implicitInputs: [ico, manifest], vars: { rcflags: rcFlags.join(" ") }, }); @@ -900,25 +907,14 @@ function windowsSysrootIncludeDirs(winsysroot: string): string[] { return dirs; } -/** - * Linker flags to embed bun.exe.manifest into the executable. - * The manifest enables longPathAware (paths > MAX_PATH) and SegmentHeap - * (Windows 10+ low-fragmentation heap). - */ -function manifestLinkFlags(cfg: Config): string[] { - if (!cfg.windows) return []; - const manifest = resolve(cfg.cwd, "src/bun.exe.manifest"); - return [`/MANIFEST:EMBED`, `/MANIFESTINPUT:${manifest}`]; -} - /** * Files the linker reads via ldflags that ninja should track for relinking - * (symbol lists, linker script, manifest). CMake's LINK_DEPENDS equivalent. + * (symbol lists, linker script). CMake's LINK_DEPENDS equivalent. + * (The Windows manifest is no longer a link input — it's embedded by the + * resource compiler; see emitWindowsResources.) */ function linkImplicitInputs(cfg: Config): string[] { - const files = linkDepends(cfg); - if (cfg.windows) files.push(resolve(cfg.cwd, "src/bun.exe.manifest")); - return files; + return linkDepends(cfg); } // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/windows-app-info.rc b/src/windows-app-info.rc index 36f8738cf799..4da6b555a2ff 100644 --- a/src/windows-app-info.rc +++ b/src/windows-app-info.rc @@ -2,6 +2,12 @@ IDI_MYICON ICON "@BUN_ICO_PATH@" +// Application manifest (longPathAware + SegmentHeap), embedded as a resource +// rather than via the linker's /MANIFEST:EMBED — that path needs libxml2 (or +// mt.exe) inside the linker, which rustc's bundled lld-link doesn't have. +// 1 = CREATEPROCESS_MANIFEST_RESOURCE_ID, 24 = RT_MANIFEST. +1 24 "@BUN_MANIFEST_PATH@" + VS_VERSION_INFO VERSIONINFO FILEVERSION @Bun_VERSION_MAJOR@,@Bun_VERSION_MINOR@,@Bun_VERSION_PATCH@,0 PRODUCTVERSION @Bun_VERSION_MAJOR@,@Bun_VERSION_MINOR@,@Bun_VERSION_PATCH@,0 From 16da0f213ce7ae0a89b4b967d47f5e893976cac6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 01:50:26 +0000 Subject: [PATCH 21/26] Point WEBKIT_VERSION at the oven-sh/WebKit#239 preview build (TEMPORARY) autobuild-preview-pr-239-245e88fc adds the cross-compiled Windows artifacts, including the bun-webkit-windows-amd64-lto ThinLTO variant the new Windows LTO config consumes. Every non-Windows artifact in that release is built from the same configuration as main cf8fb22b. Swap to the oven-sh/WebKit main commit once #239 merges, before this PR lands. --- scripts/build/deps/webkit.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 92a42ed033da..ef37fb2fa5ff 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,10 +3,13 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -// oven-sh/WebKit main: macOS artifacts cross-compiled on Linux, all -lto -// variants built with ThinLTO (per-module summaries, so bun's -flto=thin -// link imports across the C++/Rust/JSC boundaries). -export const WEBKIT_VERSION = "cf8fb22b701140a4245d21b34ce8c6f813ea3f1f"; +// TEMPORARY: preview build of oven-sh/WebKit#239 (Windows artifacts +// cross-compiled on Linux + the new bun-webkit-windows-amd64-lto ThinLTO +// variant). Same code as main cf8fb22b plus the Windows-only workflow +// changes — every non-Windows artifact is built from the identical +// configuration. Swap to the oven-sh/WebKit main commit once #239 merges, +// before this PR lands. +export const WEBKIT_VERSION = "autobuild-preview-pr-239-245e88fc"; /** * WebKit (JavaScriptCore) — the JS engine. From 1ebba50dda77bcae622d35e4054e952f13e9e9d6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 02:10:48 +0000 Subject: [PATCH 22/26] windows: restore /OPT:SAFEICF NOICF was a temporary diagnostic state (unfolded PDB symbolication for the Strong corruption investigation); SAFEICF only folds functions whose address is never taken, so the ClassInfo/constructor identity guarantees that ruled out plain /OPT:ICF still hold. With ThinLTO on the x64 cross builds the folding also claws back part of the cross-module inlining growth. --- scripts/build/flags.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 4a2f4d4d07f9..1d11f723f49d 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -956,23 +956,20 @@ export const linkerFlags: Flag[] = [ "/LTCG", "/OPT:REF", // SAFEICF (lld-specific) only folds functions whose address is never - // taken, so JSC ClassInfo native constructors — stored as pointers and - // compared for identity — stay distinct. /OPT:ICF (aggressive) folded + // taken (it honours .llvm_addrsig; objects without one — MSVC CRT + // import libs, the prebuilt ICU data — are treated conservatively), so + // JSC ClassInfo native constructors — stored as pointers and compared + // for identity — stay distinct. /OPT:ICF (aggressive) folded // callBigIntConstructor with constructBigInt → "not a constructor", // and broke expect.any(Constructor); see commit 218430c731. Mirrors // Linux `-Wl,-icf=safe`. // - // TEMPORARILY /OPT:NOICF instead of /OPT:SAFEICF: re-enabling - // `panic = "abort"` (Cargo.toml) exposes a Windows-only `Strong* - // corrupted (0x1)` in the fs/promises writeFile async-iterable path - // (#53265+). Under abort's no-landing-pad codegen SAFEICF folds enough - // Rust+C++ bodies that PDB symbolication maps the crash to - // lol_html/ucnv_MBCS/JSBigInt — useless for finding the owning struct. - // `bun-profile.exe` and `bun.exe` share this link (strip-only diff), so - // NOICF can't be confined to the profile binary alone; once the - // corruption is root-caused via `llvm-symbolizer --relative-address` - // against the NOICF PDB, revert this to `/OPT:SAFEICF`. - "/OPT:NOICF", + // (This was temporarily /OPT:NOICF so PDB symbolication stayed + // unfolded while chasing the Windows-only `Strong* corrupted + // (0x1)` crash in the fs/promises writeFile async-iterable path under + // `panic = "abort"` — flip it back locally if that investigation needs + // an unfolded PDB again.) + "/OPT:SAFEICF", // String-literal tail merging (lld-specific; MSVC link.exe has no // equivalent). Helps .rdata the same way --icf handles .rodata.cst on ELF. "/OPT:lldtailmerge", From 3deb8905e392a9853cdadda7721c2aa74e52aed7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 02:15:12 +0000 Subject: [PATCH 23/26] verify-baseline: widen icelake simdutf ceilings for the cross-built WTF The cross-compiled windows WebKit artifacts (clang-cl 21 on Linux) emit Vpermb (AVX512_VBMI) in convert_utf16be_to_latin1 / convert_valid_ utf16be_to_latin1 and Vmovdqa64 (AVX512F) in the validate_utf16* kernels where the native-built objects didn't. All six symbols are icelake implementations selected only through simdutf's runtime CPUID dispatch (the icelake gate requires the AVX-512BW/CD/VL/VBMI2/VPOPCNTDQ set), the same gate every other icelake entry in this allowlist already relies on. --- .../verify-baseline-static/allowlist-x64-windows.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index 66ad1e65af99..8d4887b90abe 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -116,7 +116,7 @@ simdutf::icelake::implementation::convert_latin1_to_utf16be simdutf::icelake::implementation::convert_latin1_to_utf16le [AVX, AVX512BW, AVX512F, AVX512VL, BMI2] simdutf::icelake::implementation::convert_latin1_to_utf32 [AVX, AVX512BW, AVX512F, AVX512VL, BMI2] simdutf::icelake::implementation::convert_latin1_to_utf8 [AVX, AVX512BW, AVX512F, AVX512_VBMI, AVX512_VBMI2, BMI2] -simdutf::icelake::implementation::convert_utf16be_to_latin1 [AVX, AVX512BW, AVX512F, BMI2] +simdutf::icelake::implementation::convert_utf16be_to_latin1 [AVX, AVX512BW, AVX512F, AVX512_VBMI, BMI2] simdutf::icelake::implementation::convert_utf16be_to_latin1_with_errors [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::convert_utf16be_to_utf32 [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::convert_utf16be_to_utf32_with_errors [AVX, AVX512BW, AVX512F, BMI2] @@ -144,7 +144,7 @@ simdutf::icelake::implementation::convert_utf8_to_utf16le simdutf::icelake::implementation::convert_utf8_to_utf16le_with_errors [AVX, AVX512BW, AVX512F, AVX512_VBMI, AVX512_VBMI2, BMI1, BMI2, LZCNT] simdutf::icelake::implementation::convert_utf8_to_utf32 [AVX, AVX2, AVX512BW, AVX512F, AVX512_VBMI, BMI2] simdutf::icelake::implementation::convert_utf8_to_utf32_with_errors [AVX, AVX2, AVX512BW, AVX512F, AVX512_VBMI, BMI2] -simdutf::icelake::implementation::convert_valid_utf16be_to_latin1 [AVX, AVX512BW, AVX512F, BMI2] +simdutf::icelake::implementation::convert_valid_utf16be_to_latin1 [AVX, AVX512BW, AVX512F, AVX512_VBMI, BMI2] simdutf::icelake::implementation::convert_valid_utf16be_to_utf32 [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::convert_valid_utf16le_to_latin1 [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::convert_valid_utf16le_to_utf32 [AVX, AVX512BW, AVX512F, BMI2] @@ -179,10 +179,10 @@ simdutf::icelake::implementation::validate_ascii simdutf::icelake::implementation::validate_ascii_with_errors [AVX, AVX512BW, BMI2] simdutf::icelake::implementation::validate_utf16be [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::validate_utf16be_as_ascii [AVX, AVX512BW, AVX512F, BMI2] -simdutf::icelake::implementation::validate_utf16be_with_errors [AVX, AVX512BW, BMI1, BMI2] -simdutf::icelake::implementation::validate_utf16le [AVX, AVX512BW, BMI2] -simdutf::icelake::implementation::validate_utf16le_as_ascii [AVX, AVX512BW, BMI2] -simdutf::icelake::implementation::validate_utf16le_with_errors [AVX, AVX512BW, BMI1, BMI2] +simdutf::icelake::implementation::validate_utf16be_with_errors [AVX, AVX512BW, AVX512F, BMI1, BMI2] +simdutf::icelake::implementation::validate_utf16le [AVX, AVX512BW, AVX512F, BMI2] +simdutf::icelake::implementation::validate_utf16le_as_ascii [AVX, AVX512BW, AVX512F, BMI2] +simdutf::icelake::implementation::validate_utf16le_with_errors [AVX, AVX512BW, AVX512F, BMI1, BMI2] simdutf::icelake::implementation::validate_utf32 [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::validate_utf32_with_errors [AVX, AVX512BW, AVX512F, BMI2] simdutf::icelake::implementation::validate_utf8 [AVX, AVX512BW, AVX512F, BMI2] From 1a83cd3672021b61912cf79dfe504768b13c551d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 02:44:12 +0000 Subject: [PATCH 24/26] Enable the lazy zstd ICU data decompression hook on Windows The cross-compiled Windows WebKit artifacts now ship sicudt.lib as the same per-item zstd repack the Linux artifacts use (filtered, then compressed with a shared trained dictionary), with udata.cpp calling the weak bun_icu_maybe_decompress hook. Widen the hook's platform gate so Windows builds define it; with the unpatched/uncompressed native windows-release.ps1 artifacts the hook simply never fires. --- src/jsc/bindings/bun_icu_decompress.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/bun_icu_decompress.cpp b/src/jsc/bindings/bun_icu_decompress.cpp index 0515784367b4..d17798d3023f 100644 --- a/src/jsc/bindings/bun_icu_decompress.cpp +++ b/src/jsc/bindings/bun_icu_decompress.cpp @@ -13,12 +13,20 @@ #include "root.h" -// The repacked libicudata.a (and the patched udata.cpp that calls this hook) -// are produced by oven-sh/WebKit's Dockerfile / Dockerfile.musl only. On every -// other platform ICU is unmodified, so there is nothing to decompress and the -// weak externs below have no definer — gate the whole implementation to keep -// non-ELF weak-symbol semantics out of the picture. -#if OS(LINUX) +// The repacked ICU data archive (and the patched udata.cpp that calls this +// hook) are produced by oven-sh/WebKit's Dockerfile / Dockerfile.musl / +// Dockerfile.windows. On macOS ICU is the unmodified system one, so there is +// nothing to decompress and the weak externs below would have no definer — +// gate the implementation to the platforms whose prebuilts carry compressed +// items. +// +// Windows note: COFF has no true weak-undefined symbols — clang lowers each +// declaration to a per-TU weak external with an absolute-0 default, which is +// fine here because this is the only TU referencing the dict symbols and the +// repacked sicudt.lib defines them anyway (an unresolved weak external only +// becomes a problem when two TUs reference it, see the WTFTimer__* notes in +// oven-sh/WebKit). +#if OS(LINUX) || OS(WINDOWS) #include "MimallocWTFMalloc.h" From 551b532b2a07d47c04019186149dd47d3fc5ffa2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 03:39:34 +0000 Subject: [PATCH 25/26] Bump WEBKIT_VERSION to the oven-sh/WebKit#239 preview with zstd ICU data (TEMPORARY) autobuild-preview-pr-239-bca1d74d adds the filtered + per-item-zstd ICU data table to every Windows artifact (sicudt.lib 24.6 MB -> 11.9 MB) on top of the earlier ThinLTO -lto variant. Swap to the oven-sh/WebKit main commit once #239 merges, before this PR lands. --- scripts/build/deps/webkit.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index ef37fb2fa5ff..4126840bf260 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -4,12 +4,12 @@ * From https://github.com/oven-sh/WebKit releases. */ // TEMPORARY: preview build of oven-sh/WebKit#239 (Windows artifacts -// cross-compiled on Linux + the new bun-webkit-windows-amd64-lto ThinLTO -// variant). Same code as main cf8fb22b plus the Windows-only workflow -// changes — every non-Windows artifact is built from the identical -// configuration. Swap to the oven-sh/WebKit main commit once #239 merges, -// before this PR lands. -export const WEBKIT_VERSION = "autobuild-preview-pr-239-245e88fc"; +// cross-compiled on Linux, the new bun-webkit-windows-amd64-lto ThinLTO +// variant, and the filtered + per-item-zstd ICU data table). Same code as +// main cf8fb22b plus the Windows-only workflow changes — every non-Windows +// artifact is built from the identical configuration. Swap to the +// oven-sh/WebKit main commit once #239 merges, before this PR lands. +export const WEBKIT_VERSION = "autobuild-preview-pr-239-bca1d74d"; /** * WebKit (JavaScriptCore) — the JS engine. From 639cdd2915b86e7e07db86ccd3f60723318499f2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 09:10:59 +0000 Subject: [PATCH 26/26] Bump WEBKIT_VERSION to oven-sh/WebKit main 963f8758c29e The Windows cross-compile + ThinLTO + filtered/zstd-ICU WebKit work (oven-sh/WebKit#239) is merged to main; point at the main autobuild instead of the PR preview release. --- scripts/build/deps/webkit.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 4126840bf260..c49031a453f7 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,13 +3,11 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -// TEMPORARY: preview build of oven-sh/WebKit#239 (Windows artifacts -// cross-compiled on Linux, the new bun-webkit-windows-amd64-lto ThinLTO -// variant, and the filtered + per-item-zstd ICU data table). Same code as -// main cf8fb22b plus the Windows-only workflow changes — every non-Windows -// artifact is built from the identical configuration. Swap to the -// oven-sh/WebKit main commit once #239 merges, before this PR lands. -export const WEBKIT_VERSION = "autobuild-preview-pr-239-bca1d74d"; +// oven-sh/WebKit main: macOS + Windows artifacts cross-compiled on Linux, +// -lto variants built with ThinLTO (per-module summaries for cross-language +// importing), and the Windows ICU data table filtered + per-item zstd +// compressed (lazily decompressed via bun_icu_decompress.cpp). +export const WEBKIT_VERSION = "963f8758c29e965471c191668d5776a1a1b014b6"; /** * WebKit (JavaScriptCore) — the JS engine.