Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/build/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ For `mode: "full"` (the normal case):
6. **Compile** — loop sources, `cxx()`/`cc()` per file.
7. **Link** — `emitShims(n, cfg)` for platform workaround dylibs, then `link(n, cfg, exeName, objects, {libs, flags})`.
8. **Post-link** — strip (release only), dsymutil (darwin release only).
9. **Checks** — validations of the link edge (`ninja check` names them too), all static except the first: `<exe> --revision` (load-time failures; only when the host can run the target), `verify-binary.ts binary` (exported symbols vs the lists in src/, exact NEEDED/dylib/DLL set and glibc/FBSD symbol-version ceilings, forbidden imports, static-initializer allowlist, symbols that must not be defined (the AoT compiler's IR as text, without assertions), W^X / nx-stack / PIE / DllCharacteristics, debug-info shape — expectations in `binary-expectations.ts`), and `verify-binary.ts duplicates` (no symbol strongly defined by two link inputs). Only CI fails on a finding: local builds (`cfg.ci` unset), and ASan and debug builds in CI, run the two scans with `--warn-only` (`binaryChecksWarnOnly` in `bun.ts`) — same report, the step passes.
9. **Checks** — validations of the link edge (`ninja check` names them too), all static except the first: `<exe> --revision` (load-time failures; only when the host can run the target), `verify-binary.ts binary` (exported symbols vs the lists in src/, exact NEEDED/dylib/DLL set and glibc/FBSD symbol-version ceilings, forbidden imports, static-initializer allowlist, symbols that must not be defined (the AoT compiler's IR as text, without assertions), W^X / nx-stack / PIE / DllCharacteristics, the PE header's stack reserve and commit, debug-info shape — expectations in `binary-expectations.ts`), and `verify-binary.ts duplicates` (no symbol strongly defined by two link inputs). Only CI fails on a finding: local builds (`cfg.ci` unset), and ASan and debug builds in CI, run the two scans with `--warn-only` (`binaryChecksWarnOnly` in `bun.ts`) — same report, the step passes.

CI's `build-bun` step builds the same graph (`ci-build` profile: release, prebuilt WebKit); `build.ts` then packages and uploads the zips.

Expand Down
14 changes: 14 additions & 0 deletions scripts/build/binary-expectations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ export interface BinaryExpectations {
/** Exact set of IMAGE_DLL_CHARACTERISTICS_* names. */
dllCharacteristics: string[];
subsystem: string;
/** SizeOfStackReserve, bytes: the main thread's stack, and the default for a thread created without a size. */
stackReserve: number;
/**
* SizeOfStackCommit, bytes. Every thread that passes no commit size of its own is charged this much of the
* system commit limit when it starts, and does not start when it cannot be charged (flags.ts
* WINDOWS_STACK_COMMIT). One page: anything more is paid by every thread of every bun process.
*/
stackCommit: number;
};
macho?: {
/** MH_* flags that must be present. */
Expand Down Expand Up @@ -420,6 +428,9 @@ export function shimExpectations(): BinaryExpectations {
pe: {
dllCharacteristics: ["DYNAMIC_BASE", "HIGH_ENTROPY_VA", "NX_COMPAT", "TERMINAL_SERVER_AWARE"],
subsystem: "IMAGE_SUBSYSTEM_WINDOWS_CUI",
// The linker's defaults: the shim passes no /STACK.
stackReserve: 0x100000,
stackCommit: 0x1000,
},
};
}
Expand Down Expand Up @@ -632,6 +643,9 @@ export function binaryExpectations(cfg: Config): BinaryExpectations {
pe: {
dllCharacteristics: ["DYNAMIC_BASE", "HIGH_ENTROPY_VA", "NX_COMPAT", "TERMINAL_SERVER_AWARE"],
subsystem: "IMAGE_SUBSYSTEM_WINDOWS_CUI",
// Literals, not flags.ts's constants: an edit to those has to fail this check.
stackReserve: 0x1200000,
stackCommit: 0x1000,
},
};
}
Expand Down
28 changes: 26 additions & 2 deletions scripts/build/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ export type FlagValue = string | string[] | ((cfg: Config) => string | string[])
*/
export const DARWIN_STACK_SIZE = "0x1200000";

/**
* `/STACK:reserve,commit` for bun.exe: SizeOfStackReserve and SizeOfStackCommit
* in the PE header. The reserve is the main thread's stack, and the default for
* a thread created without a size.
*
* The commit is charged against the system commit limit when a thread starts,
* for EVERY thread that passes no commit size of its own, and Windows does not
* start a thread it cannot charge it for. That covers `CreateThread` with size
* 0 (WTF, mimalloc, zstd, sqlite, threads the OS adds to the process) and with
* `STACK_SIZE_PARAM_IS_A_RESERVATION` (all of Rust std). One page is lld-link's
* default, and what node.exe and WebKit's own executables ship: the rest of the
* stack is committed as it is touched.
*
* The commit is also a floor under every thread's stack. A thread that asks for
* a reserve at or below it (64 KB, 512 KB and 2 MB all did under a 2 MB commit)
* gets the commit as its reserve, all of it committed, which leaves no guard
* page.
*
* binary-expectations.ts states both numbers again as literals, and
* verify-binary.ts checks the linked header against them.
*/
export const WINDOWS_STACK_RESERVE = "0x1200000";
export const WINDOWS_STACK_COMMIT = "0x1000";

export interface Flag {
/** Flag(s) to emit. Can be a function for flags that interpolate config values. */
flag: FlagValue;
Expand Down Expand Up @@ -1134,9 +1158,9 @@ export const linkerFlags: Flag[] = [
toolchain: true,
},
{
flag: ["/STACK:0x1200000,0x200000", "/errorlimit:0"],
flag: [`/STACK:${WINDOWS_STACK_RESERVE},${WINDOWS_STACK_COMMIT}`, "/errorlimit:0"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Windows users whose script has a large constant regexp tested against a constant string get an access-violation crash (exit 0xC0000005) that the base build does not have. The /STACK:...,0x1000 operand at scripts/build/flags.ts:1044 leaves a JIT worklist thread with a 4 KB committed stack plus one guard page, and Yarr's JIT prologue moves the stack pointer by the whole frame without a probe when DFGStrengthReductionPhase folds the match on that thread. Fix: before shipping a one-page header, make every thread that runs JIT-generated Yarr code safe for frames above one page: give the JIT worklist threads a commit size of their own (WTF Thread::create) or add a stack probe to the Yarr prologue, so the guard page cannot be skipped.

Why this was flagged

A script with a constant regexp of many capture groups, e.g. /^(a?){N}b$/ written out, tested in a hot loop against a constant subject, so the DFG constant-folds the match on a JIT worklist thread via RegExp::matchConcurrently. With scripts/build/flags.ts:1044 now emitting /STACK:0x1200000,0x1000 (WINDOWS_STACK_COMMIT at flags.ts:53), that thread is created by WTF with size 0 and starts with 4096 bytes committed and a single guard page; nothing pre-commits a JIT thread's stack (preCommitStackMemory only runs on JS threads). The Yarr JIT prologue subtracts the full frame from the stack pointer without a probe, so a frame larger than about 16 KB lands past the guard page and the process dies with 0xC0000005. On the base the header commits 0x200000 bytes, so the same frame lands on committed pages and the script runs. The hardening/stack checks in scripts/build/verify-binary.ts:636-644 only confirm the header values and cannot catch this. The PR description measures 15.6 KB passing and 31.3 KB, 46.9 KB and 125 KB failing on both x64 and arm64.

Verification: On Windows, a script whose hot function tests a large constant regexp against a constant string, so the DFG folds the match on a JIT worklist thread. scripts/build/flags.ts:1044 now emits /STACK:${WINDOWS_STACK_RESERVE},${WINDOWS_STACK_COMMIT} with WINDOWS_STACK_COMMIT = "0x1000" (flags.ts:53), replacing /STACK:0x1200000,0x200000. JIT-generated Yarr code emits no __chkstk-style probe.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct. It is the second bullet under Downsides, and the numbers quoted here are the ones measured for this PR.

The gap is in JSC. For a match on a compiler thread, Yarr takes its stack limit from the reserve of the thread, and nothing commits or probes the frame. Upstream JSC has the same gap with its own one-page header. The 2 MB commit covered it for frames below about 1.9 MB.

Both fixes named here are changes in oven-sh/WebKit:

  • A commit size of their own for the JIT worklist threads puts the large request back on the threads of one of the two crash groups this PR is about (BUN-2V2T is JITWorklist::wakeThreads).
  • A probe in the Yarr prologue, or a stack limit taken from the committed stack so that the DFG does not fold the match, fixes it where it is. That fix is separate work, with a Windows test for the 31.3 KB frame.

The order is a decision for the maintainer:

  • This PR after the WebKit bump that carries the Yarr fix: no new failure.
  • This PR first: until that bump, a match that the DFG folds crashes when its Yarr frame is above about 16 KB. The largest frame in 4,219 regexes from packages is 6 KB, and the match needs a constant subject in hot code.

when: c => c.windows,
desc: "18MB stack reserve (JSC uses deep recursion), no error limit",
desc: "18MB stack reserve (JSC uses deep recursion), one page committed when a thread starts and the rest as it is touched; no error limit",
},
{
flag: "/DEBUG:FULL",
Expand Down
31 changes: 29 additions & 2 deletions scripts/build/verify-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* Static scans of the linked executable against the expectations
* configure wrote into <spec.json> (see binary-expectations.ts):
* exported symbols, dynamic libraries + symbol-version ceilings,
* forbidden imports, static initializers, hardening bits, debug info.
* forbidden imports, static initializers, hardening bits, the PE
* header's stack reserve and commit, debug info.
*
* verify-binary.ts duplicates <nm> <rspfile> <report>
* Every object and archive on bun's link line, scanned for a symbol
Expand Down Expand Up @@ -74,7 +75,15 @@ function versionLeq(a: string, b: string): boolean {
}

type CheckName =
"exports" | "dynamic libraries" | "imports" | "static initializers" | "symbols" | "hardening" | "debug info" | "size";
| "exports"
| "dynamic libraries"
| "imports"
| "static initializers"
| "symbols"
| "hardening"
| "thread stacks"
| "debug info"
| "size";

/**
* What a violation of each check means for the shipped binary and what the
Expand Down Expand Up @@ -122,6 +131,14 @@ const GUIDANCE: Record<CheckName, string[]> = {
"stack, no writable+executable mapping, ASLR flags). Losing one is almost always an",
"unintended side effect of a linker-flag or toolchain change; find that change.",
],
"thread stacks": [
"SizeOfStackCommit is not the main thread's alone: Windows charges it against the system",
"commit limit when any thread starts without a commit size of its own (WTF, Rust std,",
"mimalloc, threads the OS adds), and does not start a thread it cannot charge it for.",
"Above one page, every thread of every bun process pays the difference at its start.",
"For a deeper stack raise the reserve, or give that one thread a size of its own",
"(flags.ts WINDOWS_STACK_RESERVE / WINDOWS_STACK_COMMIT).",
],
size: [
"This program is written to stay tiny (no CRT, no allocator, no formatting). A jump in size",
"means one of those came in: look for a new panic message, a `core::fmt` use, or a new dependency.",
Expand Down Expand Up @@ -643,6 +660,16 @@ function verifyPE(spec: VerifySpec): void {
violations.push(`subsystem version ${ver}, expected ${expect.minOSVersion}`);
const props = [...chars, `subsystem ${ver}`];
report("hardening", `${props.length} hardening properties`, violations, props);

// 6. stack sizes every thread of the process inherits
const reserve = Number(field(hdr, "SizeOfStackReserve"));
const commit = Number(field(hdr, "SizeOfStackCommit"));
const stackViolations: string[] = [];
if (reserve !== expect.pe.stackReserve)
stackViolations.push(`SizeOfStackReserve ${reserve}, expected ${expect.pe.stackReserve}`);
if (commit !== expect.pe.stackCommit)
stackViolations.push(`SizeOfStackCommit ${commit}, expected ${expect.pe.stackCommit}`);
report("thread stacks", `${reserve} bytes of stack reserved, ${commit} committed per thread`, stackViolations);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/runtime/node/memory_pressure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ mod windows {
);
let Ok(thread) = std::thread::Builder::new()
.name("MemoryPressure".into())
.stack_size(64 * 1024)
// A reservation: Windows commits stack as it is touched.
.stack_size(2 * 1024 * 1024)
.spawn(move || thread_main(vm, n, s))
else {
return;
Expand Down
9 changes: 8 additions & 1 deletion src/threading/io_thread_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ static MUTEX: Mutex = Mutex::new();
/// N > 1 means N-1 `ThreadPool`s are using the IO thread pool.
static REF_COUNT: AtomicUsize = AtomicUsize::new(0);

/// On Windows the size is only reserved, and one `PathBuffer` is 98 KB there.
const STACK_SIZE: u32 = if cfg!(windows) {
2 * 1024 * 1024
} else {
512 * 1024
};

pub fn acquire() -> NonNull<ThreadPoolLib::ThreadPool> {
let mut count = REF_COUNT.load(Ordering::Acquire);
loop {
Expand Down Expand Up @@ -58,7 +65,7 @@ pub fn acquire() -> NonNull<ThreadPoolLib::ThreadPool> {
(*THREAD_POOL.get()).write(ThreadPoolLib::ThreadPool::init(ThreadPoolLib::Config {
max_threads: u32::from(bun_core::get_thread_count().clamp(2, 4)),
// Use a much smaller stack size for the IO thread pool
stack_size: 512 * 1024,
stack_size: STACK_SIZE,
}));
}
// 2 means initialized and referenced by one `ThreadPool`.
Expand Down
13 changes: 13 additions & 0 deletions test/internal/source-lints/windows-cross-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,16 @@ describe.skipIf(isWindows)("Windows cross-compile LTO config (non-windows host)"
expect(linuxFlags.cxxflags).toContain("-fno-split-lto-unit");
});
});

describe.skipIf(isWindows)("Windows cross-compile link flags (non-windows host)", () => {
test("the link reserves 18 MB of stack and commits one page of it", () => {
// The second operand of /STACK is not the main thread's alone: Windows charges it against the system commit
// limit when any thread starts without a commit size of its own, and does not start a thread it cannot charge
// it for. It is written out, so the header does not depend on a linker's default.
for (const arch of ["x64", "aarch64"] as const) {
for (const buildType of ["Release", "Debug"] as const) {
expect(computeFlags(resolveWindowsCross({ arch, buildType })).ldflags).toContain("/STACK:0x1200000,0x1000");
}
}
});
});
81 changes: 80 additions & 1 deletion test/js/bun/binary/tls-segment-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// every platform rather than only showing up as a Windows size bump.

import { describe, expect, test } from "bun:test";
import { isFreeBSD, isLinux, isWindows, preadExact, readElf64ProgramHeaders } from "harness";
import { bunEnv, bunExe, isFreeBSD, isLinux, isWindows, preadExact, readElf64ProgramHeaders } from "harness";
import { closeSync, openSync } from "node:fs";

/**
Expand Down Expand Up @@ -84,3 +84,82 @@ describe("static TLS footprint", () => {
expect(size).toBeLessThan(CEILING);
});
});

/** PE32+: SizeOfStackReserve and SizeOfStackCommit of the optional header. */
function peStackSizes(path: string): { reserve: number; commit: number } {
const fd = openSync(path, "r");
try {
const dos = preadExact(fd, 0, 64);
if (dos.readUInt16LE(0) !== 0x5a4d) throw new Error("not PE (no MZ)");
const peOff = dos.readUInt32LE(60);
if (preadExact(fd, peOff, 4).readUInt32LE(0) !== 0x00004550) throw new Error("not PE (no PE\\0\\0)");
const optional = preadExact(fd, peOff + 4 + 20, 96);
if (optional.readUInt16LE(0) !== 0x20b) throw new Error("not PE32+");
return { reserve: Number(optional.readBigUInt64LE(72)), commit: Number(optional.readBigUInt64LE(80)) };
} finally {
closeSync(fd);
}
}

// The stack commit in the PE header is not the main thread's alone. Windows charges it against the system commit
// limit when a thread starts, for every thread that passes no commit size of its own (WTF's, Rust std's, mimalloc's,
// the ones the OS adds), and does not start a thread it cannot charge it for. bun.exe had 2 MB there: a process near
// the commit limit ended when JSC could not start a GC helper thread, which it does again after every idle period.
describe.skipIf(!isWindows)("thread stacks", () => {
test("the PE header reserves 18 MB of stack and commits one page of it", () => {
expect(peStackSizes(bunExe())).toEqual({ reserve: 0x1200000, commit: 0x1000 });
});

test("a thread commits its stack as it touches it, not when it starts", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const { dlopen } = require("bun:ffi");
const { symbols: kernel32 } = dlopen("kernel32.dll", {
GetModuleHandleA: { args: ["ptr"], returns: "ptr" },
GetProcAddress: { args: ["ptr", "ptr"], returns: "ptr" },
CreateThread: { args: ["ptr", "u64", "ptr", "ptr", "u32", "ptr"], returns: "u64" },
});
const Sleep = kernel32.GetProcAddress(
kernel32.GetModuleHandleA(Buffer.from("kernel32.dll\\0")),
Buffer.from("Sleep\\0"),
);
const INFINITE = 0xffffffff;
const STACK_SIZE_PARAM_IS_A_RESERVATION = 0x10000;
const threads = 32;
// Bun.unsafe.memoryFootprint() is the process's commit charge on Windows (PrivateUsage).
function commitPerThread(stackSize, flags) {
const before = Bun.unsafe.memoryFootprint();
for (let i = 0; i < threads; i++)
if (!kernel32.CreateThread(null, stackSize, Sleep, INFINITE, flags, null))
throw new Error("CreateThread failed");
return Math.round((Bun.unsafe.memoryFootprint() - before) / threads);
}
console.log(JSON.stringify({
// How WTF, mimalloc and the OS start a thread: both sizes from the header.
headerSizes: commitPerThread(0, 0),
// How Rust std starts one: a reserve of its own, the commit from the header.
ownReserve: commitPerThread(0x1200000, STACK_SIZE_PARAM_IS_A_RESERVATION),
}));
process.exit(0);
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const commit = JSON.parse(stdout);
console.log(
`commit per thread start: ${commit.headerSizes} bytes (header sizes), ${commit.ownReserve} (own reserve)`,
);
// With 2 MB in the header each start took 2.1 MB. What is left is the TEB, the TLS block and the first stack
// pages: 28 to 57 KB on the x64 and arm64 test machines.
expect(commit.headerSizes).toBeLessThan(256 * 1024);
expect(commit.ownReserve).toBeLessThan(256 * 1024);
expect(exitCode).toBe(0);
});
});
Loading