diff --git a/.buildkite/ci.mjs b/.buildkite/ci.mjs index bc34dd62d4ba..176ee6eeb24f 100755 --- a/.buildkite/ci.mjs +++ b/.buildkite/ci.mjs @@ -725,10 +725,17 @@ function getVerifyBaselineStep(platform, options) { * * linux-aarch64 is absent because its build lane runs on the aarch64 host and * traces itself; `packageAndUpload()` is its sole publisher. + * + * The `on` platforms are entries of `testPlatforms`, so the step runs on an + * image that exists. The windows tracer is built on the test VM for whichever + * architecture it is running on (scripts/orderfile/functrace-windows.c), so each + * windows target traces on its own arch's fleet. */ const traceOrderTargets = [ { os: "darwin", arch: "aarch64", on: { os: "darwin", arch: "aarch64", release: "26", tier: "latest" } }, { os: "linux", arch: "x64", on: { os: "linux", arch: "x64", distro: "debian", release: "13" } }, + { os: "windows", arch: "x64", on: { os: "windows", arch: "x64", release: "2019", tier: "oldest" } }, + { os: "windows", arch: "aarch64", on: { os: "windows", arch: "aarch64", release: "11", tier: "latest" } }, ]; /** @@ -744,15 +751,24 @@ const traceOrderTargets = [ * Non-PR only — `orderFileEligible()` ignores PR builds, so a trace there has * no consumer. Soft-fail: the order file is an optimization, and a broken * tracer must not fail a build. + * + * Windows agents run commands under cmd.exe (see getVerifyBaselineStep for the + * `|| exit /b 1` convention). The generator compiles the tracer there, which + * takes clang-cl or a Visual Studio environment; the image has both, and + * vs-shell.ps1 provides the latter the same way it does for the test runner. + * The profile zip carries the two maps the generator resolves addresses with + * (packageAndUpload in scripts/build/ci.ts; scripts/orderfile/windows-symbols.ts). * @param {Target} target * @param {Platform} tracePlatform * @param {PipelineOptions} options * @returns {CommandStep} */ function getTraceOrderStep(target, tracePlatform, options) { + const { os } = target; const targetKey = getTargetKey(target); const triplet = getTargetTriplet(target); const profileDir = `${triplet}-profile`; + const generate = `scripts/orderfile/generate.ts --build-dir=${profileDir} --out=${triplet}.order`; return { key: `${targetKey}-trace-order`, label: `${getTargetLabel(target)} - trace-order`, @@ -762,13 +778,21 @@ function getTraceOrderStep(target, tracePlatform, options) { cancel_on_build_failing: isMergeQueue(), soft_fail: true, timeout_in_minutes: 15, - command: [ - `buildkite-agent artifact download '${profileDir}.zip' . --step ${targetKey}-build-bun`, - `unzip -o '${profileDir}.zip'`, - `chmod +x ${profileDir}/bun-profile`, - `./${profileDir}/bun-profile scripts/orderfile/generate.ts --build-dir=${profileDir} --out=${triplet}.order`, - `buildkite-agent artifact upload '${triplet}.order'`, - ], + command: + os === "windows" + ? [ + `buildkite-agent artifact download ${profileDir}.zip . --step ${targetKey}-build-bun || exit /b 1`, + `tar -xf ${profileDir}.zip || exit /b 1`, + `pwsh -NoProfile -File .\\scripts\\vs-shell.ps1 .\\${profileDir}\\bun-profile.exe ${generate} || exit /b 1`, + `buildkite-agent artifact upload ${triplet}.order`, + ] + : [ + `buildkite-agent artifact download '${profileDir}.zip' . --step ${targetKey}-build-bun`, + `unzip -o '${profileDir}.zip'`, + `chmod +x ${profileDir}/bun-profile`, + `./${profileDir}/bun-profile ${generate}`, + `buildkite-agent artifact upload '${triplet}.order'`, + ], }; } diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index c7bedb065579..ae2fdef75eb6 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -37,10 +37,10 @@ import { allDeps } from "./deps/index.ts"; import { lolhtml } from "./deps/lolhtml.ts"; import { rustArgon2 } from "./deps/rust-argon2.ts"; import { assert } from "./error.ts"; -import { bunIncludes, computeFlags, extraFlagsFor, linkDepends } from "./flags.ts"; +import { bunIncludes, computeFlags, extraFlagsFor, linkDepends, linkerMapOutputs } from "./flags.ts"; import { writeIfChanged } from "./fs.ts"; import type { BuildNode, Ninja } from "./ninja.ts"; -import { emitRust, linkerMapPath, rustLibPath, rustLtoLinkInputs } from "./rust.ts"; +import { emitRust, rustLibPath, rustLtoLinkInputs } from "./rust.ts"; import { quote, slash } from "./shell.ts"; import { emitShims, machoPostlinkCommand, machoPostlinkImplicitInputs } from "./shims.ts"; import { computeDepLibs, resolveDep, type ResolvedDep } from "./source.ts"; @@ -511,10 +511,9 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs, ...depChecks], - // Declare the `-Wl,-Map=` side-product so `perf` symbolication picks it - // up. Linux release only — the map flag itself is gated identically in - // flags.ts. - linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, + // Declare the maps the release link writes as side-products (`perf` + // symbolication on linux; the order file tracer's symbol table on windows). + linkerMapOutputs: linkerMapOutputs(cfg), }); // ─── Step 7: post-link (strip, dsymutil, smoke test) ─── @@ -658,7 +657,7 @@ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs], - linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, + linkerMapOutputs: linkerMapOutputs(cfg), }); // Strip + smoke test — same as full mode. @@ -735,7 +734,7 @@ function emitRustAndLink(n: Ninja, cfg: Config, sources: Sources): BunOutput { libs: depLibs, flags: ldflags, implicitInputs: [...linkImplicitInputs(cfg), ...shims.implicitInputs], - linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, + linkerMapOutputs: linkerMapOutputs(cfg), }); const { strippedExe, dsym } = emitPostLink(n, cfg, exe, exeName, flags.stripflags); diff --git a/scripts/build/ci.ts b/scripts/build/ci.ts index a890f5974a6c..13cff829e4bb 100644 --- a/scripts/build/ci.ts +++ b/scripts/build/ci.ts @@ -21,7 +21,7 @@ import { } from "node:fs"; import { basename, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { generateOrderFile } from "../orderfile/generate.ts"; +import { generateOrderFile, readTextSymbols } from "../orderfile/generate.ts"; // @ts-ignore — utils.mjs has JSDoc types but no .d.ts import * as utils from "../utils.mjs"; import { bunExeName, shouldStrip, type BunOutput } from "./bun.ts"; @@ -29,7 +29,7 @@ import type { Config } from "./config.ts"; import { webkitTestFFIPath } from "./deps/webkit.ts"; import { BuildError } from "./error.ts"; import { crossFeaturesJson } from "./features-json.ts"; -import { orderFilePath, usesOrderFile } from "./flags.ts"; +import { linkerMapOutputs, orderFilePath, usesOrderFile } from "./flags.ts"; /** True if running under any CI (env: CI, BUILDKITE, or GITHUB_ACTIONS). */ export const isCI: boolean = utils.isCI; @@ -342,7 +342,10 @@ function upload(paths: string[], cwd: string): void { // ├── bun-profile[.exe] // ├── testFFI[.exe] (WebKit FFI test binary, when shipped) // ├── features.json -// ├── bun-profile.linker-map (linux/mac non-asan) +// ├── bun-profile.linker-map (linkerMapOutputs: release, non-asan) +// ├── bun-profile.map (windows; with the above, what the +// │ trace-order step resolves addresses with) +// ├── linker.order (the order file this binary was linked with, if any) // ├── bun-profile.pdb (windows) // └── bun-profile.dSYM (mac) // @@ -434,10 +437,10 @@ export function packageAndUpload(cfg: Config, output: BunOutput): void { } else if (cfg.darwin) { files.push(`${exeName}.dSYM`); } - // Linker map: posix non-asan (cmake gate: (APPLE OR LINUX) AND NOT ENABLE_ASAN). - if (cfg.unix && !cfg.asan) { - files.push(`${exeName}.linker-map`); - } + // Linker map(s). On windows they are also what the trace-order step + // (.buildkite/ci.mjs) resolves traced addresses against, the PE itself + // having no symbol table, so without them that step has nothing to work from. + files.push(...linkerMapOutputs(cfg).map(map => basename(map))); // The symbol ordering file this binary was linked with, next to the linker // map. Skip the seeded placeholder — it has no functions in it. const hasOrderFile = usesOrderFile(cfg) && orderFileFunctionCount(cfg) > 0; @@ -993,24 +996,21 @@ export function verifyOrderFileApplied(cfg: Config, ctx: OrderFileContext, exe: return; } - // Same resolution as generate.ts: honor NM, else llvm-nm, else nm. - let nm = { status: null, stdout: "" } as { status: number | null; stdout: string }; - for (const tool of [process.env.NM, "llvm-nm", "nm"].filter(Boolean) as string[]) { - nm = spawnSync(tool, ["--defined-only", exe], { encoding: "utf8", maxBuffer: 1 << 29 }); - if (nm.status === 0) break; - } - if (nm.status !== 0) { - console.log("~ symbol order: no working nm — skipping verification"); + // The same names the generator traces against: nm's, or on windows the link's maps'. + let symbols: Map; + try { + symbols = readTextSymbols(exe); + } catch (error) { + console.log( + `~ symbol order: cannot read the binary's symbols — skipping verification (${(error as Error).message})`, + ); return; } const addresses = new Map(); let textBase = Number.MAX_SAFE_INTEGER; - for (const line of nm.stdout.split("\n")) { - const m = /^([0-9a-f]+) ([tT]) (\S+)$/.exec(line); - if (!m) continue; - const address = parseInt(m[1]!, 16); - addresses.set(m[3]!, address); + for (const [address, names] of symbols) { + for (const name of names) addresses.set(name, address); if (address < textBase) textBase = address; } @@ -1055,7 +1055,9 @@ export function verifyOrderFileApplied(cfg: Config, ctx: OrderFileContext, exe: `the order file had no effect: hot functions sit at ${mb(hot)}, a typical one at ${mb(control)}`, cfg.darwin ? "Apple ld ignored it — check -order_file and that the names match nm's" - : "lld ignored it — check --symbol-ordering-file and that -ffunction-sections survived", + : cfg.windows + ? "lld-link ignored it — check /order and that /Gy survived" + : "lld ignored it — check --symbol-ordering-file and that -ffunction-sections survived", ); return; } diff --git a/scripts/build/compile.ts b/scripts/build/compile.ts index dcaaa2870818..d80b62f435e2 100644 --- a/scripts/build/compile.ts +++ b/scripts/build/compile.ts @@ -450,8 +450,8 @@ export interface LinkOpts { * Editing these should trigger relink (cmake's LINK_DEPENDS equivalent). */ implicitInputs?: string[]; - /** Output linker map to this path (for debugging symbol bloat). */ - linkerMapOutput?: string | undefined; + /** Map files the link's flags make it write alongside the executable (flags.ts linkerMapOutputs). */ + linkerMapOutputs?: string[]; } /** @@ -461,11 +461,8 @@ export interface LinkOpts { export function link(n: Ninja, cfg: Config, out: string, objects: string[], opts: LinkOpts): string { const absOut = resolve(cfg.buildDir, out + cfg.exeSuffix); - // Linker map is an implicit output (ninja tracks it but not in $out) - const implicitOutputs: string[] = []; - if (opts.linkerMapOutput !== undefined) { - implicitOutputs.push(resolve(cfg.buildDir, opts.linkerMapOutput)); - } + // Linker maps are implicit outputs (ninja tracks them but they're not in $out) + const implicitOutputs = (opts.linkerMapOutputs ?? []).map(map => resolve(cfg.buildDir, map)); const node: BuildNode = { outputs: [absOut], diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 7bd4b6a5a73e..184aa86f8fc3 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -1009,6 +1009,32 @@ export const linkerFlags: Flag[] = [ when: c => c.windows && c.release, desc: "Release link opts + delay-load non-critical DLLs (faster startup)", }, + { + // A PE carries no symbol table — lld-link puts the names in the PDB — so + // these two maps stand in for it on the order file's behalf (see + // scripts/orderfile/windows-symbols.ts): the MSVC-style one lists every + // symbol by final address under the exact name /order takes, and lld's own + // says where each input chunk was placed, which is what tells a function + // apart from the labels the MSVC CRT leaves on data inside its code. They + // ship in the profile zip beside the binary, for the trace-order step + // (.buildkite/ci.mjs) and for verifyOrderFileApplied() in scripts/build/ci.ts. + flag: c => [`/lldmap:${slash(linkerMapPath(c))}`, `/map:${slash(symbolMapPath(c))}`], + when: c => c.windows && writesLinkerMap(c), + desc: "Linker maps: the order file tracer's symbol table (see windows-symbols.ts)", + }, + { + // lld-link's spelling of the symbol ordering file (the Linux entry below + // explains it): one COMDAT leader name per line, laid out first in .text. + // Every function is its own COMDAT here — /Gy for bun's C++ and for the + // WebKit prebuilt, rustc's default function sections for the Rust side, and + // LTO output is per-function regardless — so one trace reorders all of + // them. /ignore:4037 is --no-warn-symbol-ordering's counterpart: the names + // were traced from an earlier build's binary (see usesOrderFile), and each + // one that no longer exists would otherwise be an LNK4037 warning. + flag: c => [`/order:@${slash(orderFilePath(c))}`, "/ignore:4037"], + when: c => c.windows && usesOrderFile(c), + desc: "Sort startup-hot functions to the front of .text (cuts resident binary pages)", + }, // ─── macOS ─── { @@ -1107,7 +1133,7 @@ export const linkerFlags: Flag[] = [ desc: "Suppress all linker warnings (workaround: no selective suppress for alignment warnings as of 2025-07)", }, { - flag: c => ["-dead_strip", "-dead_strip_dylibs", `-Wl,-map,${c.buildDir}/${bunExeName(c)}.linker-map`], + flag: c => ["-dead_strip", "-dead_strip_dylibs", `-Wl,-map,${linkerMapPath(c)}`], when: c => c.darwin && c.release, desc: "Dead-code strip + emit linker map", }, @@ -1293,7 +1319,7 @@ export const linkerFlags: Flag[] = [ // with `bun-profile`, so disabling ICF on the profile binary "for perf // symbolication" would also bloat the shipped binary's .text — and // `perf` symbolicates folded functions fine via the linker-map anyway. - flag: c => ["-Wl,-icf=safe", `-Wl,-Map=${c.buildDir}/${bunExeName(c)}.linker-map`], + flag: c => ["-Wl,-icf=safe", `-Wl,-Map=${linkerMapPath(c)}`], when: c => c.linux && c.release && !c.asan && !c.valgrind, desc: "Identical-code-folding (safe; perf symbolication uses the linker-map)", }, @@ -1426,13 +1452,18 @@ export const linkerFlags: Flag[] = [ /** * Whether this target links with a symbol ordering file (lld * `--symbol-ordering-file` on linux, `-order_file` on darwin, which both Apple - * ld and ld64.lld take). Only where the startup win is worth a relink: release - * builds, not under a sanitizer — the tracer swaps `.text` out for a private - * copy, and nobody measures startup RSS on an ASAN build anyway. + * ld and ld64.lld take, lld-link `/order` on windows). Only where the startup + * win is worth a relink: release builds, not under a sanitizer — the tracer + * swaps `.text` out for a private copy, and nobody measures startup RSS on an + * ASAN build anyway. * * This says where the order file is CONSUMED, not where it is produced. A * cross-compiled lane cannot trace its own binary (`canTraceOrderFile`), so it - * inherits an earlier build's file instead and still links ordered. + * inherits an earlier build's file instead and still links ordered; the + * trace-order step in .buildkite/ci.mjs produces that file on the target's test + * fleet. Both windows targets work this way: their tracer is a debugger + * (scripts/orderfile/functrace-windows.c), so it needs no preload mechanism, + * and it plants INT3 or BRK depending on which architecture it is built for. * * linux gnu only: musl links statically, so LD_PRELOAD cannot load the tracer, * and no musl test host exists to trace on either. android has no order-file @@ -1442,12 +1473,12 @@ export const linkerFlags: Flag[] = [ * inherit and linking with an always-empty order file just adds noise. */ export function usesOrderFile( - cfg: Pick, + cfg: Pick, ): boolean { if (!cfg.release || cfg.asan || cfg.valgrind) return false; if (cfg.linux) return cfg.abi === "gnu"; if (cfg.darwin) return cfg.arm64; - return false; + return cfg.windows; } /** The order file lives in the build directory — it is generated, never committed. */ @@ -1455,25 +1486,59 @@ export function orderFilePath(cfg: Pick): string { return join(cfg.buildDir, "linker.order"); } +/** + * Whether the link writes its map(s); mirrors the map flags above (linux: the + * `-icf=safe` entry, darwin: `-dead_strip`, windows: `/lldmap` + `/map`). + * `linkerMapOutputs()` names them: bun.ts declares those to ninja as the link's + * outputs, and ci.ts ships them in the profile zip — on windows the trace-order + * step cannot work without them (see the `/lldmap` entry). + */ +export function writesLinkerMap( + cfg: Pick, +): boolean { + if (!cfg.release) return false; + if (cfg.linux) return !cfg.asan && !cfg.valgrind; + return cfg.darwin || cfg.windows; +} + +/** `/bun-profile.linker-map`: the linker's own map — lld's `-Map`, ld64's `-map`, lld-link's `/lldmap`. */ +export function linkerMapPath(cfg: Config): string { + return join(cfg.buildDir, `${bunExeName(cfg)}.linker-map`); +} + +/** + * `/bun-profile.map`: lld-link's MSVC-style `/map`, every symbol by + * address. Windows only; the same name scripts/orderfile/windows-symbols.ts + * derives from the binary's. + */ +export function symbolMapPath(cfg: Config): string { + return join(cfg.buildDir, `${bunExeName(cfg)}.map`); +} + +/** The map files the link writes (see writesLinkerMap), or none. */ +export function linkerMapOutputs(cfg: Config): string[] { + if (!writesLinkerMap(cfg)) return []; + return cfg.windows ? [linkerMapPath(cfg), symbolMapPath(cfg)] : [linkerMapPath(cfg)]; +} + /** * Files the linker reads via flags above. Return as implicit inputs so * ninja relinks when exported symbols / version script change. * CMake tracks these via set_target_properties LINK_DEPENDS. + * + * The release symbol ordering file is one of them on every target that uses + * it: listing it here is what makes regenerating (or inheriting) it relink, and + * only relink. */ export function linkDepends(cfg: Config): string[] { if (cfg.freebsd) return [join(cfg.cwd, "src/symbols.dyn"), join(cfg.cwd, "src/linker-freebsd.lds")]; - if (cfg.windows) return [join(cfg.cwd, "src/symbols.def")]; - // The release symbol ordering file: listing it here is what makes - // regenerating it relink, and only relink. - if (cfg.darwin) { - const darwin = [join(cfg.cwd, "src/symbols.txt")]; - if (usesOrderFile(cfg)) darwin.push(orderFilePath(cfg)); - return darwin; - } - // linux: ELF dynamic-list + version script. - const linux = [join(cfg.cwd, "src/symbols.dyn"), join(cfg.cwd, "src/linker.lds")]; - if (usesOrderFile(cfg)) linux.push(orderFilePath(cfg)); - return linux; + const depends = cfg.windows + ? [join(cfg.cwd, "src/symbols.def")] + : cfg.darwin + ? [join(cfg.cwd, "src/symbols.txt")] + : [join(cfg.cwd, "src/symbols.dyn"), join(cfg.cwd, "src/linker.lds")]; // linux: ELF dynamic-list + version script + if (usesOrderFile(cfg)) depends.push(orderFilePath(cfg)); + return depends; } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 8e149cc4ba2b..5b7872b7072d 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -26,7 +26,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import { bunExeName, type Abi, type Arch, type Config, type OS } from "./config.ts"; +import type { Abi, Arch, Config, OS } from "./config.ts"; import { assert } from "./error.ts"; import { computeCpuTargetFlags } from "./flags.ts"; import type { Ninja } from "./ninja.ts"; @@ -974,11 +974,6 @@ export function rustLtoLinkInputs(n: Ninja, cfg: Config, rustObjects: string[]): return [out, ...rustObjects]; } -/** `${buildDir}/${exe}.linker-map` — lld's `-Wl,-Map=` output (see flags.ts). */ -export function linkerMapPath(cfg: Config): string { - return join(cfg.buildDir, `${bunExeName(cfg)}.linker-map`); -} - /** * Linker flags to wrap the Rust staticlib so every `#[no_mangle]` member * reaches the final image (the dynamic-list / NAPI surface has no inbound diff --git a/scripts/orderfile/functrace-windows.c b/scripts/orderfile/functrace-windows.c new file mode 100644 index 000000000000..1d34712b4612 --- /dev/null +++ b/scripts/orderfile/functrace-windows.c @@ -0,0 +1,541 @@ +// Function-entry tracer for scripts/orderfile/generate.ts on Windows: what +// functrace.c and ptyrun.c do on linux and macOS, as one program. +// +// Produces functrace.c's record — the functions a run enters, in first-entry +// order — the way a debugger would. The binary under trace is started as this +// program's debuggee, and while it is still stopped at its creation event, with +// nothing of it run yet, the first instruction of every function is overwritten +// with a breakpoint (x86-64 INT3, arm64 BRK). Each breakpoint exception puts the +// instruction back, records the address and resumes the thread at it, so every +// function traps exactly once and runs at full speed afterwards. Nothing is +// loaded into the traced process, and its children are not debugged +// (DEBUG_ONLY_THIS_PROCESS), which is what functrace.c has to arrange by +// scrubbing itself out of the environment. +// +// With BUN_FUNCTRACE_TTY set, the debuggee is started on a pseudo console +// rather than our stdio, as ptyrun.c starts its child on a pty: our stdin is +// typed into it and whatever it writes is forwarded to our stdout. Console +// stdio is a different path through bun (libuv's tty layer, console modes, +// WriteConsole) than a pipe, and the only way to reach it is to be a console. +// +// Function starts arrive as link-time addresses (a PE carries no symbol table; +// generate.ts gets them from the link's maps, see windows-symbols.ts). ASLR +// relocates the image, so they are shifted by the load slide on the way in and +// back on the way out. The trace file layout is functrace.c's. +// +// clang-cl /O2 -fuse-ld=lld functrace-windows.c (or: cl /O2 functrace-windows.c) +// set BUN_FUNCTRACE_STARTS=starts.bin +// set BUN_FUNCTRACE_OUT=trace.bin +// functrace-windows build\release\bun-profile.exe -e "console.log(1)" +// +// Exits with the debuggee's exit code; 2 if the trace itself could not be set up. +#if !defined(_M_X64) && !defined(_M_ARM64) +#error "functrace-windows.c builds for x64 or arm64 Windows" +#endif + +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "kernel32.lib") + +#if defined(_M_X64) +typedef uint8_t insn_t; +#define BREAKPOINT ((insn_t)0xcc) // INT3 +#define MACHINE IMAGE_FILE_MACHINE_AMD64 +#define MACHINE_NAME "x64" +#define PC(context) ((context)->Rip) +static int is_breakpoint(insn_t insn) { return insn == BREAKPOINT; } +#else +typedef uint32_t insn_t; +#define BREAKPOINT ((insn_t)0xd43e0000) // BRK #0xf000, the immediate Windows uses for a debug break +#define MACHINE IMAGE_FILE_MACHINE_ARM64 +#define MACHINE_NAME "arm64" +#define PC(context) ((context)->Pc) +static int is_breakpoint(insn_t insn) { return (insn & 0xffe0001fu) == 0xd4200000u; } // BRK with any immediate +#endif + +#define MAX_REGIONS 8 +#define STARTS_HEADER_WORDS 3 // u64 magic, version, count +#define TRACE_HEADER_WORDS 5 // u64 magic, version, slide, starts, count +#define STARTS_MAGIC UINT64_C(0x4e55425354525453) // "STRTSBUN" little-endian +#define TRACE_MAGIC UINT64_C(0x4e55424543415254) // "TRACEBUN" little-endian +#define FILE_VERSION UINT64_C(1) + +static struct { + uintptr_t start, end; +} regions[MAX_REGIONS]; +static int region_count; + +static HANDLE process; // the debuggee +static uintptr_t slide; // where the image landed, minus where it was linked to land +static uintptr_t *starts; // runtime addresses, sorted +static insn_t *originals; // instruction that was at starts[i] +static uint8_t *seen; +static size_t start_count; +static uint64_t *record; // header words, then one entry per function recorded so far + +static __declspec(noreturn) void die(const char *format, ...) +{ + va_list args; + va_start(args, format); + fputs("functrace: ", stderr); + vfprintf(stderr, format, args); + fputc('\n', stderr); + va_end(args); + // The debuggee dies with us: a debugger's exit kills its debuggees unless + // it asked otherwise, and a half-armed process is not worth keeping. + exit(2); +} + +static int region_of(uintptr_t a) +{ + for (int i = 0; i < region_count; i++) + if (a >= regions[i].start && a + sizeof(insn_t) <= regions[i].end) return i; + return -1; +} + +static size_t find_start(uintptr_t a) +{ + size_t lo = 0, hi = start_count; + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + if (starts[mid] < a) lo = mid + 1; + else hi = mid; + } + return (lo < start_count && starts[lo] == a) ? lo : SIZE_MAX; +} + +/** Writes into the debuggee's code, which is mapped read-execute. */ +static void write_code(uintptr_t at, const void *bytes, size_t n) +{ + DWORD protection = 0; + SIZE_T written = 0; + if (!VirtualProtectEx(process, (void *)at, n, PAGE_EXECUTE_READWRITE, &protection)) + die("cannot unprotect %zu bytes of code at %p (error %lu)", n, (void *)at, GetLastError()); + BOOL ok = WriteProcessMemory(process, (void *)at, bytes, n, &written); + DWORD error = GetLastError(); + VirtualProtectEx(process, (void *)at, n, protection, &protection); + if (!ok || written != n) die("cannot write %zu bytes of code at %p (error %lu)", n, (void *)at, error); + FlushInstructionCache(process, (void *)at, n); +} + +// ─── the image ────────────────────────────────────────────────────────────── + +/** + * The load slide and the executable sections, from the image file the creation + * event hands over. The file, not the mapping: the mapping's headers belong to + * the loader, and which of them it rewrites while relocating is its business. + */ +static void map_image(HANDLE file, uintptr_t base) +{ + // lld-link's headers are about 1 KB: DOS stub, PE header and a dozen sections. + static __declspec(align(16)) uint8_t header[4096]; + DWORD got = 0; + OVERLAPPED from_start; + memset(&from_start, 0, sizeof from_start); + if (!ReadFile(file, header, sizeof header, &got, &from_start) && + (GetLastError() != ERROR_IO_PENDING || !GetOverlappedResult(file, &from_start, &got, TRUE))) + die("cannot read the image file (error %lu)", GetLastError()); + + const IMAGE_DOS_HEADER *dos = (const IMAGE_DOS_HEADER *)header; + if (got < sizeof *dos || dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew < 0 || + (DWORD)dos->e_lfanew + sizeof(IMAGE_NT_HEADERS64) > got) + die("the command is not a 64-bit PE image"); + const IMAGE_NT_HEADERS64 *nt = (const IMAGE_NT_HEADERS64 *)(header + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) + die("the command is not a 64-bit PE image"); + if (nt->FileHeader.Machine != MACHINE) + die("the command is built for machine type 0x%x, but this tracer is " MACHINE_NAME + " — a debugger has to be the architecture of what it debugs", + (unsigned)nt->FileHeader.Machine); + + const IMAGE_SECTION_HEADER *sections = IMAGE_FIRST_SECTION(nt); + unsigned count = nt->FileHeader.NumberOfSections; + if ((const uint8_t *)(sections + count) > header + got) die("the image's section table does not fit in %zu bytes", sizeof header); + + slide = base - (uintptr_t)nt->OptionalHeader.ImageBase; + for (unsigned i = 0; i < count && region_count < MAX_REGIONS; i++) { + if (!(sections[i].Characteristics & IMAGE_SCN_MEM_EXECUTE)) continue; + regions[region_count].start = base + sections[i].VirtualAddress; + regions[region_count].end = regions[region_count].start + sections[i].Misc.VirtualSize; + region_count++; + } + if (!region_count) die("the image has no executable section"); +} + +static int cmp_uintptr(const void *a, const void *b) +{ + uintptr_t x = *(const uintptr_t *)a, y = *(const uintptr_t *)b; + return (x > y) - (x < y); +} + +static void read_starts(const wchar_t *path) +{ + FILE *f = _wfopen(path, L"rb"); + if (!f) die("cannot open %ls", path); + if (fseek(f, 0, SEEK_END) != 0) die("cannot read %ls", path); + long size = ftell(f); + rewind(f); + if (size < (long)(STARTS_HEADER_WORDS * 8)) die("%ls is not a starts file", path); + uint64_t *words = malloc((size_t)size); + if (!words || fread(words, 1, (size_t)size, f) != (size_t)size) die("cannot read %ls", path); + fclose(f); + + uint64_t n = words[2]; + if (words[0] != STARTS_MAGIC || words[1] != FILE_VERSION || n == 0 || n > (uint64_t)size / 8 - STARTS_HEADER_WORDS) + die("%ls is not a starts file", path); + starts = malloc((size_t)n * sizeof *starts); + if (!starts) die("out of memory"); + for (size_t i = 0; i < (size_t)n; i++) { + // Drop anything outside the image's own code — a symbol the linker + // dropped, say — and anything a breakpoint cannot be planted on. + uintptr_t a = (uintptr_t)words[STARTS_HEADER_WORDS + i] + slide; + if (region_of(a) < 0 || a % sizeof(insn_t) != 0) continue; + starts[start_count++] = a; + } + free(words); + if (!start_count) die("none of the %llu function starts in %ls fall inside the command's code — is it the binary they were read from?", (unsigned long long)n, path); + + qsort(starts, start_count, sizeof *starts, cmp_uintptr); + size_t unique = 0; + for (size_t i = 0; i < start_count; i++) + if (unique == 0 || starts[unique - 1] != starts[i]) starts[unique++] = starts[i]; + start_count = unique; +} + +/** Runs at the creation event: the image is mapped and none of it has executed. */ +static void arm(HANDLE image_file, uintptr_t image_base, const wchar_t *starts_path) +{ + map_image(image_file, image_base); + read_starts(starts_path); + + // Each executable section in one round trip: copy it out, plant every + // breakpoint in the copy, write it back. One protection change and one + // cache flush, instead of one of each per function. + uint8_t *code[MAX_REGIONS]; + for (int r = 0; r < region_count; r++) { + size_t n = regions[r].end - regions[r].start; + SIZE_T got = 0; + code[r] = malloc(n); + if (!code[r]) die("out of memory"); + if (!ReadProcessMemory(process, (const void *)regions[r].start, code[r], n, &got) || got != n) + die("cannot read the command's code (error %lu)", GetLastError()); + } + + // A function whose first instruction already is a breakpoint — JSC's LLInt + // puts one at labels that must never be reached — stays unarmed: restoring + // it would raise again at the same address, and the exception would be + // indistinguishable from ours. + size_t kept = 0; + for (size_t i = 0; i < start_count; i++) { + int r = region_of(starts[i]); + insn_t insn; + memcpy(&insn, code[r] + (starts[i] - regions[r].start), sizeof insn); + if (!is_breakpoint(insn)) starts[kept++] = starts[i]; + } + start_count = kept; + + originals = calloc(start_count, sizeof *originals); + seen = calloc(start_count, sizeof *seen); + record = calloc(TRACE_HEADER_WORDS + start_count, sizeof *record); + if (!originals || !seen || !record) die("out of memory"); + record[0] = TRACE_MAGIC; + record[1] = FILE_VERSION; + record[2] = slide; + record[3] = start_count; + + for (size_t i = 0; i < start_count; i++) { + int r = region_of(starts[i]); + uint8_t *p = code[r] + (starts[i] - regions[r].start); + memcpy(&originals[i], p, sizeof originals[i]); + const insn_t breakpoint = BREAKPOINT; + memcpy(p, &breakpoint, sizeof breakpoint); + } + for (int r = 0; r < region_count; r++) { + write_code(regions[r].start, code[r], regions[r].end - regions[r].start); + free(code[r]); + } +} + +// ─── breakpoints ──────────────────────────────────────────────────────────── + +/** + * INT3 is reported with the thread already past it; BRK with the thread still + * on it. Either way the thread is to re-execute the restored instruction, so + * point it there rather than knowing which. + */ +static void resume_at(DWORD thread_id, uintptr_t at) +{ + HANDLE thread = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, FALSE, thread_id); + if (!thread) die("cannot open thread %lu (error %lu)", thread_id, GetLastError()); + CONTEXT context; // CONTEXT declares its own 16-byte alignment + memset(&context, 0, sizeof context); + context.ContextFlags = CONTEXT_CONTROL; + if (!GetThreadContext(thread, &context)) die("cannot read thread %lu's registers (error %lu)", thread_id, GetLastError()); + PC(&context) = (DWORD64)at; + if (!SetThreadContext(thread, &context)) die("cannot resume thread %lu at %p (error %lu)", thread_id, (void *)at, GetLastError()); + CloseHandle(thread); +} + +/** Returns whether the exception was one of our breakpoints, now dealt with. */ +static int on_exception(const DEBUG_EVENT *event) +{ + const EXCEPTION_RECORD *exception = &event->u.Exception.ExceptionRecord; + if (exception->ExceptionCode != EXCEPTION_BREAKPOINT) return 0; + uintptr_t at = (uintptr_t)exception->ExceptionAddress; + size_t i = find_start(at); + if (i == SIZE_MAX) return 0; + + // Another thread may have executed the breakpoint before the first one's + // restore landed; it arrives here too, and only needs pointing back. + if (!seen[i]) { + seen[i] = 1; + write_code(at, &originals[i], sizeof originals[i]); + record[TRACE_HEADER_WORDS + record[4]] = at - slide; + record[4]++; + } + resume_at(event->dwThreadId, at); + return 1; +} + +static void write_record(const wchar_t *path) +{ + FILE *f = _wfopen(path, L"wb"); + if (!f) die("cannot create %ls", path); + size_t words = TRACE_HEADER_WORDS + (size_t)record[4]; + if (fwrite(record, sizeof *record, words, f) != words || fclose(f) != 0) die("cannot write %ls", path); +} + +// ─── pseudo console ───────────────────────────────────────────────────────── + +static HANDLE our_stdin, our_stdout; // what gets typed into the console, and where its screen output goes +static HANDLE console_input; // our end of the console's input: bytes written here arrive as keystrokes +static HANDLE console_output; // our end of its output: everything the debuggee writes to the screen + +static DWORD WINAPI type_stdin(LPVOID unused) +{ + (void)unused; + char buffer[4096]; + DWORD n = 0, written = 0; + while (ReadFile(our_stdin, buffer, sizeof buffer, &n, NULL) && n > 0) { + // The input has the pipe workload's line feeds; a terminal's Enter key is a carriage return. + for (DWORD i = 0; i < n; i++) + if (buffer[i] == '\n') buffer[i] = '\r'; + if (!WriteFile(console_input, buffer, n, &written, NULL)) break; + } + return 0; +} + +static volatile ULONGLONG last_output_at; // GetTickCount64() when the console last produced output + +static DWORD WINAPI forward_output(LPVOID unused) +{ + (void)unused; + char buffer[8192]; + DWORD n = 0, written = 0; + // Ends with a broken pipe once the console is closed. Reading continuously + // also matters while the debuggee runs: a console whose output nobody drains + // eventually blocks the process writing to it. + while (ReadFile(console_output, buffer, sizeof buffer, &n, NULL) && n > 0) { + last_output_at = GetTickCount64(); + if (our_stdout && !WriteFile(our_stdout, buffer, n, &written, NULL)) our_stdout = NULL; + } + return 0; +} + +/** + * The console renders what the debuggee wrote on its own schedule, and closing + * it discards whatever it has not rendered yet — on Windows Server 2019 that is + * routinely the debuggee's last lines, written just before it exited. Nothing + * announces that it has caught up, so give it a moment after the exit, and + * longer for as long as output keeps arriving. + */ +static void close_console(HPCON console, HANDLE output_thread) +{ + const ULONGLONG quiet_ms = 250, at_most_ms = 3000; + ULONGLONG exited_at = GetTickCount64(); + for (;;) { + ULONGLONG now = GetTickCount64(), latest = last_output_at > exited_at ? last_output_at : exited_at; + if (now - latest >= quiet_ms || now - exited_at >= at_most_ms) break; + Sleep(25); + } + ClosePseudoConsole(console); // ends the output, which is what lets the forwarder finish + WaitForSingleObject(output_thread, 5000); +} + +/** Creates the console the debuggee is to be started on, as a process attribute for CreateProcess. */ +static HPCON create_console(LPPROC_THREAD_ATTRIBUTE_LIST *attributes) +{ + HANDLE input_read, output_write; + if (!CreatePipe(&input_read, &console_input, NULL, 0) || !CreatePipe(&console_output, &output_write, NULL, 0)) + die("cannot create the console's pipes (error %lu)", GetLastError()); + COORD size = { 80, 24 }; // ptyrun.c's window + HPCON console = NULL; + HRESULT result = CreatePseudoConsole(size, input_read, output_write, 0, &console); + if (FAILED(result)) die("cannot create a pseudo console (0x%08lx)", (unsigned long)result); + // The console holds its own references to its ends of the pipes. + CloseHandle(input_read); + CloseHandle(output_write); + + SIZE_T bytes = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &bytes); + *attributes = malloc(bytes); + if (!*attributes || !InitializeProcThreadAttributeList(*attributes, 1, 0, &bytes) || + !UpdateProcThreadAttribute(*attributes, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, console, sizeof console, NULL, NULL)) + die("cannot attach the pseudo console to the command (error %lu)", GetLastError()); + + // A new process is handed its parent's standard handle numbers, and is only + // given handles to its console in the slots that are empty. Ours are pipes + // to whoever ran us, meaningless in the debuggee, so it would start with + // those numbers as its stdio rather than the console. The threads above + // keep the handles themselves; our own stderr is the CRT's, and unaffected. + our_stdin = GetStdHandle(STD_INPUT_HANDLE); + our_stdout = GetStdHandle(STD_OUTPUT_HANDLE); + if (our_stdout == INVALID_HANDLE_VALUE) our_stdout = NULL; + SetStdHandle(STD_INPUT_HANDLE, NULL); + SetStdHandle(STD_OUTPUT_HANDLE, NULL); + SetStdHandle(STD_ERROR_HANDLE, NULL); + return console; +} + +// ─── the debuggee ─────────────────────────────────────────────────────────── + +/** Appends one argument the way the CRT's argv parsing (CommandLineToArgvW) undoes. */ +static wchar_t *append_argument(wchar_t *out, const wchar_t *arg) +{ + if (*arg && !wcspbrk(arg, L" \t\"")) { + size_t n = wcslen(arg); + memcpy(out, arg, n * sizeof *arg); + return out + n; + } + *out++ = L'"'; + for (;;) { + size_t backslashes = 0; + while (*arg == L'\\') { + backslashes++; + arg++; + } + // Backslashes only escape when they precede a quote — including the + // closing one we are about to add — so double them just there. + size_t emit = *arg == L'\0' || *arg == L'"' ? backslashes * 2 : backslashes; + for (size_t i = 0; i < emit; i++) *out++ = L'\\'; + if (*arg == L'\0') break; + if (*arg == L'"') *out++ = L'\\'; + *out++ = *arg++; + } + *out++ = L'"'; + return out; +} + +static wchar_t *command_line(int argc, wchar_t **argv) +{ + size_t bound = 1; + for (int i = 0; i < argc; i++) bound += wcslen(argv[i]) * 2 + 3; // every character escaped, quoted, and a separator + wchar_t *line = malloc(bound * sizeof *line), *out = line; + if (!line) die("out of memory"); + for (int i = 0; i < argc; i++) { + if (i) *out++ = L' '; + out = append_argument(out, argv[i]); + } + *out = L'\0'; + return line; +} + +/** A copy of a variable's value, with the variable itself removed from what the debuggee will inherit. */ +static wchar_t *take_variable(const wchar_t *name) +{ + const wchar_t *value = _wgetenv(name); + wchar_t *copy = value && *value ? _wcsdup(value) : NULL; + SetEnvironmentVariableW(name, NULL); + return copy; +} + +int wmain(int argc, wchar_t **argv) +{ + if (argc < 2) { + fputs("usage: BUN_FUNCTRACE_STARTS= BUN_FUNCTRACE_OUT= functrace-windows [args...]\n", stderr); + return 2; + } + wchar_t *starts_path = take_variable(L"BUN_FUNCTRACE_STARTS"); + wchar_t *out_path = take_variable(L"BUN_FUNCTRACE_OUT"); + wchar_t *tty = take_variable(L"BUN_FUNCTRACE_TTY"); + if (!starts_path || !out_path) die("BUN_FUNCTRACE_STARTS and BUN_FUNCTRACE_OUT must both be set"); + // Being debugged switches a process's heaps into their checked mode, which + // is slow and not what a release binary does when it runs for real. + SetEnvironmentVariableW(L"_NO_DEBUG_HEAP", L"1"); + + STARTUPINFOEXW startup; + memset(&startup, 0, sizeof startup); + startup.StartupInfo.cb = sizeof startup.StartupInfo; + DWORD flags = DEBUG_ONLY_THIS_PROCESS; + HPCON console = NULL; + if (tty) { + console = create_console(&startup.lpAttributeList); + startup.StartupInfo.cb = sizeof startup; + flags |= EXTENDED_STARTUPINFO_PRESENT; + } else { + // The debuggee shares our stdio, the same as it would have had it been + // started directly. + HANDLE *handles[] = { &startup.StartupInfo.hStdInput, &startup.StartupInfo.hStdOutput, &startup.StartupInfo.hStdError }; + DWORD ids[] = { STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE }; + for (int i = 0; i < 3; i++) { + *handles[i] = GetStdHandle(ids[i]); + SetHandleInformation(*handles[i], HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + } + + PROCESS_INFORMATION info; + wchar_t *line = command_line(argc - 1, argv + 1); + if (!CreateProcessW(argv[1], line, NULL, NULL, !tty, flags, NULL, NULL, &startup.StartupInfo, &info)) + die("cannot start %ls (error %lu)", argv[1], GetLastError()); + process = info.hProcess; + CloseHandle(info.hThread); + + HANDLE output_thread = NULL; + if (tty) { + output_thread = CreateThread(NULL, 0, forward_output, NULL, 0, NULL); + HANDLE input_thread = CreateThread(NULL, 0, type_stdin, NULL, 0, NULL); + if (!output_thread || !input_thread) die("cannot start the console threads (error %lu)", GetLastError()); + CloseHandle(input_thread); + } + + DWORD exit_code = 0; + for (int exited = 0; !exited;) { + DEBUG_EVENT event; + if (!WaitForDebugEvent(&event, INFINITE)) die("lost the debuggee (error %lu)", GetLastError()); + DWORD disposition = DBG_CONTINUE; + switch (event.dwDebugEventCode) { + case CREATE_PROCESS_DEBUG_EVENT: + if (!event.u.CreateProcessInfo.hFile) die("the creation event came without the image file"); + arm(event.u.CreateProcessInfo.hFile, (uintptr_t)event.u.CreateProcessInfo.lpBaseOfImage, starts_path); + CloseHandle(event.u.CreateProcessInfo.hFile); + break; + case LOAD_DLL_DEBUG_EVENT: + if (event.u.LoadDll.hFile) CloseHandle(event.u.LoadDll.hFile); + break; + case EXCEPTION_DEBUG_EVENT: + // Every exception that is not one of our breakpoints — the loader's + // own initial breakpoint, whatever the program raises and handles + // itself, a real crash — goes on to the program's handlers and plays + // out as it would untraced. + if (!on_exception(&event)) disposition = DBG_EXCEPTION_NOT_HANDLED; + break; + case EXIT_PROCESS_DEBUG_EVENT: + exit_code = event.u.ExitProcess.dwExitCode; + exited = 1; + break; + } + ContinueDebugEvent(event.dwProcessId, event.dwThreadId, disposition); + } + CloseHandle(process); + + if (console) close_console(console, output_thread); + write_record(out_path); + return (int)exit_code; +} diff --git a/scripts/orderfile/generate.ts b/scripts/orderfile/generate.ts index 8890a7f06598..9b3267fc83c6 100644 --- a/scripts/orderfile/generate.ts +++ b/scripts/orderfile/generate.ts @@ -9,22 +9,26 @@ * cuts the resident binary pages roughly in half with no change to the binary's * size and no change to what the code does. * - * How: `functrace.c` is an injected-library shim that plants a breakpoint (INT3 - * on x86-64, BRK on arm64) at every function's first instruction and restores - * it the first time it fires, so it records exactly the functions a run enters. - * We run a handful of representative workloads, map every recorded address back - * to its linker-visible name (`nm` on the unstripped binary), and emit those - * names in first-entry order. Symbols the linker cannot find are ignored, so - * the file degrades gracefully as code moves. + * How: a tracer plants a breakpoint (INT3 on x86-64, BRK on arm64) at every + * function's first instruction and restores it the first time it fires, so it + * records exactly the functions a run enters. On linux and macOS that is + * `functrace.c`, a library injected into the traced process; on Windows it is + * `functrace-windows.c`, which runs the process as its debuggee and does the + * same from outside. We run a handful of representative workloads, map every + * recorded address back to its linker-visible name (`readTextSymbols`: nm on + * the unstripped binary, or on Windows the maps the link wrote beside it — see + * windows-symbols.ts), and emit those names in first-entry order. Symbols the + * linker cannot find are ignored, so the file degrades gracefully as code moves. * * This replaced an earlier page-fault tracer. A page trace lists every function * that shares a page with a hot one, so ~5k real entries turned into ~38k * names, most of which never ran; the extra names still sort to the front and * dilute the hot set. Recording exact entries lists only what ran. * - * One workload runs under `ptyrun.c`, on a pseudo-terminal: bun's stdio, tty - * and readline code is a different path on a terminal than on a pipe, and the - * functions it reaches are a couple of thousand that no other workload touches. + * One workload runs on a pseudo-terminal (`ptyrun.c`; a pseudo console on + * Windows): bun's stdio, tty and readline code is a different path on a + * terminal than on a pipe, and the functions it reaches are a couple of + * thousand that no other workload touches. * * The file is never committed. Release builds generate it from their own pass-1 * binary and relink against it; canary builds inherit the last successful @@ -38,16 +42,18 @@ * linked with a file generated from the plain release build lands at 22.6 MB, * and at 21.6 MB with its own. * - * Linux x86-64/arm64 and macOS arm64. Linux is the lld `--symbol-ordering-file` - * input; macOS is Apple ld's `-order_file`. + * Linux x86-64/arm64, macOS arm64, and Windows x64/arm64. Linux is the lld + * `--symbol-ordering-file` input, macOS Apple ld's `-order_file`, Windows + * lld-link's `/order:@`; all three take one symbol name per line. */ import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { readWindowsTextSymbols } from "./windows-symbols.ts"; -const STARTS_HEADER_WORDS = 3; // must match functrace.c: magic, version, count +const STARTS_HEADER_WORDS = 3; // must match functrace.c and functrace-windows.c: magic, version, count const TRACE_HEADER_WORDS = 5; // magic, version, slide, starts, count const STARTS_MAGIC = 0x4e55425354525453n; // "STRTSBUN" const TRACE_MAGIC = 0x4e55424543415254n; // "TRACEBUN" @@ -116,7 +122,10 @@ export function runCommand(cmd: string[], options: RunOptions = {}) { export interface GenerateOptions { /** Build directory holding the unstripped binary. */ buildDir: string; - /** Unstripped binary to trace. Defaults to `bun-profile`; an assertions build names it differently. */ + /** + * Unstripped binary to trace, without the `.exe` Windows adds. Defaults to + * `bun-profile`; an assertions build names it differently. + */ exeName?: string; /** Where to write the order file. Defaults to `/linker.order`. */ outPath?: string; @@ -127,33 +136,55 @@ export interface GenerateOptions { } /** - * Linker-visible function names, by address. Multiple names can share one - * address (aliases, and ICF on darwin), and the order file must list every name - * the linker might know a function by. On macOS nm prints names with the C - * leading underscore, which is also what `-order_file` expects, so no - * stripping — lld and ld take exactly what nm gave. + * Linker-visible function names by link-time address, for every function in + * the binary. Multiple names can share one address (aliases, and functions the + * linker folded together), and the order file must list every name the linker + * might know a function by, so nothing is collapsed here. Names are taken + * exactly as the tool prints them: on macOS that includes the C leading + * underscore, which is also what `-order_file` expects. + * + * ELF and Mach-O binaries carry their symbol table, so nm reads it off the + * binary. A PE does not, so a Windows binary is read through the maps the link + * wrote next to it (windows-symbols.ts). */ -function readSymbolTable(bunProfile: string): Map { - // Bare `nm` with no GNU-only long options: the regex below is the - // defined-text-symbol filter, and nothing here depends on output order. - const nm = process.env.NM || "nm"; - const r = runCommand([nm, bunProfile]); - if (r.status !== 0) throw new Error(`${nm} failed on ${bunProfile}\n${r.stderr}`); +export function readTextSymbols(exe: string): Map { + if (exe.toLowerCase().endsWith(".exe")) return readWindowsTextSymbols(exe); const symbols = new Map(); - for (const line of r.stdout.toString().split("\n")) { - const m = /^([0-9a-f]+) ([tT]) (\S+)$/.exec(line); + // $NM, else whichever nm is installed. Bare, with no GNU-only long options: + // the regex is the defined-text-symbol filter, and nothing depends on order. + const tools = [process.env.NM, "llvm-nm", "nm"].filter((tool): tool is string => !!tool); + const failures: string[] = []; + let listing: string | undefined; + for (const nm of tools) { + let r: ReturnType; + try { + r = runCommand([nm, exe]); + } catch (error) { + failures.push((error as Error).message); // not installed + continue; + } + if (r.status === 0) { + listing = r.stdout.toString(); + break; + } + failures.push(`${nm} exited ${r.status}: ${r.stderr.toString().trim()}`); + } + if (listing === undefined) throw new Error(`cannot list ${exe}'s symbols:\n${failures.join("\n")}`); + + for (const line of listing.split("\n")) { + const m = /^([0-9a-f]+) [tT] (\S+)$/.exec(line); if (!m) continue; const address = parseInt(m[1]!, 16); const names = symbols.get(address); - if (names) names.push(m[3]!); - else symbols.set(address, [m[3]!]); + if (names) names.push(m[2]!); + else symbols.set(address, [m[2]!]); } - if (symbols.size === 0) throw new Error(`${nm} reported no text symbols — is ${bunProfile} stripped?`); + if (symbols.size === 0) throw new Error(`nm listed no text symbols — is ${exe} stripped?`); return symbols; } -/** Write function starts for functrace.c: u64 magic, version, count, addresses. */ +/** Write function starts for the tracer: u64 magic, version, count, addresses. */ function writeStarts(path: string, addresses: number[]): void { const buffer = new ArrayBuffer((STARTS_HEADER_WORDS + addresses.length) * 8); const words = new BigUint64Array(buffer); @@ -164,7 +195,7 @@ function writeStarts(path: string, addresses: number[]): void { writeFileSync(path, new Uint8Array(buffer)); } -/** Read a trace functrace.c wrote: first-entry addresses, slide already removed. */ +/** Read a trace the tracer wrote: first-entry addresses, slide already removed. */ function readTrace(path: string, name: string): number[] { const raw = readFileSync(path); if (raw.byteLength < TRACE_HEADER_WORDS * 8) throw new Error(`workload "${name}" wrote a truncated trace`); @@ -178,40 +209,104 @@ function readTrace(path: string, name: string): number[] { return out; } +/** A built tracer: how to run one workload under it. */ +interface Tracer { + /** The linker option the resulting file is for, named in its header. */ + linker: string; + /** The command that runs `exe` with the workload's arguments under trace, and the environment that arms it. */ + launch(workload: Workload, exe: string): { cmd: string[]; env: Record }; +} + +/** + * functrace.c rides into the traced process on the loader's preload variable. + * The terminal workload runs under ptyrun.c, which is then the traced process's + * parent, so it is handed the preload to pass down rather than loading it itself. + */ +function buildUnixTracer(scratch: string): Tracer { + const darwin = process.platform === "darwin"; + const tracer = join(scratch, darwin ? "functrace.dylib" : "functrace.so"); + const ptyrun = join(scratch, "ptyrun"); + const cc = process.env.CC || "cc"; + const build = runCommand( + darwin + ? [cc, "-O2", "-dynamiclib", "-fPIC", "-o", tracer, join(here, "functrace.c")] + : [cc, "-O2", "-shared", "-fPIC", "-o", tracer, join(here, "functrace.c"), "-ldl", "-lpthread"], + ); + if (build.status !== 0) throw new Error(`failed to build the tracer with ${cc}\n${build.stderr}`); + const pty = runCommand([cc, "-O2", "-o", ptyrun, join(here, "ptyrun.c"), ...(darwin ? [] : ["-lutil"])]); + if (pty.status !== 0) throw new Error(`failed to build the pty runner with ${cc}\n${pty.stderr}`); + + const preloadVar = darwin ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD"; + return { + linker: darwin ? "ld -order_file" : "lld --symbol-ordering-file", + launch: (workload, exe) => + workload.tty + ? { cmd: [ptyrun, exe, ...workload.args], env: { PTYRUN_PRELOAD: tracer } } + : { cmd: [exe, ...workload.args], env: { [preloadVar]: tracer } }, + }; +} + +/** + * functrace-windows.c is a debugger, so it runs the workload itself, and puts + * it on a pseudo console when asked to. Built with clang-cl when there is one + * (LLVM is on every bun dev machine and CI image), else with cl, which needs a + * Visual Studio developer shell; $CC names one explicitly. + */ +function buildWindowsTracer(scratch: string): Tracer { + const tracer = join(scratch, "functrace.exe"); + const failures: string[] = []; + for (const cc of process.env.CC ? [process.env.CC] : ["clang-cl", "cl"]) { + // clang-cl links with whatever `link` is first on PATH, which on a machine + // with git is as likely to be coreutils' as MSVC's; lld-link ships beside it. + const linker = /clang-cl/i.test(basename(cc)) ? ["-fuse-ld=lld"] : []; + let build: ReturnType; + try { + build = runCommand([cc, "/nologo", "/O2", ...linker, join(here, "functrace-windows.c"), `/Fe:${tracer}`], { + cwd: scratch, // cl drops the .obj in the working directory + }); + } catch (error) { + failures.push((error as Error).message); // not installed + continue; + } + if (build.status === 0) break; + failures.push(`${cc} exited ${build.status}:\n${build.stdout}${build.stderr}`); // cl reports errors on stdout + } + if (!existsSync(tracer)) { + throw new Error(`failed to build the tracer — needs clang-cl, or cl in a developer shell:\n${failures.join("\n")}`); + } + return { + linker: "lld-link /order", + launch: (workload, exe) => { + const env: Record = {}; + if (workload.tty) env.BUN_FUNCTRACE_TTY = "1"; + return { cmd: [tracer, exe, ...workload.args], env }; + }, + }; +} + export function generateOrderFile(options: GenerateOptions): { count: number; outPath: string } { const buildDir = resolve(options.buildDir); const outPath = resolve(options.outPath ?? join(buildDir, "linker.order")); const minFunctions = options.minFunctions ?? MIN_FUNCTIONS; const log = (message: string) => options.verbose && console.log(message); - const darwin = process.platform === "darwin"; - if (process.platform !== "linux" && !(darwin && process.arch === "arm64")) { - throw new Error("the order file tracer builds on linux x86-64/arm64 or macOS arm64"); + const windows = process.platform === "win32"; + if (process.platform !== "linux" && !windows && !(process.platform === "darwin" && process.arch === "arm64")) { + throw new Error("the order file tracer builds on linux x86-64/arm64, macOS arm64, or Windows x64/arm64"); } - // The unstripped binary: its symbol table is what maps addresses back to names. - const bunProfile = join(buildDir, options.exeName ?? "bun-profile"); + // The unstripped binary: its symbols are what map addresses back to names. + const bunProfile = join(buildDir, (options.exeName ?? "bun-profile") + (windows ? ".exe" : "")); if (!existsSync(bunProfile)) { throw new Error(`${bunProfile} not found — build it first (bun run build:release)`); } const scratch = mkdtempSync(join(tmpdir(), "bun-orderfile-")); try { - // ── Build the tracer and the pty runner ─────────────────────────────────── - const tracer = join(scratch, darwin ? "functrace.dylib" : "functrace.so"); - const ptyrun = join(scratch, "ptyrun"); - const cc = process.env.CC || "cc"; - const build = runCommand( - darwin - ? [cc, "-O2", "-dynamiclib", "-fPIC", "-o", tracer, join(here, "functrace.c")] - : [cc, "-O2", "-shared", "-fPIC", "-o", tracer, join(here, "functrace.c"), "-ldl", "-lpthread"], - ); - if (build.status !== 0) throw new Error(`failed to build the tracer with ${cc}\n${build.stderr}`); - const pty = runCommand([cc, "-O2", "-o", ptyrun, join(here, "ptyrun.c"), ...(darwin ? [] : ["-lutil"])]); - if (pty.status !== 0) throw new Error(`failed to build the pty runner with ${cc}\n${pty.stderr}`); + const tracer = windows ? buildWindowsTracer(scratch) : buildUnixTracer(scratch); // ── Symbol table and function starts ────────────────────────────────────── - const symbols = readSymbolTable(bunProfile); + const symbols = readTextSymbols(bunProfile); const startsPath = join(scratch, "starts.bin"); writeStarts( startsPath, @@ -287,13 +382,10 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou const seen = new Set(); for (const [i, workload] of workloads.entries()) { const out = join(scratch, `trace-${i}.bin`); - // The tracer loads into the traced process and nowhere else. On a terminal - // ptyrun is the parent, so it is the one that hands the preload down. - const preloadVar = darwin ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD"; - const preload = workload.tty ? { PTYRUN_PRELOAD: tracer } : { [preloadVar]: tracer }; - const r = runCommand(workload.tty ? [ptyrun, bunProfile, ...workload.args] : [bunProfile, ...workload.args], { + const { cmd, env } = tracer.launch(workload, bunProfile); + const r = runCommand(cmd, { env: { - ...preload, + ...env, BUN_FUNCTRACE_STARTS: startsPath, BUN_FUNCTRACE_OUT: out, BUN_DEBUG_QUIET_LOGS: "1", @@ -332,7 +424,7 @@ export function generateOrderFile(options: GenerateOptions): { count: number; ou } const header = [ - `# ${darwin ? "ld -order_file" : "lld --symbol-ordering-file"}: functions bun executes while starting up,`, + `# ${tracer.linker}: functions bun executes while starting up,`, "# in first-entry order, so they land together at the front of .text.", "# Generated by scripts/orderfile/generate.ts — not committed.", `# ${order.length} functions from ${workloads.length} workloads.`, diff --git a/scripts/orderfile/windows-symbols.ts b/scripts/orderfile/windows-symbols.ts new file mode 100644 index 000000000000..874de7f1197a --- /dev/null +++ b/scripts/orderfile/windows-symbols.ts @@ -0,0 +1,145 @@ +/** + * Function addresses and names for a Windows binary, for generate.ts. + * + * A PE carries no symbol table — lld-link puts the names in the PDB — so the + * release link writes two maps next to the binary instead (scripts/build/ + * flags.ts), and they ship in the profile zip with it: + * + * bun-profile.map lld-link's MSVC-style `/map`: every symbol, by + * address, under the name `/order` knows it by + * bun-profile.linker-map lld's own `/lldmap`: where every input chunk — + * each function's section — was placed + * + * The first has the names; the second says which of them are functions. The + * symbol listing cannot tell: it has every label in `.text`, and the MSVC CRT + * defines labels on things that are not functions and must not have a + * breakpoint written over them — arm64 `memset` keeps the byte table its + * computed branch indexes in `.text` under a symbol named `Table`, and the + * MSVC-compiled CRT leaves a `$LN123` label on every slot of the jump tables + * it emits after a function's code. A breakpoint there is a corrupted table, + * and printf branches into the weeds. Every such label sits inside the chunk + * of the function it belongs to, while a function starts a chunk of its own: + * `/Gy` and function sections give each one its own COMDAT, which is also the + * only thing `/order` can move. So the names kept are the ones at chunk + * starts; what that drops was never orderable anyway. + */ +import { existsSync, readFileSync } from "node:fs"; +import { basename } from "node:path"; + +/** `bun-profile.map` for `bun-profile.exe`: the symbol listing. */ +export function symbolMapFor(exe: string): string { + return exe.replace(/\.exe$/i, "") + ".map"; +} + +/** `bun-profile.linker-map` for `bun-profile.exe`: lld's own map, of chunks. */ +export function linkerMapFor(exe: string): string { + return exe.replace(/\.exe$/i, "") + ".linker-map"; +} + +/** + * Names by link-time address for every function in the binary — every name at + * the start of a chunk in a code section. Several names can share an address + * (aliases, and functions the linker folded together), and all are kept: the + * order file has to list whichever of them the linker knows the function by. + */ +export function readWindowsTextSymbols(exe: string): Map { + const symbolMap = symbolMapFor(exe); + const linkerMap = linkerMapFor(exe); + for (const map of [symbolMap, linkerMap]) { + if (!existsSync(map)) { + throw new Error( + `${map} not found — the release link writes it (the /map and /lldmap flags in scripts/build/flags.ts), ` + + `and it ships beside ${basename(exe)}`, + ); + } + } + + const chunkStarts = parseChunkStarts(readFileSync(linkerMap, "utf8")); + if (chunkStarts.size === 0) throw new Error(`${linkerMap} lists no chunks — is it lld's map?`); + const listing = parseSymbolMap(readFileSync(symbolMap, "utf8")); + if (listing.symbols.length === 0) throw new Error(`${symbolMap} lists no code symbols — is it lld-link's map?`); + + const functions = new Map(); + for (const [address, name] of listing.symbols) { + if (!chunkStarts.has(address - listing.imageBase)) continue; + const names = functions.get(address); + if (names) names.push(name); + else functions.set(address, [name]); + } + if (functions.size === 0) { + throw new Error(`none of ${symbolMap}'s symbols start a chunk of ${linkerMap} — are they from the same link?`); + } + return functions; +} + +export interface SymbolListing { + /** What the addresses are relative to; lld's map counts from here. */ + imageBase: number; + /** Every symbol in a code section, in the listing's order: publics, then statics. */ + symbols: [address: number, name: string][]; +} + +/** + * The MSVC-style map. A header names the image base, a section table gives + * each output section's class, then "Publics by Value" and "Static symbols" + * each list one symbol per line with its `section:offset`, name and address: + * + * Preferred load address is 0000000140000000 + * + * Start Length Name Class + * 0001:00000000 02f1a2c0H .text CODE + * 0002:00000000 00a3b120H .rdata DATA + * + * 0001:000004c0 ?main@@YAHHPEAPEAD@Z 00000001400014c0 bun.obj + * + * Only the code sections' symbols are of interest: section 0000 holds absolute + * symbols, and the data sections nothing a breakpoint belongs on. Names can + * exceed their column, which is why the address is matched after whitespace + * rather than at a fixed offset. The addresses include the image base. + */ +export function parseSymbolMap(map: string): SymbolListing { + let imageBase: number | undefined; + const codeSections = new Set(); + const symbols: SymbolListing["symbols"] = []; + for (const line of map.split("\n")) { + const base = /^ Preferred load address is ([0-9a-f]+)/.exec(line); + if (base) { + imageBase = parseInt(base[1]!, 16); + continue; + } + const section = /^ ([0-9a-f]{4}):[0-9a-f]{8} [0-9a-f]{8}H \S+\s+CODE\s*$/.exec(line); + if (section) { + codeSections.add(section[1]!); + continue; + } + const symbol = /^ ([0-9a-f]{4}):[0-9a-f]{8}\s+(\S+)\s+([0-9a-f]{16})\s/.exec(line); + if (symbol && codeSections.has(symbol[1]!)) symbols.push([parseInt(symbol[3]!, 16), symbol[2]!]); + } + if (imageBase === undefined) throw new Error("the symbol listing has no image base — is it lld-link's /map output?"); + return { imageBase, symbols }; +} + +/** + * lld's own map: the address, size and alignment of each output section, of + * each input chunk placed in it (`object:(section)`), and of each symbol in + * the chunk, which it lists with size and alignment 0 and under a demangled + * name — so the names are taken from the other map, and only the chunks from + * this one: + * + * Address Size Align Out In Symbol + * 00001000 02f1a2c0 4096 .text + * 00001000 00000034 16 bun.obj:(.text$mn) + * 00001000 00000000 0 int __cdecl main(int, char **) + * + * Returns the address of every chunk, relative to the image base like all of + * this map's addresses. Chunks of every section are included; a data chunk + * cannot share an address with a code symbol, so there is nothing to filter. + */ +export function parseChunkStarts(map: string): Set { + const starts = new Set(); + for (const line of map.split("\n")) { + const m = /^([0-9a-f]+) [0-9a-f]+ +(\d+) +(.*)$/.exec(line); + if (m && m[2] !== "0" && m[3]!.includes(":(")) starts.add(parseInt(m[1]!, 16)); + } + return starts; +} diff --git a/test/js/bun/perf/functrace-fixture.c b/test/js/bun/perf/functrace-fixture.c index 070abc58515a..710a15a5fea8 100644 --- a/test/js/bun/perf/functrace-fixture.c +++ b/test/js/bun/perf/functrace-fixture.c @@ -1,33 +1,58 @@ -// Fixture for the scripts/orderfile/functrace.c regression test. +// Fixture for the tracer tests in linker-order.test.ts — functrace.c on linux +// and macOS, functrace-windows.c on Windows. // -// Calls TOUCHED functions, execs the program named by argv[1] (which inherits -// the injected library), then calls one more. Under the tracer each call is a -// recorded first entry, so a child that create-and-truncates the trace file -// shows up as a collapsed count. +// Calls TOUCHED functions, runs the program named by argv[1] as a child (on +// linux and macOS it inherits the injected library), then calls one more. Under +// the tracer each call is a recorded first entry, so a child that +// create-and-truncates the trace file shows up as a collapsed count. // // noinline + a data dependency through the return value so the optimizer // cannot fold the calls away. // // cc -O2 -o functrace-fixture functrace-fixture.c +// clang-cl /O2 -fuse-ld=lld functrace-fixture.c #include +#if defined(_WIN32) +#include +#define NOINLINE __declspec(noinline) +#else #include #include +#define NOINLINE __attribute__((noinline)) +#endif #define TOUCHED 32 #define F(n) \ - __attribute__((noinline)) static unsigned long long f##n(unsigned long long x) { return x + n; } + NOINLINE static unsigned long long f##n(unsigned long long x) { return x + n; } F(0) F(1) F(2) F(3) F(4) F(5) F(6) F(7) F(8) F(9) F(10) F(11) F(12) F(13) F(14) F(15) F(16) F(17) F(18) F(19) F(20) F(21) F(22) F(23) F(24) F(25) F(26) F(27) F(28) F(29) F(30) F(31) -__attribute__((noinline)) static unsigned long long after(unsigned long long x) { return x + 1; } +NOINLINE static unsigned long long after(unsigned long long x) { return x + 1; } static unsigned long long (*const fns[TOUCHED])(unsigned long long) = { f0, f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12, f13, f14, f15, f16, f17, f18, f19, f20, f21, f22, f23, f24, f25, f26, f27, f28, f29, f30, f31, }; +/** Runs `program` to completion and returns its exit status. */ +static int run_child(const char *program) +{ +#if defined(_WIN32) + return (int)_spawnl(_P_WAIT, program, program, (const char *)NULL); +#else + pid_t child = fork(); + if (child < 0) return -1; + if (child == 0) { + execl(program, program, (char *)NULL); + _exit(127); + } + int status = 0; + return waitpid(child, &status, 0) == child ? status : -1; +#endif +} + int main(int argc, char **argv) { if (argc < 2) return 2; @@ -35,14 +60,7 @@ int main(int argc, char **argv) unsigned long long sum = 0; for (int i = 0; i < TOUCHED; i++) sum = fns[i](sum); - pid_t child = fork(); - if (child < 0) return 3; - if (child == 0) { - execl(argv[1], argv[1], (char *)NULL); - _exit(127); - } - int status = 0; - if (waitpid(child, &status, 0) != child || status != 0) return 4; + if (run_child(argv[1]) != 0) return 4; sum = after(sum); printf("%llu\n", sum); diff --git a/test/js/bun/perf/linker-order.test.ts b/test/js/bun/perf/linker-order.test.ts index 0632b8f84f31..39b270d74dad 100644 --- a/test/js/bun/perf/linker-order.test.ts +++ b/test/js/bun/perf/linker-order.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isMusl, nodeExe, tempDir } from "harness"; +import { bunEnv, bunExe, isMusl, isWindows, nodeExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { mustGenerateOrderFile, @@ -8,18 +9,34 @@ import { type OrderFileContext, } from "../../../../scripts/build/ci.ts"; import type { Config } from "../../../../scripts/build/config.ts"; -import { linkDepends, linkerFlags, orderFilePath, usesOrderFile } from "../../../../scripts/build/flags.ts"; -import { generateOrderFile } from "../../../../scripts/orderfile/generate.ts"; +import { + linkDepends, + linkerFlags, + linkerMapOutputs, + linkerMapPath, + orderFilePath, + symbolMapPath, + usesOrderFile, + writesLinkerMap, +} from "../../../../scripts/build/flags.ts"; +import { slash } from "../../../../scripts/build/shell.ts"; +import { generateOrderFile, readTextSymbols } from "../../../../scripts/orderfile/generate.ts"; +import { + linkerMapFor, + parseChunkStarts, + parseSymbolMap, + symbolMapFor, +} from "../../../../scripts/orderfile/windows-symbols.ts"; /** * `/linker.order` lists the functions bun executes while starting up * so they land together at the front of `.text`, which is worth ~12 MB of * resident binary pages on a `bun -e 'console.log(1)'`: lld - * `--symbol-ordering-file` on linux, Apple ld `-order_file` on macOS (see - * scripts/orderfile/generate.ts). + * `--symbol-ordering-file` on linux, Apple ld `-order_file` on macOS, lld-link + * `/order` on windows (see scripts/orderfile/generate.ts). * - * Nothing in the build fails if this wiring rots. Both linkers skip names they - * cannot resolve, so a dropped flag silently gives the RSS back instead of + * Nothing in the build fails if this wiring rots. All three linkers skip names + * they cannot resolve, so a dropped flag silently gives the RSS back instead of * breaking the link. CI's verifyOrderFileApplied() catches it, but only on * release builds — these checks are what notices in a PR. */ @@ -28,6 +45,7 @@ const cfg = (overrides: Partial = {}) => linux: true, darwin: false, abi: "gnu", + arch: "x64", arm64: false, release: true, asan: false, @@ -38,12 +56,35 @@ const cfg = (overrides: Partial = {}) => mode: "link-only", crossTarget: undefined, canRunOnHost: true, + host: { os: "linux" }, buildDir: "/tmp/build", + cacheDir: "/tmp/build/cache", cwd: "/repo", ...overrides, }) as Config; const darwinArm64 = { linux: false, darwin: true, abi: undefined, arm64: true } as Partial; +/** Both windows lanes cross-compile from linux, so neither can run what it links. */ +const windowsX64 = { + linux: false, + windows: true, + abi: undefined, + crossTarget: "x86_64-pc-windows-msvc", + canRunOnHost: false, +} as Partial; +const windowsArm64 = { + ...windowsX64, + arch: "aarch64", + arm64: true, + crossTarget: "aarch64-pc-windows-msvc", +} as Partial; + +/** Everything the link command line gets from linkerFlags for this config (an entry without `when` always applies). */ +const appliedLinkerFlags = (config: Config): string[] => + linkerFlags + .filter(flag => !flag.when || flag.when(config)) + .flatMap(flag => (typeof flag.flag === "function" ? flag.flag(config) : flag.flag)) + .flat(); /** A canary build on Buildkite, off a pull request. */ const ctx = (overrides: Partial = {}): OrderFileContext => ({ @@ -68,8 +109,16 @@ describe("symbol ordering file", () => { expect(usesOrderFile(cfg({ ...darwinArm64, crossTarget: "arm64-apple-macosx" }))).toBe(true); }); + it("is enabled for both windows release links", () => { + // Neither lane can trace what it links (see windowsX64); each inherits the + // file its trace-order step traced on the matching test fleet. + expect(usesOrderFile(cfg(windowsX64))).toBe(true); + expect(usesOrderFile(cfg(windowsArm64))).toBe(true); + }); + it("is disabled where it cannot work or is not wanted", () => { expect(usesOrderFile(cfg({ release: false }))).toBe(false); // debug: not worth a relink + expect(usesOrderFile(cfg({ ...windowsX64, release: false }))).toBe(false); expect(usesOrderFile(cfg({ asan: true }))).toBe(false); // tracer swaps .text expect(usesOrderFile(cfg({ valgrind: true }))).toBe(false); // Both of these would otherwise attempt a trace that can never succeed and @@ -78,7 +127,7 @@ describe("symbol ordering file", () => { expect(usesOrderFile(cfg({ abi: "android" }))).toBe(false); // cross: cannot run the binary // darwin x64: the tracer is arm64-only, so nothing ever seeds the chain. expect(usesOrderFile(cfg({ ...darwinArm64, arm64: false }))).toBe(false); - expect(usesOrderFile(cfg({ linux: false, windows: true }))).toBe(false); + expect(usesOrderFile(cfg({ linux: false, freebsd: true, abi: undefined }))).toBe(false); }); it("lives in the build directory, never the source tree", () => { @@ -88,10 +137,7 @@ describe("symbol ordering file", () => { it("is passed to lld on the linux release link", () => { const config = cfg(); - const applied = linkerFlags - .filter(flag => flag.when(config)) - .flatMap(flag => (typeof flag.flag === "function" ? flag.flag(config) : flag.flag)) - .flat(); + const applied = appliedLinkerFlags(config); expect(applied).toContain(`-Wl,--symbol-ordering-file=${orderFilePath(config)}`); // Without this, a stale entry is a hard link error rather than a skipped symbol. @@ -101,33 +147,104 @@ describe("symbol ordering file", () => { it("is passed to Apple ld on the macOS arm64 release link", () => { const config = cfg(darwinArm64); - const applied = linkerFlags - .filter(flag => flag.when(config)) - .flatMap(flag => (typeof flag.flag === "function" ? flag.flag(config) : flag.flag)) - .flat(); + const applied = appliedLinkerFlags(config); expect(applied).toContain(`-Wl,-order_file,${orderFilePath(config)}`); expect(applied.join(" ")).not.toContain("--symbol-ordering-file"); }); + it("is passed to lld-link on both windows release links, along with the maps that name its entries", () => { + for (const config of [cfg(windowsX64), cfg(windowsArm64)]) { + const applied = appliedLinkerFlags(config); + + expect(applied).toContain(`/order:@${slash(orderFilePath(config))}`); + // LNK4037, once per name the inherited file has that this build no longer + // does: the windows spelling of --no-warn-symbol-ordering above. + expect(applied).toContain("/ignore:4037"); + // The PE has no symbol table, so these are what the trace-order step turns + // addresses back into names with (windows-symbols.ts) — the listing for the + // names, lld's own map for which of them start a function. + expect(applied).toContain(`/map:${slash(symbolMapPath(config))}`); + expect(applied).toContain(`/lldmap:${slash(linkerMapPath(config))}`); + expect(applied.join(" ")).not.toMatch(/--symbol-ordering-file|-order_file/); + } + }); + it("is not passed on a debug or sanitizer link", () => { - for (const config of [cfg({ release: false }), cfg({ asan: true })]) { - const applied = linkerFlags - .filter(flag => flag.when(config)) - .flatMap(flag => (typeof flag.flag === "function" ? flag.flag(config) : flag.flag)) - .flat() - .join(" "); - expect(applied).not.toContain("--symbol-ordering-file"); - expect(applied).not.toContain("-order_file"); + for (const config of [cfg({ release: false }), cfg({ asan: true }), cfg({ ...windowsX64, release: false })]) { + const applied = appliedLinkerFlags(config).join(" "); + expect(applied).not.toMatch(/--symbol-ordering-file|-order_file|\/order:/); } }); it("is a link dependency, so regenerating it relinks", () => { // This is what makes the release two-pass work: overwrite the file, re-run - // ninja, and the link is the only edge whose input changed. - expect(linkDepends(cfg())).toContain(orderFilePath(cfg())); - expect(linkDepends(cfg(darwinArm64))).toContain(orderFilePath(cfg(darwinArm64))); + // ninja, and the link is the only edge whose input changed. On windows it is + // what makes inheriting one relink at all. + for (const config of [cfg(), cfg(darwinArm64), cfg(windowsX64), cfg(windowsArm64)]) { + expect(linkDepends(config)).toContain(orderFilePath(config)); + } expect(linkDepends(cfg({ release: false }))).not.toContain(orderFilePath(cfg({ release: false }))); + expect(linkDepends(cfg({ ...windowsX64, release: false }))).not.toContain(orderFilePath(cfg(windowsX64))); + }); +}); + +describe("linker maps", () => { + it("are written exactly where linkerMapOutputs() says, which is what declares them to ninja and ships them", () => { + // bun.ts declares the maps as the link's outputs and ci.ts packs them from + // that list, while the flags that write them live in each platform's entry; + // the trace-order step on windows reads them out of the profile zip, so the + // two drifting apart there means silently unordered windows builds. + const configs = { + linux: cfg(), + "linux asan": cfg({ asan: true }), + "linux debug": cfg({ release: false }), + "macOS arm64": cfg(darwinArm64), + "windows x64": cfg(windowsX64), + "windows arm64": cfg(windowsArm64), + "windows debug": cfg({ ...windowsX64, release: false }), + }; + const written = Object.fromEntries( + Object.entries(configs).map(([name, config]) => { + const flags = appliedLinkerFlags(config).join(" "); + const maps = linkerMapOutputs(config); + // Each declared map is named by some flag (as given, or slashed for + // lld-link), and a config that declares none has no map flag at all. + const everyMapWritten = maps.every(map => flags.includes(map) || flags.includes(slash(map))); + const anyMapFlag = /bun-profile\.(linker-)?map\b/.test(flags); + return [name, everyMapWritten && anyMapFlag === maps.length > 0]; + }), + ); + expect(written).toEqual(Object.fromEntries(Object.keys(configs).map(name => [name, true]))); + + const declared = Object.fromEntries( + Object.entries(configs).map(([name, config]) => [ + name, + linkerMapOutputs(config).map(map => map.split(/[\\/]/).at(-1)), + ]), + ); + expect(declared).toEqual({ + linux: ["bun-profile.linker-map"], + "linux asan": [], + "linux debug": [], + "macOS arm64": ["bun-profile.linker-map"], + "windows x64": ["bun-profile.linker-map", "bun-profile.map"], + "windows arm64": ["bun-profile.linker-map", "bun-profile.map"], + "windows debug": [], + }); + expect(Object.entries(configs).map(([, config]) => writesLinkerMap(config))).toEqual( + Object.entries(declared).map(([, maps]) => maps.length > 0), + ); + }); + + it("are named after the binary they describe, on both sides", () => { + // The link writes them next to the binary (flags.ts); the generator, handed + // only the binary, looks for the same names next to it. + const exe = join("/tmp/build", "bun-profile.exe"); + expect([linkerMapFor(exe), symbolMapFor(exe)]).toEqual([ + linkerMapPath(cfg(windowsX64)), + symbolMapPath(cfg(windowsX64)), + ]); }); }); @@ -170,9 +287,16 @@ describe("deciding whether a build generates its own order file", () => { }); }); -const compiler = process.env.CC || Bun.which("cc") || Bun.which("clang") || Bun.which("gcc"); +const orderfile = join(import.meta.dir, "../../../../scripts/orderfile"); const darwin = process.platform === "darwin"; -const supported = process.platform === "linux" || (darwin && process.arch === "arm64"); +const supported = process.platform === "linux" || isWindows || (darwin && process.arch === "arm64"); +// On windows specifically clang-cl, which is on the CI images: the fixtures +// below are linked by lld-link to get the maps the generator reads, the way the +// release link writes them. (The generator itself also accepts cl for building +// the tracer; it gets its maps from the build.) +const compiler = isWindows + ? Bun.which("clang-cl") + : process.env.CC || Bun.which("cc") || Bun.which("clang") || Bun.which("gcc"); // Not musl: the real generator never runs there (bun-musl is statically linked, // so LD_PRELOAD cannot load the tracer — see usesOrderFile), so compiling and // running the tracer on a musl host exercises nothing the build uses. @@ -180,6 +304,8 @@ const canTrace = supported && !isMusl && !!compiler; /** The injected-library variable the tracer rides in on. */ const preloadVar = darwin ? "DYLD_INSERT_LIBRARIES" : "LD_PRELOAD"; const shared = darwin ? ["-dynamiclib", "-fPIC"] : ["-shared", "-fPIC"]; +const STARTS_MAGIC = 0x4e55425354525453n; +const TRACE_MAGIC = 0x4e55424543415254n; async function compile(args: string[]) { await using proc = Bun.spawn({ cmd: [compiler!, "-O1", ...args], env: bunEnv, stderr: "pipe" }); @@ -188,14 +314,166 @@ async function compile(args: string[]) { expect(exitCode).toBe(0); } +/** + * clang-cl compile and lld-link of one source file into `out`, in `cwd` so the + * .obj lands there; lld-link explicitly, as the generator does, since `link` on + * PATH may well be coreutils'. `link` is extra linker options. + */ +async function compileMsvc(cwd: string, source: string, out: string, link: string[] = []) { + // /Gy as in the real build: a chunk per function, which is what the generator + // takes to be one (windows-symbols.ts) and what /order can move. + await using proc = Bun.spawn({ + cmd: [compiler!, "/nologo", "/O1", "/Gy", "-fuse-ld=lld", source, `/Fe:${out}`, ...(link.length ? ["/link", ...link] : [])], // prettier-ignore + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`${compiler} exited ${exitCode}:\n${stdout}${stderr}`); +} + +/** The linker options that write a binary's two maps where the generator looks for them (see windows-symbols.ts). */ +const mapsFor = (exe: string): string[] => [`/map:${symbolMapFor(exe)}`, `/lldmap:${linkerMapFor(exe)}`]; + +/** The starts file the generator writes: magic, version, count, then every function's link-time address. */ +async function writeStarts(path: string, addresses: Iterable) { + const list = [...addresses].map(BigInt); + const words = new BigUint64Array(3 + list.length); + words.set([STARTS_MAGIC, 1n, BigInt(list.length)], 0); + words.set(list, 3); + await Bun.write(path, new Uint8Array(words.buffer)); +} + +/** The trace's header: magic, version, slide, start count, entry count. */ +async function readTraceHeader(path: string) { + const [magic, version, , , entries] = new BigUint64Array(await Bun.file(path).slice(0, 40).arrayBuffer()); + return { magic, version, entries: Number(entries) }; +} + describe("order file generator", () => { it.skipIf(!supported)("refuses a build directory with no binary to trace", () => { expect(() => generateOrderFile({ buildDir: "/tmp/definitely-not-a-build-dir" })).toThrow(/not found/); }); it.skipIf(supported)("refuses to run on an unsupported platform", () => { - // The tracer is x86-64 INT3 / arm64 BRK on linux, or arm64 BRK on macOS. - expect(() => generateOrderFile({ buildDir: "/tmp/build" })).toThrow(/linux|macOS/); + // The tracers are x86-64 INT3 / arm64 BRK on linux and windows, arm64 BRK on macOS. + expect(() => generateOrderFile({ buildDir: "/tmp/build" })).toThrow(/linux|macOS|Windows/); + }); +}); + +/** + * On windows the generator gets its functions from the two maps the link writes + * (the PE has no symbol table): the names from lld-link's symbol listing, which + * have to be the linker's exact spellings or /order matches nothing, and which + * of them are functions from lld's own map of chunks. See windows-symbols.ts. + */ +describe("windows symbol maps", () => { + // Shape of a real listing: one output section can have several rows, names + // can overflow their column, folded functions share an address, section 0000 + // holds absolute symbols that have addresses too, and the statics come after + // the publics. + const symbolMap = [ + " bun-profile", + "", + " Preferred load address is 0000000140000000", + "", + " Start Length Name Class", + " 0001:00000000 0000019fH .text CODE", + " 0001:000001a0 0001604aH .text$mn CODE", + " 0002:00000000 00005bf0H .rdata DATA", + " 0003:00000000 00000a81H .data DATA", + "", + " Address Publics by Value Rva+Base Lib:Object", + "", + " 0000:00000000 __guard_fids_table 0000000000000000 ", + " 0001:00000000 main 0000000140001000 bun.obj", + " 0001:00000080 ?run@Server@bun@@QEAAXAEBV?$Vector@PEAXV?$Allocator@PEAX@bun@@@2@@Z 0000000140001080 bun.obj", + " 0001:00000200 memset 0000000140001200 libvcruntime:memset.obj", + " 0002:00000010 ??_C@_02DKCKIIND@?$CFs?$AA@ 0000000140002010 bun.obj", + " 0003:00000000 sink 0000000140003000 bun.obj", + "", + " entry point at 0001:00000000", + "", + " Static symbols", + "", + " 0000:00000000 __guard_fids__ 0000000140000000 libcmt:exe_main.obj", + " 0001:00000074 $LN12 0000000140001074 bun.obj", + " 0001:00000078 $LN13 0000000140001078 bun.obj", + " 0001:000001a0 _ZN3bun4mainE 00000001400011a0 libbun_rust.lib(bun.o)", + " 0001:000001a0 _ZN3bun4sameE.llvm.123 00000001400011a0 libbun_rust.lib(bun.o)", + " 0001:00000200 .bf 0000000140001200 libvcruntime:memset.obj", + " 0001:00000240 Table 0000000140001240 libvcruntime:memset.obj", + " 0002:00000020 anInitializer 0000000140002020 bun.obj", + "", + ].join("\n"); + + // Shape of lld's map: output sections, the chunks placed in each (one per + // function where there are function sections; memset.obj's whole .text is + // one), empty chunks, and under each chunk its symbols, demangled — including + // one whose demangled name happens to contain the chunk marker. + const linkerMap = [ + "Address Size Align Out In Symbol", + "00001000 00000280 4096 .text", + "00001000 00000000 4 bun.obj:(.text)", + "00001000 0000007c 16 bun.obj:(.text$mn)", + "00001000 00000000 0 int __cdecl main(int, char **)", + "00001074 00000000 0 $LN12", + "00001080 00000010 16 bun.obj:(.text$mn)", + "00001080 00000000 0 public: void __cdecl bun::Server::run(class bun::Vector> const &)", + "000011a0 00000040 16 libbun_rust.lib(bun.o):(.text)", + "000011a0 00000000 0 bun::(anonymous namespace)::main", + "000011c0 00000000 0 bun::(anonymous namespace)::helper", + "00001200 00000080 16 libvcruntime.lib(memset.obj):(.text)", + "00001200 00000000 0 memset", + "00001240 00000000 0 Table", + "00002000 00000030 4096 .rdata", + "00002010 00000003 1 bun.obj:(.rdata)", + '00002010 00000000 0 "%s"', + "", + ].join("\n"); + + it("lists every name in the code sections of the symbol listing, and the image base", () => { + expect(parseSymbolMap(symbolMap)).toEqual({ + imageBase: 0x140000000, + symbols: [ + [0x140001000, "main"], + [0x140001080, "?run@Server@bun@@QEAAXAEBV?$Vector@PEAXV?$Allocator@PEAX@bun@@@2@@Z"], + [0x140001200, "memset"], + [0x140001074, "$LN12"], + [0x140001078, "$LN13"], + [0x1400011a0, "_ZN3bun4mainE"], + [0x1400011a0, "_ZN3bun4sameE.llvm.123"], + [0x140001200, ".bf"], + [0x140001240, "Table"], + ], + }); + expect(() => parseSymbolMap("not a map\n")).toThrow(/image base/); + }); + + it("takes the chunks, and only the chunks, from lld's map", () => { + // Not the output sections, and not the symbols, whatever their names look like. + expect([...parseChunkStarts(linkerMap)].sort((a, b) => a - b)).toEqual([0x1000, 0x1080, 0x11a0, 0x1200, 0x2010]); + }); + + it("keeps the names that start a chunk, which is what drops the labels on the tables inside functions", () => { + using dir = tempDir("windows-symbols", { + "traced.map": symbolMap, + "traced.linker-map": linkerMap, + "unmapped.exe": "", + }); + + // main's jump table slots ($LN12, $LN13) and memset's byte table are gone; + // memset's own second name at its start is as welcome as any other alias. + expect(readTextSymbols(join(String(dir), "traced.exe"))).toEqual( + new Map([ + [0x140001000, ["main"]], + [0x140001080, ["?run@Server@bun@@QEAAXAEBV?$Vector@PEAXV?$Allocator@PEAX@bun@@@2@@Z"]], + [0x140001200, ["memset", ".bf"]], + [0x1400011a0, ["_ZN3bun4mainE", "_ZN3bun4sameE.llvm.123"]], + ]), + ); + expect(() => readTextSymbols(join(String(dir), "unmapped.exe"))).toThrow(/unmapped\.map not found/); }); }); @@ -246,9 +524,9 @@ describe.skipIf(process.platform !== "linux" || !nodeExe())("interactive workloa * One of the traced workloads runs on a pseudo-terminal, because bun's stdio, * tty and readline code take a path there that a pipe never reaches, and an * order file that missed it would leave all of that scattered. `ptyrun.c` is - * what provides the terminal. + * what provides the terminal (on windows, the tracer itself does — see below). */ -describe.skipIf(!canTrace)("pty runner", () => { +describe.skipIf(!canTrace || isWindows)("pty runner", () => { /** Reports what the process sees on its stdio, plus the one line it was typed. */ const probe = [ `process.stdin.once("data", data => {`, @@ -287,7 +565,7 @@ describe.skipIf(!canTrace)("pty runner", () => { // binary and not into ptyrun. const preload = join(String(dir), darwin ? "empty.dylib" : "empty.so"); await Promise.all([ - compile(["-o", ptyrun, join(import.meta.dir, "../../../../scripts/orderfile/ptyrun.c"), ...(darwin ? [] : ["-lutil"])]), // prettier-ignore + compile(["-o", ptyrun, join(orderfile, "ptyrun.c"), ...(darwin ? [] : ["-lutil"])]), compile([...shared, "-o", preload, join(String(dir), "empty.c")]), ]); @@ -305,14 +583,40 @@ describe.skipIf(!canTrace)("pty runner", () => { }); }); +/** + * What a trace of functrace-fixture.c must say, whichever tracer wrote it: the + * fixture calls f0..f31 in that order, runs a child, then calls `after`, and + * every one of those is a first entry. A trace a child process truncated or + * re-armed over has a handful of entries and is missing the early ones, which + * in a real trace are the hottest. + */ +async function expectFixtureTrace(trace: string, symbols: Map) { + const raw = await Bun.file(trace).arrayBuffer(); + const words = new BigUint64Array(raw); + // Layout: u64 magic, version, slide, start count, entry count, then the entries. + expect({ magic: words[0], version: words[1] }).toEqual({ magic: TRACE_MAGIC, version: 1n }); + const entries = Array.from(words.subarray(5, 5 + Number(words[4])), address => Number(address)); + + // Each entry resolves to the names at that address, the way generate.ts + // resolves them; macOS nm spells C functions with a leading underscore. + const names = entries.flatMap(address => symbols.get(address) ?? [`unresolved ${address.toString(16)}`]); + const plain = names.map(name => (darwin ? name.replace(/^_/, "") : name)); + const touched = plain.filter(name => /^f\d+$/.test(name)); + + expect(touched).toEqual(Array.from({ length: 32 }, (_, i) => `f${i}`)); + expect(plain).toContain("main"); + expect(plain).toContain("after"); + expect(plain.indexOf("after")).toBeGreaterThan(plain.indexOf("f31")); + expect(new Set(entries).size).toBe(entries.length); +} + /** * The tracer loads into the binary under trace and nowhere else. Every workload * that execs something — `bun install` runs lifecycle scripts, the cli workload * shells out — hands the preload to the child, and a child that created and - * truncated the trace file would wipe the entries recorded so far. Those are - * the earliest ones, which is to say the hottest. + * truncated the trace file would wipe the entries recorded so far. */ -describe.skipIf(!canTrace)("function tracer", () => { +describe.skipIf(!canTrace || isWindows)("function tracer", () => { it.concurrent("records exact entries, and keeps them across an exec'd child", async () => { using dir = tempDir("functrace", { "child.c": "int main(void) { return 0; }\n" }); const root = String(dir); @@ -323,30 +627,17 @@ describe.skipIf(!canTrace)("function tracer", () => { const trace = join(root, "trace.bin"); await Promise.all([ - compile([...shared, "-o", tracer, join(import.meta.dir, "../../../../scripts/orderfile/functrace.c"), ...(darwin ? [] : ["-ldl", "-lpthread"])]), // prettier-ignore + compile([...shared, "-o", tracer, join(orderfile, "functrace.c"), ...(darwin ? [] : ["-ldl", "-lpthread"])]), compile(["-o", fixture, join(import.meta.dir, "functrace-fixture.c")]), compile(["-o", child, join(root, "child.c")]), ]); - // Write the starts file the generator would: magic, version, count, then - // nm's text-symbol addresses. Bare nm, no GNU-only flags — the regex is - // the defined-text-symbol filter. - await using nm = Bun.spawn({ cmd: ["nm", fixture], env: bunEnv, stdout: "pipe", stderr: "pipe" }); - const [nmOut, nmErr, nmExit] = await Promise.all([nm.stdout.text(), nm.stderr.text(), nm.exited]); - const addresses: bigint[] = []; - for (const line of nmOut.split("\n")) { - const m = /^([0-9a-f]+) [tT] \S+$/.exec(line); - if (m) addresses.push(BigInt(`0x${m[1]}`)); - } - expect({ nmErr, nmExit }).toEqual({ nmErr: "", nmExit: 0 }); - expect(addresses.length).toBeGreaterThan(33); - const words = new BigUint64Array(3 + addresses.length); - words.set([0x4e55425354525453n, 1n, BigInt(addresses.length)], 0); - words.set(addresses, 3); - await Bun.write(starts, new Uint8Array(words.buffer)); - - // The fixture calls 32 functions, execs `child` (dynamically linked, so it - // inherits the preload), then calls one more. + // The starts file the generator would write, from the same symbol reader. + const symbols = readTextSymbols(fixture); + expect(symbols.size).toBeGreaterThan(33); + await writeStarts(starts, symbols.keys()); + + // The child is dynamically linked, so it inherits the preload. await using proc = Bun.spawn({ cmd: [fixture, child], env: { ...bunEnv, [preloadVar]: tracer, BUN_FUNCTRACE_STARTS: starts, BUN_FUNCTRACE_OUT: trace }, @@ -356,11 +647,155 @@ describe.skipIf(!canTrace)("function tracer", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "497", stderr: "", exitCode: 0 }); - // Layout: u64 magic, version, slide, start count, entry count, addresses. - const header = new BigUint64Array(await Bun.file(trace).slice(0, 40).arrayBuffer()); - expect({ magic: header[0], version: header[1] }).toEqual({ magic: 0x4e55424543415254n, version: 1n }); - // The 32 fixture functions, the one after, plus _start and main. A child - // that truncated the file leaves a handful. - expect(Number(header[4])).toBeGreaterThanOrEqual(33); + await expectFixtureTrace(trace, symbols); + }); +}); + +/** + * On windows the tracer is a debugger (functrace-windows.c), so it takes the + * place of both functrace.c and ptyrun.c: it starts the binary itself — on a + * pseudo console when asked to, since that is the only way the console paths + * get traced — plants the breakpoints from outside, and writes the same trace. + * Its addresses come from the link's maps rather than nm, so the fixtures are + * linked with them, as the release is. + */ +describe.skipIf(!canTrace || !isWindows)("windows tracer", () => { + it.concurrent("records exact entries out of the maps' functions, and leaves the child alone", async () => { + using dir = tempDir("functrace-windows", { "child.c": "int main(void) { return 0; }\n" }); + const root = String(dir); + const tracer = join(root, "functrace.exe"); + const fixture = join(root, "fixture.exe"); + const child = join(root, "child.exe"); + const starts = join(root, "starts.bin"); + const trace = join(root, "trace.bin"); + + await Promise.all([ + compileMsvc(root, join(orderfile, "functrace-windows.c"), tracer), + // No folding: `after` has the same body as f1, and the trace is checked + // for it being entered separately, after f31. + compileMsvc(root, join(import.meta.dir, "functrace-fixture.c"), fixture, [...mapsFor(fixture), "/opt:noicf"]), + compileMsvc(root, join(root, "child.c"), child), + ]); + + const symbols = readTextSymbols(fixture); + expect(symbols.size).toBeGreaterThan(33); // the fixture's own functions, plus the static CRT's + // The static CRT is also where the labels come from that are not functions: + // its assembly routines name their internal labels (and, on arm64, their + // tables), so the listing always has more than the functions kept here. A + // breakpoint on one of those tables is what this test crashes on otherwise. + const listed = parseSymbolMap(readFileSync(symbolMapFor(fixture), "utf8")).symbols.length; + expect([...symbols.values()].flat().length).toBeLessThan(listed); + await writeStarts(starts, symbols.keys()); + + await using proc = Bun.spawn({ + cmd: [tracer, fixture, child], + env: { ...bunEnv, BUN_FUNCTRACE_STARTS: starts, BUN_FUNCTRACE_OUT: trace }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The fixture's stdout comes through the tracer's, and so does its exit code. + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "497", stderr: "", exitCode: 0 }); + + await expectFixtureTrace(trace, symbols); + }); + + it.concurrent("reports the debuggee's exit code, and refuses a binary the starts are not for", async () => { + using dir = tempDir("functrace-windows-exit", { + "exit.c": "int main(int argc, char **argv) { (void)argv; return argc + 40; }\n", + }); + const root = String(dir); + const tracer = join(root, "functrace.exe"); + const exit = join(root, "exit.exe"); + const starts = join(root, "starts.bin"); + await Promise.all([ + compileMsvc(root, join(orderfile, "functrace-windows.c"), tracer), + compileMsvc(root, join(root, "exit.c"), exit, mapsFor(exit)), + ]); + const env = { ...bunEnv, BUN_FUNCTRACE_STARTS: starts, BUN_FUNCTRACE_OUT: join(root, "trace.bin") }; + + await writeStarts(starts, readTextSymbols(exit).keys()); + await using traced = Bun.spawn({ cmd: [tracer, exit, "a", "b"], env, stdout: "pipe", stderr: "pipe" }); + // Addresses far outside any code section: a starts file for some other binary. + await writeStarts(join(root, "elsewhere.bin"), [0x7ff600000000, 0x7ff600000010]); + await using refused = Bun.spawn({ + cmd: [tracer, exit], + env: { ...env, BUN_FUNCTRACE_STARTS: join(root, "elsewhere.bin") }, + stdout: "pipe", + stderr: "pipe", + }); + + const [tracedErr, tracedExit, refusedErr, refusedExit] = await Promise.all([ + traced.stderr.text(), + traced.exited, + refused.stderr.text(), + refused.exited, + ]); + expect({ tracedErr, tracedExit, refusedExit }).toEqual({ tracedErr: "", tracedExit: 43, refusedExit: 2 }); + expect(refusedErr).toContain("none of the 2 function starts"); + }); + + it.concurrent("puts the debuggee on a console when asked to, and types our stdin into it", async () => { + using dir = tempDir("functrace-console", { + // Reports whether its stdio is a console, how wide, and the line it was typed. + "probe.c": [ + "#include ", + "#include ", + "#include ", + "int main(void) {", + " DWORD mode;", + " CONSOLE_SCREEN_BUFFER_INFO screen;", + " int console = GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) &&", + " GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &screen);", + " char line[64];", + ' const char *typed = fgets(line, sizeof line, stdin) ? line : "nothing";', + ' line[strcspn(line, "\\r\\n")] = 0;', + ' printf("%s %d %s\\n", console ? "true" : "false", console ? (int)screen.dwSize.X : 0, typed);', + " return 0;", + "}", + "", + ].join("\n"), + }); + const root = String(dir); + const tracer = join(root, "functrace.exe"); + const probe = join(root, "probe.exe"); + const starts = join(root, "starts.bin"); + await Promise.all([ + compileMsvc(root, join(orderfile, "functrace-windows.c"), tracer), + compileMsvc(root, join(root, "probe.c"), probe, mapsFor(probe)), + ]); + await writeStarts(starts, readTextSymbols(probe).keys()); + + async function type(name: string, env: Record) { + const trace = join(root, `${name}.bin`); + await using proc = Bun.spawn({ + cmd: [tracer, probe], + env: { ...bunEnv, ...env, BUN_FUNCTRACE_STARTS: starts, BUN_FUNCTRACE_OUT: trace }, + stdin: new Blob(["hi\n"]), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A console's output is a terminal rendering — escape sequences, and the + // typed line echoed back — so pick the probe's own line out of it. + const line = stdout + .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") + .split(/[\x00-\x1f]+/) + .map(text => text.trim()) + .find(text => /^(true|false) \d+ /.test(text)); + return { line, stderr, exitCode, entries: (await readTraceHeader(trace)).entries }; + } + + const [terminal, pipe] = await Promise.all([type("console", { BUN_FUNCTRACE_TTY: "1" }), type("pipe", {})]); + + expect({ console: terminal.line, pipe: pipe.line, stderr: terminal.stderr + pipe.stderr }).toEqual({ + console: "true 80 hi", + pipe: "false 0 hi", + stderr: "", + }); + expect({ console: terminal.exitCode, pipe: pipe.exitCode }).toEqual({ console: 0, pipe: 0 }); + // Both runs were traced: the probe's main, and the CRT on the way there. + expect(Math.min(terminal.entries, pipe.entries)).toBeGreaterThan(1); }); });