Skip to content

napi: stub link slots for post-compile .node injection - #30033

Draft
robobun wants to merge 4 commits into
mainfrom
farm/c0f407bb/napi-link-slots
Draft

napi: stub link slots for post-compile .node injection#30033
robobun wants to merge 4 commits into
mainfrom
farm/c0f407bb/napi-link-slots

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Prototype for appending a .node addon to a bun build --compile executable after it's been built, without re-running the bundler. The addon bytes ride inside the existing __BUN,__bun section and are loaded from memory at runtime: bun never writes a .node to disk.

Note: originally implemented in Zig; rewritten in Rust (src/standalone_graph/napi_link.rs) after the Zig-to-Rust migration landed on main. The C++ side (c-bindings.cpp, BunProcess.cpp) and the test carried over; MachoFile::find_section / write_section_with_header / section_bytes[_mut] were added to the Rust bun_exe_format crate, and the Bun.unsafe host functions use the #[bun_jsc::host_fn] API. The FreeBSD cache-file fallback from the Zig version was dropped (no ELF patcher exists, so slots can never be populated there). Later rebases merged two upstream changes to the same dlopen block: the glibc-on-musl check (#15753) is skipped for slot-loaded addons, which have no on-disk path, and the post-dlopen tmpfile deletion removed by #29587 is gone from the slot branch as well, so bundler-embedded addons keep #29587's content-hashed shared file.

What

  • Slot table baked into bun. A fixed BunNapiLinkSlot[8] array (256 B each: {magic, offset, length, hash, path[224]}) lives in its own section (__DATA,__bun_napi_lnk on Mach-O, .bun_napi_link on ELF, .bnapi on 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.
  • In-memory stub loader. process.dlopen("/\$bunfs/...") consults the slot table before the existing per-launch tmpfile extraction. A hit is loaded from memory:
    • macOS: NSCreateObjectFileImageFromMemory then NSLinkModule. MH_DYLIB inputs are flipped to MH_BUNDLE (identical load-command layout) since the API only accepts bundles. Process_functionDlopen routes symbol lookup through NSLookupSymbolInModule instead of dlsym when the handle came from here; static-constructor napi_module_register still fires during NSLinkModule so that path is unchanged.
    • Linux: memfd_create(MFD_EXEC), write the slice, dlopen("/proc/self/fd/N"). The fd is kept for the process lifetime.
    • Windows/FreeBSD: no loader yet; also no patcher for PE/ELF, so slots cannot be populated there today.
    • Handles are memoised per slot; repeated require() returns the same module instance via the existing DLHandleMap.
  • Mach-O post-link patcher. Bun.unsafe.linkNapiModule(exePath, addonPath, virtualPath, outPath) parses the compiled Mach-O, appends the .node image into __BUN,__bun after the module-graph payload (the u64 size header keeps pointing at the graph length so StandaloneModuleGraph::from_executable's trailer check still lands), stamps the first free slot with {offset = 8 + aligned(graph_len), length, hash, path}, and re-signs. MachoFile gains find_section() and write_section_with_header().
  • Inspection. Bun.unsafe.napiLinkSlots() dumps the running binary's table.

Example

// After: bun build --compile app.ts --outfile app
Bun.unsafe.linkNapiModule("./app", "./addon.node", "/$bunfs/linked.node", "./app-linked");

// Inside app.ts:
const m = { exports: {} };
process.dlopen(m, "/$bunfs/linked.node");  // resolves via slot 0, loaded from memory

Verification

  • test/napi/napi-link-slots.test.ts
    • all platforms: 8 empty slots exposed, linkNapiModule rejects non-Mach-O input
    • macOS: full compile -> linkNapiModule -> run round-trip using second_addon.node; checks the slot shows up as used and process.dlopen reaches napi_register_module_v1
  • readelf -S on a compiled binary shows .bun_napi_link adjacent to .bun; the binary reports 8 unused slots
  • bun run rust:check-all clean on all targets

Not done yet

  • ELF/PE post-link patchers (runtime slot table and sections exist everywhere; only the Mach-O writer is implemented)
  • Auto-populating slots from bun build --compile itself (so bundler-embedded .node files 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

@github-actions github-actions Bot added the claude label May 1, 2026
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:59 PM PT - Aug 18th, 2026

@robobun, your commit a2c27172a0b08990b1eca294f9940824a8919eff passed in Build #100801! 🎉


🧪   To try this PR locally:

bunx bun-pr 30033

That installs a local version of the PR into your bun-30033 executable, so you can run:

bun-30033 --bun

@github-actions

github-actions Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Found 9 issues this PR may fix:

  1. Bun build --compile fails when including libraries that have native modules (DuckDB) #17312 - Native modules (DuckDB) fail with bun build --compile; link slots enable post-compile native addon injection
  2. Cannot find module "client.node" using bun build with @tigerbeetle/tigerbeetle-node #9951 - Cannot find client.node in compiled executable (@tigerbeetle); link slots provide a mechanism to embed and load .node files
  3. Requiering a node binary from a single-file executable with embed directories fails #13684 - Requiring a .node binary from a single-file executable with embed directories fails; directly addressed by link slot loading
  4. error: Cannot find module './tailwindcss-oxide.darwin-arm64.node' from '/$bunfs/root/myapp' #21228 - tailwindcss-oxide.darwin-arm64.node not found in compiled binary (/$bunfs/); slot system and ModuleLoader fallback path address this
  5. "bun build" does not embed binaries from node_modules correctly #15374 - Native .node binaries (sharp) not embedded/loaded correctly in compiled executables
  6. bun build embedded shared library doesn't work in macOS. #14009 - Embedded shared library (.dylib) doesn't work in compiled macOS binary; PR adds Mach-O section manipulation and runtime loading
  7. bun build can't compile @libsql #18909 - @libsql native .node addon fails in compiled binary on other machines; link slots enable post-compile injection
  8. In bun 1.3.4, sharp paackage is failing at build #25395 - Sharp native .node addon failing in compiled builds
  9. bun build --compile produces a binary that only works on my machine #24470 - Compiled binary only works on build machine due to missing native modules (lzma .node); link slots allow embedding in the binary

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17312
Fixes #9951
Fixes #13684
Fixes #21228
Fixes #15374
Fixes #14009
Fixes #18909
Fixes #25395
Fixes #24470

🤖 Generated with Claude Code

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 52113 (sha 7b9f184a, complete):

failing test lane also failing on unrelated PR builds
test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts win x64/x64-baseline/aarch64 52104, 52105, 52107, 52109, 52110, 52112
test/cli/hot/hot.test.ts (sourcemap reload count) win aarch64 52112
test/js/bun/s3/s3-storage-class.test.ts (S3Error: UnknownError) darwin aarch64 52107

All three are ambient flakes unrelated to this branch (NAPI / Mach-O / process.dlopen). test/napi/napi-link-slots.test.ts passed on every lane — Linux, FreeBSD, Windows, and both macOS x64 + aarch64, including the full compile → linkNapiModule → NSLinkModule → napi_register_module_v1 round-trip.

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build 61202 (sha fd33334e, the Rust port):

The only failing test is test/cli/install/bunx.test.ts ("should handle package that requires node 24", exit 3). It fails identically on every recent build in the queue regardless of branch (61195, 61196, 61199, 61200 — unrelated branches), so it's an ambient breakage on main (version-gated registry package), not something this PR touches. test/napi/napi-link-slots.test.ts has no failure on any lane.

The diff itself is green: rust:check-all passes on all targets, the slot table / in-memory loader / Mach-O patcher tests pass, and the compile smoke test shows the .bun_napi_link section with 8 empty slots. Ready for review once the bunx breakage is fixed on main; a rebase will pick that up.

@robobun
robobun force-pushed the farm/c0f407bb/napi-link-slots branch from fd33334 to b485076 Compare August 14, 2026 01:04
Comment thread src/exe_format/macho.rs Outdated
Comment on lines +94 to +98
/// 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).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/exe_format/macho.rs Outdated
Comment on lines +136 to +138
/// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/exe_format/macho.rs Outdated
Comment on lines +145 to +148
/// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/exe_format/macho.rs Outdated
Comment on lines +159 to +164
/// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment on lines +480 to +484
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment on lines +490 to +491
// Not a link slot — fall through to the module-graph tmpfile
// extractor for bundler-embedded addons.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment on lines +499 to +501
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +584 to +588
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment on lines +765 to +768
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment on lines +788 to +790
// 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[].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1082 to +1094
// -----------------------------------------------------------------------------
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1133 to +1136
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1165 to +1174
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1184 to +1185
// dyld may patch the image (e.g. slide fixups) and takes ownership of the
// buffer on success, so give it a private writable copy.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1190 to +1192
// `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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1203 to +1206
// `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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1210 to +1212
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1219 to +1220
// Mach-O exports carry a leading underscore that `dlsym` strips for you;
// `NSLookupSymbolInModule` does not.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1240 to +1242
// 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).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment on lines +1304 to +1305
// Slot offsets are measured from the start of the section (the u64 size
// header), matching Mach-O.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/standalone_graph/napi_link.rs Outdated
Comment on lines +302 to +305
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/standalone_graph/napi_link.rs Outdated
Comment on lines +308 to +310
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/standalone_graph/napi_link.rs Outdated
Comment on lines +318 to +319
// Rewrite the section. The header must keep pointing at the module graph
// length, not the combined length.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/standalone_graph/napi_link.rs Outdated
Comment on lines +327 to +332
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/standalone_graph/napi_link.rs Outdated
Comment on lines +352 to +354
// 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`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/exe_format/macho.rs
Comment on lines +149 to +151
/// `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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +480 to +481
// Set when the addon came from a link slot (see napi_link.rs); it is
// already loaded, so the dlopen() below is skipped.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1082 to +1084
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1123 to +1124
// Start of the .bun section (what BunNapiLinkSlot::offset is relative to);
// defined per platform below.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1152 to +1155
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +84 to +85
/// `Bun.unsafe.napiLinkSlots()`: the running binary's link-slot table as
/// `{ index, used, path, offset, length, hash }[]`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +129 to +130
/// `Bun.unsafe.linkNapiModule(exePath, addonPath, virtualPath, outPath)`;
/// see `napi_link::link_into_macho`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1 to +8
//! 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +96 to +98
/// Per-slot handle cache; native addons are never unloaded, so a second
/// `require()` must hand `Process_functionDlopen` the same handle for its
/// `DLHandleMap` replay.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +160 to +161
// `fd` is intentionally kept open: `/proc/self/fd/N` must stay valid for
// as long as the module is mapped.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +176 to +183
/// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +217 to +219
/// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +250 to +252
// 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.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +260 to +261
// The size header must still describe only the module graph, or the
// runtime's trailer check lands on the addon bytes instead.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (a22baa1a) and force-pushed; now at 247bb8e3.

Conflicts resolved:

  • BunProcess.cpp: main added a glibc-on-musl DT_NEEDED check (Issues with running embeddings JS packages in alpine #15753) in the same region as the link-slot branch. Merged so the check is skipped for both bundler-embedded and slot-loaded addons (neither has an on-disk path to inspect); the path-based dlopen() and that check both sit in the !slotHandle branch.
  • macho.rs: main made MachoFile's fields pub(crate). Added bounds-checked section_bytes() / section_bytes_mut() accessors and rewrote the slot stamping against them rather than re-widening the field.

Also trimmed the comments in response to the automated review (net -136 lines): narration removed, design notes consolidated into the napi_link.rs module doc, stale reference to the removed cache-file path deleted. What remains is // SAFETY: annotations and one-to-three-line notes on invariants that are not recoverable 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 after the section rewrite). The bot flags those as well, but removing them would drop information a reader needs, so they stay.

Verified after rebase: rust:check-all clean on all targets; test/napi/napi-link-slots.test.ts passes; test/napi/napi.test.ts -t "bundle .node files" (exercises the merged dlopen path for regular and bundler-embedded addons) passes.

@robobun
robobun force-pushed the farm/c0f407bb/napi-link-slots branch from 247bb8e to f50eb9c Compare August 14, 2026 09:55
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status for build 95841 (f50eb9c3, rebased onto current main):

  • 177 lanes finished, all passing. No error annotations. test/napi/napi-link-slots.test.ts is green on every lane that has run.
  • Five tests failed once and passed on retry (bun-install-registry, inspect-error-leak, AsyncLocalStorage-tracking, test-cluster-shared-leak, worker-terminate-funnels); none are related to this change.
  • The two darwin 14 aarch64 test jobs have not run yet: they have expired and been re-queued three times because the release-tier=previous arm64 agent pool (six VMs) is working through a large backlog of earlier builds. Those lanes carry the macOS compile -> linkNapiModule -> dlopen round-trip, which did pass on darwin aarch64 in the previous builds of this PR (52113, 61202). Nothing to change here; they will run when the queue drains.

The diff is ready for review as it stands.

@robobun
robobun force-pushed the farm/c0f407bb/napi-link-slots branch from f50eb9c to f4fd604 Compare August 18, 2026 22:23
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (f09ed673, 250 commits) and force-pushed; now at f4fd6041.

Conflict resolved in BunProcess.cpp: #29587 (dedupe extracted embedded native modules) removed the deleteAfter / tryToDeleteIfNecessary() machinery, which the link-slot branch had been threading through. Resolution keeps #29587's behavior: the module-graph fallback no longer sets a delete flag, and the slot branch's dlopen() path no longer calls the removed helper. The slot logic itself is unchanged. Also confirmed the hand-written extern "C" declaration for Bun__tryLoadNapiLinkSlot matches the Rust definition argument-for-argument (relevant after #38943).

Heads-up on CI: main does not currently build. #37301 removed bun_runtime's bun_css dependency, which pm_diff_normalize.rs from #39229 uses (five E0433 errors; main's own builds #100750 and #100756 fail at build-bun the same way). That break is already tracked separately. To verify this rebase I applied the one-line dependency re-add locally, built, and ran the tests, then dropped it before pushing so this PR stays scoped:

Expect this PR's CI to be red at build-bun until the bun_css fix lands on main; I will rebase again once it does.

Comment on lines +486 to +487
// The extracted file is content-hashed and shared across dlopens
// and restarts (#29587), so it is never deleted here.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

robobun and others added 4 commits August 18, 2026 22:27
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.
@robobun
robobun force-pushed the farm/c0f407bb/napi-link-slots branch from f4fd604 to a2c2717 Compare August 18, 2026 22:29
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.

1 participant