windows: statically merge .node addons into --compile output - #30036
Open
robobun wants to merge 63 commits into
Open
windows: statically merge .node addons into --compile output#30036robobun wants to merge 63 commits into
robobun wants to merge 63 commits into
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
bun build --compileembeds.nodeaddons as bytes.process.dlopenwrites each one to a temp file, loads it withLoadLibraryExWand schedules a delete at reboot. Every run leaves a file and aPendingFileRenameOperationsentry behind (Process_functionDlopen,src/jsc/bindings/BunProcess.cpp).Fix
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.bunLsection. An addon the merge does not understand is skipped and keeps the old path.src/standalone_graph/LinkedNodeModule.rs):process.dlopenapplies the ASLR delta, binds the IAT, restores the page protections, callsDllMainand returns the exports. A failed bind falls back to the temp file.BUN_FEATURE_FLAG_DISABLE_PE_ADDON_LINK=1disables the merge.LoadLibrarywould 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.test/bundler/pe-linked-addon-adversarial.test.ts(52 cases),pe-linked-addon-fuzz.test.ts,compile-windows-linked-addon.test.ts, and newtest/napicases (SEH,longjmp, collided unwinds, C++ exceptions, Workers), each run as a DLL and merged.Background
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.nodethat is a PE32+ image for the target machine:.bnN) ofSizeOfImagebytes 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'sDIR64relocations.bunLsection: the reloc blocks, the import table (node.exe/bun.exeimports flagged as host imports), the entry point, thenapi_register_module_v1/node_api_module_get_api_version_v1RVAs, and the exception handlers described belowRtlLookupFunctionEntryonly searches the directory of the image containing a pc (tables registered withRtlAddFunctionTableare 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, whichIMAGE_DIRECTORY_ENTRY_EXCEPTIONis re-pointed at. Every exception handler the addon's unwind infos name is replaced with the exportedBun__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.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 ownRtlPcToFileHeaderlookup 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-DIR64fixups 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 aSizeOfImagethat is not 4-byte aligned, where the appended records would land): every parse failure fails closedBUN_FEATURE_FLAG_DISABLE_PE_ADDON_LINK=1disables the merge. Two things are not detectable at build time and need it, listed in theLinkedNodeModule.rsmodule docs: aDllMainrelying onDLL_THREAD_ATTACH/DETACHor onDLL_PROCESS_DETACHat exit (a merged addon gets neither, so itsatexithandlers and static destructors do not run at process exit), and static initializers thatdlopenanother merged addonRun time,
src/standalone_graph/LinkedNodeModule.rs, whenprocess.dlopen("B:/~BUN/...")names a merged addon: apply the ASLR delta to the recordedDIR64slots, bind the IAT (host imports against bun.exe's own export table, which already exports thenapi_*/uv_*surface viasrc/symbols.def; everything else viaLoadLibraryA+GetProcAddress; an import ofRtlPcToFileHeader, which a static-CRT addon's_CxxThrowExceptionuses 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),VirtualProtecteach section to its shipped protection and flush the instruction cache, callDllMain(DLL_PROCESS_ATTACH), and hand the export pointers back toBunProcess.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__linkedAddonExceptionHandlerruns when Windows dispatches an exception or unwind through a merged frame: it looks the displaced handler up in.bunLand calls it withDISPATCHER_CONTEXT.ImageBaseset to the addon's base andFunctionEntrynaming 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 fromImageBaseplus the entry and forwards such a context unchanged.Also:
initializePESectioninc-bindings.cppcompares the full 8-byte section name so.bunno longer matches.bunL;append_sectionstrips 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 lookBUN_PLUGIN_NAMEup against), so an exe needingonBeforeParseplugins sets the flag above.Verified with:
test/bundler/pe-linked-addon-adversarial.test.ts(every platform, through abun:internal-for-testinghook aroundadd_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 passesvalidate()while every skip leaves the host byte-identicaltest/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),.bunLrecord 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.bunLblob (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 skiptest/napi/napi.test.ts: one addon loaded from four Workers and the main thread at once, directly and inside a--compileexe (on Windows this exercises the binder lock hand-off between threads); the--compilefixture asserts on Windows that.bunL/.bn0exist 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/__exceptaround an access violation, a three-framelongjmp, and a nested__finallypair whose inner blocklongjmps out mid-unwind, and the fixture's output is compared loaded as a DLL, merged, and merged-with-flag (the nested__finallycase prints12natively 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 astd::runtime_errorthrough 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 theRtlPcToFileHeaderbinding the merged run crashed on it (build 100896), which is the failure mode the module docs used to list as a limitationcargo checkforx86_64-pc-windows-msvcandaarch64-pc-windows-msvc; the source lints intest/internal/source-lintsBackground, longer form
.pdata) is a sorted table ofRUNTIME_FUNCTIONentries, 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__tryscope table.longjmpon x64 is implemented withRtlUnwindEx, 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.throw(_CxxThrowException) records the base of the image containing the throw info, obtained fromRtlPcToFileHeader, 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.__finallyblock thatlongjmps or returns); Windows resumes the interrupted frame by re-invoking its handler with a copy of the first dispatch's context.tlssup.obj); only a nonzero template means the addon actually has thread-local storage that would need a loader-assigned slot.bun build --compilestores the bundled files in a.bunsection appended to a copy of bun.exe;.bunLand the.bnNsections 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::Guardedbecame RAII-only, so the lock hand-off uses a bareMutexplus anUnsafeCelltouched only under it;strip_authenticodebecame crate-private, soappend_sectionstrips the overlay itself;PEFile::validatewas 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 unusedBUN_PLUGIN_NAMEplumbing.The first version registered each addon's
.pdataat run time withRtlAddFunctionTable. A probe on Windows Server 2019 showed that registration is ignored for addresses inside the exe image, which broke__except,longjmpand 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 thepe::Errorenum: main removed the unusedInputIsSigned/InsufficientSpacevariants next to where this PR addsBadFunctionTable; 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_addoncases 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
/MTthrow limitation the docs listed), which led to theRtlPcToFileHeaderbinding, 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