Skip to content

windows: statically merge .node addons into --compile output - #30036

Open
robobun wants to merge 63 commits into
mainfrom
farm/f29511e0/pe-static-link-node-addons
Open

windows: statically merge .node addons into --compile output#30036
robobun wants to merge 63 commits into
mainfrom
farm/f29511e0/pe-static-link-node-addons

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows, bun build --compile embeds .node addons as bytes. process.dlopen writes each one to a temp file, loads it with LoadLibraryExW and schedules a delete at reboot. Every run leaves a file and a PendingFileRenameOperations entry behind (Process_functionDlopen, src/jsc/bindings/BunProcess.cpp).

Fix

  • Build time (add_linked_addon, src/exe_format/pe.rs): each addon becomes a section of the exe at its own RVAs. Its exception directory is merged into the exe's, with every handler routed through one trampoline. Relocs, imports, exports and the displaced handlers go into a .bunL section. An addon the merge does not understand is skipped and keeps the old path.
  • Run time (src/standalone_graph/LinkedNodeModule.rs): process.dlopen applies the ASLR delta, binds the IAT, restores the page protections, calls DllMain and returns the exports. A failed bind falls back to the temp file. BUN_FEATURE_FLAG_DISABLE_PE_ADDON_LINK=1 disables the merge.
  • Correct because the section is the image that LoadLibrary would have mapped, at another base. Everything read against an image base (relocs, unwind data, C++ throw info) is rebased at build time or shown the addon's base at run time.
  • Verified: test/bundler/pe-linked-addon-adversarial.test.ts (52 cases), pe-linked-addon-fuzz.test.ts, compile-windows-linked-addon.test.ts, and new test/napi cases (SEH, longjmp, collided unwinds, C++ exceptions, Workers), each run as a DLL and merged.

Background

  • An RVA is an offset from the base of the image that contains it. A merged addon lies inside bun.exe's image, so Windows takes its unwind data from bun.exe's exception directory and calls its handlers with bun.exe's base. The trampoline substitutes the addon's base.
  • A chained unwind record describes a fragment of a function by pointing at the primary function. A collided unwind starts inside an unwind handler, and Windows then calls that handler again with a copy of the earlier context.
  • An MSVC C++ throw takes the base for its type RVAs from RtlPcToFileHeader. node-gyp links the CRT statically, so that call is an import of the addon, which the bind redirects. Addons built on the CRT DLLs stay unmerged.

Related: #30033 takes a different approach (a post-compile slot table, a cache file on Windows). This PR is the in-image merge it lists as not done.

Notes: the merge in detail, the test list, and the history of this PR

Build time, PEFile::add_linked_addon (src/exe_format/pe.rs), for each embedded .node that is a PE32+ image for the target machine:

  • appends one RW section (.bnN) of SizeOfImage bytes with the addon's sections copied in at their own RVAs, so every intra-addon reference rebases by one constant, and applies that constant to the addon's DIR64 relocations
  • records in a new .bunL section: the reloc blocks, the import table (node.exe / bun.exe imports flagged as host imports), the entry point, the napi_register_module_v1 / node_api_module_get_api_version_v1 RVAs, and the exception handlers described below
  • merges the addon's exception directory into bun.exe's own: RtlLookupFunctionEntry only searches the directory of the image containing a pc (tables registered with RtlAddFunctionTable are consulted for code outside every image, and a merged addon is inside bun.exe), so the entries are rebased and appended to a copy of the exe's directory stored in .bunL, which IMAGE_DIRECTORY_ENTRY_EXCEPTION is re-pointed at. Every exception handler the addon's unwind infos name is replaced with the exported Bun__linkedAddonExceptionHandler, and the displaced handler is recorded per unwind info (chained unwind infos resolve to the handler their chain ends in, since a function fragment's table entry names the chained record). A chained record gets read against two bases: bun.exe's by Windows when it looks the frame up, and the addon's by whatever sees the frame through the trampoline (its handler during a collided unwind, a C++ frame handler walking to the primary function). The record in the image serves the first; for the second the build appends, after the addon's image in .bnN, a copy of every chained record whose chain ends in a handler, with its embedded entry left addon-relative, and records per handler entry which record to present
  • skips the addon (its bytes stay in .bun) when it has a nonzero TLS template (real __declspec(thread) / thread_local! storage; there is no userspace API for a loader TLS slot; the MSVC CRT's empty-template directory that nearly every node-gyp addon carries is fine), imports _CxxThrowException (it throws through vcruntime140.dll, whose own RtlPcToFileHeader lookup would report bun.exe's base for the thrown type; addons linked against the static CRT, node-gyp's default, throw fine, see run time below), targets another machine, uses v1 delay-load descriptors, non-DIR64 fixups or stripped relocs, has unwind data the merge does not understand (indirect entries, unknown versions, chains longer than the 32 links ntdll follows), or is malformed in any way (including a SizeOfImage that is not 4-byte aligned, where the appended records would land): every parse failure fails closed
  • BUN_FEATURE_FLAG_DISABLE_PE_ADDON_LINK=1 disables the merge. Two things are not detectable at build time and need it, listed in the LinkedNodeModule.rs module docs: a DllMain relying on DLL_THREAD_ATTACH/DETACH or on DLL_PROCESS_DETACH at exit (a merged addon gets neither, so its atexit handlers and static destructors do not run at process exit), and static initializers that dlopen another merged addon

Run time, src/standalone_graph/LinkedNodeModule.rs, when process.dlopen("B:/~BUN/...") names a merged addon: apply the ASLR delta to the recorded DIR64 slots, bind the IAT (host imports against bun.exe's own export table, which already exports the napi_* / uv_* surface via src/symbols.def; everything else via LoadLibraryA + GetProcAddress; an import of RtlPcToFileHeader, which a static-CRT addon's _CxxThrowException uses to find the image its throw info is relative to, is bound to a shim that reports the addon's base for a pc inside a merged addon and forwards everything else), VirtualProtect each section to its shipped protection and flush the instruction cache, call DllMain(DLL_PROCESS_ATTACH), and hand the export pointers back to BunProcess.cpp, which continues the existing napi flow. The bind runs once per addon under a lock that is handed to the C++ caller until the handle is published, so a concurrent Worker sees either nothing or the finished addon.

Bun__linkedAddonExceptionHandler runs when Windows dispatches an exception or unwind through a merged frame: it looks the displaced handler up in .bunL and calls it with DISPATCHER_CONTEXT.ImageBase set to the addon's base and FunctionEntry naming the record the build chose to present (a chained record's addon-relative copy, otherwise the record itself), which is what the handler's scope tables are relative to. When an unwind collides with one in progress, Windows re-dispatches with a copy of a context this function had already rewritten, so it derives the unwind info from ImageBase plus the entry and forwards such a context unchanged.

Also: initializePESection in c-bindings.cpp compares the full 8-byte section name so .bun no longer matches .bunL; append_section strips an Authenticode overlay before appending, so a skipped addon never touches the host image; exe_image_range() (used by the crash handler to decide whether a first-chance exception is bun's own) now stops at the first appended section, so faults inside merged addon code reach the addon's SEH handlers as they do for DLLs; a merged addon is never treated as a native bundler plugin (there is no module handle to look BUN_PLUGIN_NAME up against), so an exe needing onBeforeParse plugins sets the flag above.

Verified with:

  • test/bundler/pe-linked-addon-adversarial.test.ts (every platform, through a bun:internal-for-testing hook around add_linked_addon): 52 cases covering each skip gate, hostile reloc / import / export / unwind data plus two 256-iteration random byte-mutation passes (whole addon, unwind data), header-slot exhaustion, the exception-directory merge (host entries preserved, second addon appended, handler redirection, chained records and the contents and index entries of their copies, 32 vs. 33 links, self-referential chains), and that every merge still passes validate() while every skip leaves the host byte-identical
  • test/bundler/pe-linked-addon-fuzz.test.ts (every platform): model-based fuzzer. Each iteration generates a random addon (x64 or ARM64; sections, imports and delay imports, relocations, exports, the three TLS shapes, exception tables with plain, handler, chained and packed records) plus a model of it. Valid addons must merge and their merged section (image plus the copies of chained records), .bunL record and the exe's exception directory must equal the model byte for byte; addons with one of the refused conditions must be skipped with the host untouched; corrupted addons may do either but never error. CI runs 40 iterations per mode with a fixed seed; 20000 iterations per mode passed on the debug+ASAN build with the current model (120000 across two seeds on the model before the chained-record copies)
  • test/bundler/compile-windows-linked-addon.test.ts (Windows): compiles a synthetic PE32+ DLL and checks the section table, the .bunL blob (version 3: handler entries carry the record to present) and the exe's exception directory byte for byte, plus the feature flag and the TLS skip
  • test/napi/napi.test.ts: one addon loaded from four Workers and the main thread at once, directly and inside a --compile exe (on Windows this exercises the binder lock hand-off between threads); the --compile fixture asserts on Windows that .bunL / .bn0 exist and that fewer addons are extracted to the temp dir, with a second run under the flag; a new C addon (unwind_addon.c) does __try/__except around an access violation, a three-frame longjmp, and a nested __finally pair whose inner block longjmps out mid-unwind, and the fixture's output is compared loaded as a DLL, merged, and merged-with-flag (the nested __finally case prints 12 natively under both MSVC and clang-cl; it is the case that needs the collided-unwind handling). A second new addon (cxx_eh_addon.cpp, static CRT, exceptions on) throws a std::runtime_error through two frames with destructors, catches it by base class, then throws a user type past a non-matching clause; it is compared the same three ways. Before the RtlPcToFileHeader binding the merged run crashed on it (build 100896), which is the failure mode the module docs used to list as a limitation
  • cargo check for x86_64-pc-windows-msvc and aarch64-pc-windows-msvc; the source lints in test/internal/source-lints

Background, longer form

  • A PE image's exception directory (.pdata) is a sorted table of RUNTIME_FUNCTION entries, one per function, each pointing at unwind info that describes the prolog and optionally names a language-specific exception handler (for C code, __C_specific_handler) plus data for it, such as the __try scope table. longjmp on x64 is implemented with RtlUnwindEx, so it depends on this table as much as SEH does. All addresses in these structures are RVAs, offsets from the containing image's base, which is why a merged addon's handlers have to be shown the addon's base rather than bun.exe's.
  • An MSVC C++ throw (_CxxThrowException) records the base of the image containing the throw info, obtained from RtlPcToFileHeader, in the exception record; the throw info and the catchable-type list are RVAs relative to that base, and the catch side's frame handler resolves them against it to match clauses. With the static CRT that call is an import of the addon itself, which the bind can point elsewhere; with the CRT DLLs it happens inside vcruntime140.dll.
  • Chained unwind info is how a compiler describes a fragment of a function (for instance cold code split off by PGO) by pointing at the primary function's entry; Windows follows the chain itself and hands the handler the fragment's table entry.
  • A collided unwind is a second unwind started while a frame's unwind handler is still running (a __finally block that longjmps or returns); Windows resumes the interrupted frame by re-invoking its handler with a copy of the first dispatch's context.
  • The MSVC CRT gives nearly every DLL a TLS directory with an empty template (tlssup.obj); only a nonzero template means the addon actually has thread-local storage that would need a loader-assigned slot.
  • bun build --compile stores the bundled files in a .bun section appended to a copy of bun.exe; .bunL and the .bnN sections are appended in front of it by the same mechanism.

History

Originally implemented in Zig and re-done in Rust after the Rust rewrite landed on main, keeping the C ABI to BunProcess.cpp, the skip gates and the tests. Later merges of main adapted to upstream drift: bun_threading::Guarded became RAII-only, so the lock hand-off uses a bare Mutex plus an UnsafeCell touched only under it; strip_authenticode became crate-private, so append_section strips the overlay itself; PEFile::validate was removed upstream as unused and is restored for the adversarial suite. A later round factored the shared pieces out (reserve_section_headers, next_section_placement + append_section, the reloc / export / TLS helpers) and dropped the unused BUN_PLUGIN_NAME plumbing.

The first version registered each addon's .pdata at run time with RtlAddFunctionTable. A probe on Windows Server 2019 showed that registration is ignored for addresses inside the exe image, which broke __except, longjmp and stack walks through merged addon code; that is what the build-time exception-directory merge and the handler trampoline replaced it with. The next round then fixed the trampoline for chained unwind entries and collided unwinds and bounded the chain walk. The latest merge of main (6838c61) had one conflict, in the pe::Error enum: main removed the unused InputIsSigned / InsufficientSpace variants next to where this PR adds BadFunctionTable; resolved by taking main's removals and keeping the variants this PR uses. Re-verified after the merge: the adversarial suite on Linux, and the compile test plus the napi --compile / unwind_addon cases on Windows. A further merge of main (4198cfe) was clean.

The round after that added the model-based fuzzer and the Worker tests, then made the C++ exception fixture part of the unwind test: it crashed when merged (the /MT throw limitation the docs listed), which led to the RtlPcToFileHeader binding, and the review of the collided-unwind path for function fragments led to the appended copies of chained records and blob version 3. The last commit turned the appendix alignment assumption into a skip gate.


no test proof · iteration 31 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/compile-windows-linked-addon.test.ts test/napi/napi.test.ts

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants