module loader: don't abort on .node specifiers with a query string - #33148
Conversation
The loader classifier strips the ?query from a module key before mapping
the extension, so /x/addon.node?v=1 classifies as Loader::Napi. The
checks that divert NAPI modules away from the transpiler run on the raw
key via endsWith(".node"), so a query-suffixed specifier slipped past
them into the transpiler, whose NAPI arm was unreachable!(). The same
arm is also reachable with no query at all through --loader <ext>:napi.
- Turn the Loader::Napi transpiler arm into the documented TypeError so
no specifier spelling or loader configuration can abort the process.
- Make overridableRequire match .node against the path portion of the
resolved id and have internalRequire dlopen the query-stripped path,
so require("./addon.node?v=1") loads the addon like the plain
spelling does.
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 2:14 PM PT - Jun 30th, 2026
❌ @robobun, your commit b9554ea has 3 failures in
🧪 To try this PR locally: bunx bun-pr 33148That installs a local version of the PR into your bun-33148 --bun |
There was a problem hiding this comment.
I didn't find any issues — the fix is small, well-tested, and follows the existing null-check/throw_type_error pattern in adjacent loader arms — but since it touches the module loader and changes require() semantics for .node files (each distinct ?query now dlopens the same addon under a separate cache key), it's worth a quick human look.
Extended reasoning...
Overview
This PR fixes a process abort (unreachable!() panic) when a .node specifier carries a ?query suffix or when a custom extension is mapped to the napi loader. Three files change: src/runtime/jsc_hooks.rs replaces the unreachable!() in the Loader::Napi transpiler arm with the same TypeError the C++ fast-path produces; src/js/builtins/CommonJS.ts makes overridableRequire match .node against the path portion (before ?) and has internalRequire strip the query before calling process.dlopen; and four regression tests are added to test/js/bun/resolve/import-query.test.ts.
Security risks
None apparent. The Rust change introduces an unsafe { &*global_object } deref, but it is guarded by an explicit is_null() check and is byte-for-byte identical to the pattern used in the adjacent L::Wasm and file-loader arms in the same function. No new attack surface — the change converts an abort into a catchable TypeError.
Level of scrutiny
Moderate-to-high. The diff is small (~20 production lines) and mechanically follows established patterns, but it sits in the module loader and the CommonJS require() builtin — critical, widely-exercised paths. The CommonJS change also introduces a behavioral choice: require("./addon.node?v=1") and require("./addon.node?v=2") will now each dlopen the same on-disk file under distinct cache keys (mirroring how query strings work for JS modules). That is strictly better than the previous SIGABRT, but whether re-dlopening a native addon per query variant is the desired semantics is a design call worth a maintainer's confirmation.
Other factors
No CODEOWNERS cover these paths. The bug-hunting system found nothing. Test coverage is thorough (dynamic import, static import, require, and --loader=.xyz:napi), and the PR description verifies adjacent test suites still pass. The endsWith(".node", queryIndex) usage is correct (second arg is endPosition). I'm deferring only because of the criticality of the code path and the small embedded design decision, not because of any identified defect.
|
On the design point raised in the review, the per- That is Bun's existing semantics for a Two cache keys also do not mean two copies of the library. |
|
CI status: the only failure in build 67299 is The four tests added here ( |
Repro
With any file named
addon.nodenext to it:The process SIGABRTs. The same specifier without the query string gets the intended error instead (
TypeError: To load Node-API modules, use require() or process.dlopen instead of import.). A staticimport "./addon.node?v=1"andrequire("./addon.node?v=1")abort the same way. So doesimport("./thing.xyz")under--loader=.xyz:napi, with no query string involved at all.Cause
The loader classifier strips the
?queryfrom the module key before mapping the extension, so/x/addon.node?v=1classifies asLoader::Napi. But the checks that divert NAPI modules away from the transpiler (moduleKey->endsWith(".node")inmoduleLoaderFetch,id.endsWith(".node")inoverridableRequire) run on the raw key, which ends with?v=1. The specifier slips past them intotranspile_source_code_inner, whose NAPI arm wasunreachable!(). The--loader <ext>:napicase reaches the same arm because the extension is not.nodeto begin with.Fix
src/runtime/jsc_hooks.rs: theLoader::Napiarm of the transpiler now throws the sameTypeErrorthe.nodeguard produces, instead ofunreachable!(). Every spelling the string checks miss lands here, so no specifier or loader configuration can abort the process through this arm.src/js/builtins/CommonJS.ts:overridableRequirematches.nodeagainst the path portion of the resolved id (the id keys the module cache and keeps its?query), andinternalRequirepasses the query-stripped path toprocess.dlopen.require("./addon.node?v=1")now loads the addon exactly likerequire("./addon.node").The two
endsWith(".node")checks inZigGlobalObject.cppare intentionally unchanged: they are still correct fast paths for the plain spelling, and the cases they miss now reach the newLoader::Napiarm and get the identical error.Verification
Four tests added to
test/js/bun/resolve/import-query.test.ts: dynamic import, static import,require, and--loader=.xyz:napi. All four abort the child process on the unfixed build and pass with the fix. The 11 existing tests in the file still pass, as dotest/js/node/module/node-module-module.test.js,require-extensions.test.ts, andmodule-resolve-filename-paths.test.js.