napi: stub link slots for post-compile .node injection - #30033
Conversation
|
Updated 3:59 PM PT - Aug 18th, 2026
✅ @robobun, your commit a2c27172a0b08990b1eca294f9940824a8919eff passed in 🧪 To try this PR locally: bunx bun-pr 30033That installs a local version of the PR into your bun-30033 --bun |
|
Found 9 issues this PR may fix:
🤖 Generated with Claude Code |
92adce4 to
7b9f184
Compare
|
CI triage for build 52113 (sha
All three are ambient flakes unrelated to this branch (NAPI / Mach-O / |
7b9f184 to
aa51ac8
Compare
|
CI triage for build 61202 (sha The only failing test is The diff itself is green: |
fd33334 to
b485076
Compare
| /// Locate a `(segname, sectname)` pair and return its file offset and | ||
| /// on-disk size. Unlike `write_section`, this does not assume the | ||
| /// `__BUN,__bun` layout and does not mutate anything — it's the hook for | ||
| /// external patchers (e.g. the NAPI link slot table lives in | ||
| /// `__DATA,__bun_napi_lnk` and is overwritten in place). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Borrow the on-disk bytes of a section previously returned by | ||
| /// `find_section`. Returns `None` if the location (which came from | ||
| /// untrusted load commands) does not fit inside the file. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Mutable counterpart of `section_bytes`, for in-place patching of | ||
| /// fixed-size tables (no load-command offsets change). Callers must | ||
| /// re-`find_section` after any `write_section*` call since the backing | ||
| /// buffer may have been reallocated and later sections shifted. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Same as `write_section` but the `u64` size header written at the | ||
| /// section's first 8 bytes is `header_value` instead of `data.len()`. The | ||
| /// NAPI link-slot appender uses this to keep the header pointing at the | ||
| /// module-graph payload length (so `StandaloneModuleGraph.fromExecutable` | ||
| /// still finds its trailer) while tucking addon images past it in the | ||
| /// same section. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // NAPI link-slot addons are loaded from memory rather than via a | ||
| // filesystem path. On macOS the handle is an `NSModule` (from | ||
| // `NSLinkModule`), which needs `NSLookupSymbolInModule` in place of | ||
| // `dlsym`; on Linux it's an ordinary `dlopen()` handle sourced from a | ||
| // memfd. `slotHandle` short-circuits the path-based `dlopen()` below. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Not a link slot — fall through to the module-graph tmpfile | ||
| // extractor for bundler-embedded addons. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Path matched a slot but the in-memory load failed (e.g. the | ||
| // embedded image isn't a valid Mach-O bundle). Surface that as a | ||
| // dlopen error rather than trying to find it on disk. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // A glibc-linked addon loaded into a musl process segfaults inside the | ||
| // loader (gcompat provides the soname but not the ABI). Inspect the ELF | ||
| // DT_NEEDED list first so the user sees a catchable error instead of a | ||
| // crash report. Skipped for addons embedded via `bun build --compile`. | ||
| // See https://github.com/oven-sh/bun/issues/15753. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Symbol lookup that routes NSModule handles (from in-memory NAPI link | ||
| // slots on macOS) through `NSLookupSymbolInModule` instead of `dlsym`. | ||
| // Every other handle — including Linux memfd-backed link slots — is a | ||
| // plain dlopen/LoadLibrary handle. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Link-slot handles are memoised per process and native addons | ||
| // can't be unloaded, so leave those in place; closing them would | ||
| // also strand the cached entry in loaded_handles[]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // ----------------------------------------------------------------------------- | ||
| // NAPI link slots: a fixed table of stub native-addon loaders that can be | ||
| // filled in *after* `bun build --compile` without rebundling. Each slot points | ||
| // at a `.node` image appended into the __BUN,__bun / .bun section (after the | ||
| // standalone module graph payload). At runtime `process.dlopen` on a | ||
| // `/$bunfs/...` path checks this table first; on Linux the slice is handed to | ||
| // dlopen() via memfd (/proc/self/fd), and on other platforms it's written once | ||
| // to a content-hashed cache path so repeated launches don't re-extract. | ||
| // | ||
| // The table lives in its own section so an external linker tool can locate it | ||
| // by name (or by scanning for the per-slot magic) and stamp offset/length/ | ||
| // hash/path in place — no need to understand the module-graph serialization. | ||
| // 256 bytes per slot keeps the math trivial for such tools. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Base pointer that slot offsets are measured from. On Mach-O and ELF this is | ||
| // the address of the BUN_COMPILED symbol (start of the __BUN,__bun / .bun | ||
| // section, where the u64 size header lives). On Windows it is the start of the | ||
| // .bun PE section, looked up at runtime. Declared per-platform below. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // In-memory Mach-O bundle loader for NAPI link slots. `dyld` has no API to | ||
| // `dlopen()` a dylib from an offset inside another file, so we hand it the | ||
| // embedded bytes via the (deprecated-but-still-exported) NSObjectFileImage | ||
| // path. On modern dyld this routes through dyld's own private temp-file | ||
| // shim so code-signing still works on arm64, but from bun's side it's a | ||
| // pure pointer+length call — we never create a `.node` on disk ourselves. | ||
| // | ||
| // The returned `NSModule` is used as the "handle" in place of a `dlopen()` | ||
| // result; `Process_functionDlopen` switches symbol lookup to | ||
| // `NSLookupSymbolInModule` when the handle came from here. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // dyld may patch the image (e.g. slide fixups) and takes ownership of the | ||
| // buffer on success, so give it a private writable copy. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `NSCreateObjectFileImageFromMemory` only accepts `MH_BUNDLE`. node-gyp | ||
| // builds `.node` addons as bundles already, but if someone hands us an | ||
| // `MH_DYLIB` the load-command layout is identical, so flip the filetype. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `NSLINKMODULE_OPTION_PRIVATE` keeps the addon's symbols out of the | ||
| // global namespace (mirrors `RTLD_LOCAL`, which is what `process.dlopen` | ||
| // gets from `RTLD_LAZY` by default). `RETURN_ON_ERROR` stops dyld from | ||
| // aborting the process on an unresolved import. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // On success dyld has either taken ownership of `copy` or duplicated it; | ||
| // on failure the `NSDestroyObjectFileImage` above released it. Either way | ||
| // we must not free it here. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Mach-O exports carry a leading underscore that `dlsym` strips for you; | ||
| // `NSLookupSymbolInModule` does not. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // On ELF BUN_COMPILED.size holds the vaddr of the appended payload, not | ||
| // the payload itself, so slot offsets are measured from that vaddr (which | ||
| // is where the u64 length + module-graph data live). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Slot offsets are measured from the start of the section (the u64 size | ||
| // header), matching Mach-O. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Current payload (without the u64 header) is `graph ++ prior napi | ||
| // images`. The section's filesize may be padded past the last byte we | ||
| // care about, but those padding bytes are zero; copying them is harmless | ||
| // and keeps previously-linked addons intact. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Pad so the addon image starts on a 16 KiB boundary within the section — | ||
| // matches the section alignment and gives the loader a page-aligned | ||
| // source. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Rewrite the section. The header must keep pointing at the module graph | ||
| // length, not the combined length. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Stamp the first free slot. The slot table is fixed-size inside | ||
| // `__DATA,__bun_napi_lnk` so this is a straight overwrite that doesn't | ||
| // shift any load commands — but it must happen *after* | ||
| // `write_section_with_header` has finished shuffling bytes around, or | ||
| // we'd be editing stale memory. `__DATA` sits before `__BUN` in the | ||
| // file, so its offset is unaffected by the shift. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Slot offsets are measured from the start of the section (the u64 | ||
| // header), so account for the 8-byte header `write_section_with_header` | ||
| // places before `new_payload`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `header_value` is what the runtime reads back as the payload length; | ||
| /// it may be shorter than `data` when trailing bytes (linked addons) are | ||
| /// not part of the module graph. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Set when the addon came from a link slot (see napi_link.rs); it is | ||
| // already loaded, so the dlopen() below is skipped. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // NAPI link slots. Layout is ABI shared with src/standalone_graph/napi_link.rs | ||
| // (which documents the scheme) and with external patch tools; the table gets | ||
| // its own section so those tools can find it by name. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Start of the .bun section (what BunNapiLinkSlot::offset is relative to); | ||
| // defined per platform below. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // dlopen() cannot load an image from a byte range, so link slots go through | ||
| // the deprecated NSObjectFileImage API. The result is an NSModule, not a | ||
| // dlopen handle; Process_functionDlopen uses Bun__darwinLookupSymbolInModule | ||
| // on it instead of dlsym. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `Bun.unsafe.napiLinkSlots()`: the running binary's link-slot table as | ||
| /// `{ index, used, path, offset, length, hash }[]`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `Bun.unsafe.linkNapiModule(exePath, addonPath, virtualPath, outPath)`; | ||
| /// see `napi_link::link_into_macho`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| //! NAPI link slots: a fixed table of stub addon loaders baked into the bun | ||
| //! binary (`BUN_NAPI_LINK_SLOTS` in `c-bindings.cpp`), so a `.node` can be | ||
| //! appended to a `bun build --compile` executable after the fact without | ||
| //! rebundling. The addon image is stored in the `__BUN,__bun` / `.bun` | ||
| //! section past the module-graph payload; the slot records its offset and the | ||
| //! `/$bunfs/` path `process.dlopen` will ask for. Matching slots are loaded | ||
| //! from memory (`NSLinkModule` on macOS, memfd on Linux), never extracted to | ||
| //! disk. Only the Mach-O patcher exists so far. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Per-slot handle cache; native addons are never unloaded, so a second | ||
| /// `require()` must hand `Process_functionDlopen` the same handle for its | ||
| /// `DLHandleMap` replay. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `fd` is intentionally kept open: `/proc/self/fd/N` must stay valid for | ||
| // as long as the module is mapped. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Returns whether `path` names a link slot. On `true`, `*out_handle` is the | ||
| /// loaded module or null if loading failed; the caller must not fall back to | ||
| /// the module-graph extractor in either case. `*out_is_ns_module` means the | ||
| /// handle is an `NSModule` (use `NSLookupSymbolInModule`, not `dlsym`). | ||
| /// | ||
| /// # Safety | ||
| /// `path_ptr[..path_len]` must be readable; the out-pointers must be valid | ||
| /// for writes. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Append `addon_bytes` to the `__BUN,__bun` section of a compiled Mach-O | ||
| /// executable, stamp the first free slot with its location and | ||
| /// `virtual_path`, and return the re-signed image. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Everything after the size header (module graph plus any previously | ||
| // linked addons) is carried over verbatim; the new image is appended on a | ||
| // 16 KiB boundary. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // The size header must still describe only the module graph, or the | ||
| // runtime's trailer check lands on the addon bytes instead. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
|
Rebased onto main ( Conflicts resolved:
Also trimmed the comments in response to the automated review (net -136 lines): narration removed, design notes consolidated into the Verified after rebase: |
247bb8e to
f50eb9c
Compare
|
Status for build 95841 (
The diff is ready for review as it stands. |
f50eb9c to
f4fd604
Compare
|
Rebased onto main ( Conflict resolved in Heads-up on CI: main does not currently build. #37301 removed
Expect this PR's CI to be red at build-bun until the |
| // The extracted file is content-hashed and shared across dlopens | ||
| // and restarts (#29587), so it is never deleted here. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Adds a fixed 8-entry table of 'NAPI link slots' to the bun binary in
its own section (__DATA,__bun_napi_lnk on Mach-O, .bun_napi_link on
ELF, .bnapi on PE). Each 256-byte slot is {magic, offset, length,
hash, path[224]} and can be binary-patched by an external tool to
point at a .node image appended into the __BUN,__bun / .bun section
after bun build --compile has already run, no rebundle required.
Runtime: process.dlopen on a /$bunfs/ path checks the slot table
before the per-launch tmpfile extraction. A match is loaded from
memory: NSCreateObjectFileImageFromMemory + NSLinkModule on macOS
(MH_DYLIB inputs are flipped to MH_BUNDLE; symbol lookup routes
through NSLookupSymbolInModule), memfd_create + /proc/self/fd/N
dlopen on Linux. Handles are memoised per slot.
Patcher: Bun.unsafe.linkNapiModule(exe, addon, virtualPath, out)
appends the addon after the module-graph payload (keeping the u64
header pointing at the graph length so fromExecutable's trailer check
still lands), stamps the first free slot, and re-signs. MachoFile
gains find_section() and write_section_with_header().
Bun.unsafe.napiLinkSlots() dumps the running binary's table.
Implementation is Rust (bun_standalone_graph::napi_link) following
the Zig-to-Rust migration; originally developed as
src/napi/napi_link.zig before the port.
…vate Main made MachoFile's fields pub(crate). Add bounds-checked section_bytes()/section_bytes_mut() accessors and rewrite the slot stamping in link_into_macho against them instead of indexing the buffer directly.
Drop narration and consolidate the design notes into the napi_link.rs module doc; keep only the invariants a reader cannot recover from the code (NSObjectFileImage buffer ownership, the leading-underscore symbol convention, why the .bun size header stays at the graph length, slot table lookup ordering). Also removes a stale reference to the removed cache-file path.
f4fd604 to
a2c2717
Compare
Prototype for appending a
.nodeaddon to abun build --compileexecutable after it's been built, without re-running the bundler. The addon bytes ride inside the existing__BUN,__bunsection and are loaded from memory at runtime: bun never writes a.nodeto disk.What
BunNapiLinkSlot[8]array (256 B each:{magic, offset, length, hash, path[224]}) lives in its own section (__DATA,__bun_napi_lnkon Mach-O,.bun_napi_linkon ELF,.bnapion PE) so an external patcher can find it by section name, or by scanning for the per-slot"bunlink\0"magic, and stamp offset/length/hash/path in place without understanding the module-graph serialization.process.dlopen("/\$bunfs/...")consults the slot table before the existing per-launch tmpfile extraction. A hit is loaded from memory:NSCreateObjectFileImageFromMemorythenNSLinkModule.MH_DYLIBinputs are flipped toMH_BUNDLE(identical load-command layout) since the API only accepts bundles.Process_functionDlopenroutes symbol lookup throughNSLookupSymbolInModuleinstead ofdlsymwhen the handle came from here; static-constructornapi_module_registerstill fires duringNSLinkModuleso that path is unchanged.memfd_create(MFD_EXEC), write the slice,dlopen("/proc/self/fd/N"). The fd is kept for the process lifetime.require()returns the same module instance via the existingDLHandleMap.Bun.unsafe.linkNapiModule(exePath, addonPath, virtualPath, outPath)parses the compiled Mach-O, appends the.nodeimage into__BUN,__bunafter the module-graph payload (the u64 size header keeps pointing at the graph length soStandaloneModuleGraph::from_executable's trailer check still lands), stamps the first free slot with{offset = 8 + aligned(graph_len), length, hash, path}, and re-signs.MachoFilegainsfind_section()andwrite_section_with_header().Bun.unsafe.napiLinkSlots()dumps the running binary's table.Example
Verification
test/napi/napi-link-slots.test.tslinkNapiModulerejects non-Mach-O inputcompile -> linkNapiModule -> runround-trip usingsecond_addon.node; checks the slot shows up as used andprocess.dlopenreachesnapi_register_module_v1readelf -Son a compiled binary shows.bun_napi_linkadjacent to.bun; the binary reports 8 unused slotsbun run rust:check-allclean on all targetsNot done yet
bun build --compileitself (so bundler-embedded.nodefiles would also get the in-memory path instead of per-launch tmpfiles)no test proof · iteration 17 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi-link-slots.test.ts