node:vm: throw instead of crashing on a cyclic SourceTextModule linked inside the linker - #31624
Conversation
…inside the linker A cyclic SourceTextModule graph (A imports B, B imports A) that is linked and evaluated from inside the linker callback segfaulted: SourceTextModule instantiate() runs JSC's whole-graph record->link(), which walks every reachable record via innerModuleLinking and calls getImportedModule() for each request. When a module is evaluated mid-link, a dependency can still be in the middle of its own link() with an empty loadedModules(), so getImportedModule() dereferences an end() iterator — an assert in debug, a SIGSEGV in release. Pre-walk the dependency graph in instantiate() the same way innerModuleLinking does; if a request is missing from a record's loadedModules() the graph is not fully linked, so throw a catchable ERR_VM_MODULE_LINK_FAILURE (request for '<specifier>' is not in cache) matching Node instead of letting JSC crash. The canonical link-whole-graph-then-evaluate path is unaffected.
|
Updated 7:39 PM PT - May 30th, 2026
✅ @robobun, your commit bc73a75eb05de6cfb2f651430a9c5c53dc0a9dbd passed in 🧪 To try this PR locally: bunx bun-pr 31624That installs a local version of the PR into your bun-31624 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughNodeVMSourceTextModule now validates the entire module dependency graph exists in the loadedModules cache before calling record->link(), throwing ERR_VM_MODULE_LINK_FAILURE with a "request for '' is not in cache" message when a dependency is missing. Three regression tests cover cyclic import scenarios. ChangesCyclic SourceTextModule Safety
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
The pre-walk added in the previous commit recursed one native frame per dependency edge with no stack guard. A deep linear import chain links via microtask trampolines (so no native stack accumulates during [kLink]), then instantiate() is called once on a shallow stack and the recursive walk descends the whole chain synchronously — overflowing the native stack before record->link()'s own isSafeToRecurse() guard could throw a catchable RangeError, reintroducing the crash this change removes. Rewrite the walk with an explicit WTF::Vector worklist so depth no longer maps to native stack frames. A fully-linked deep chain now reaches record->link() and surfaces JSC's catchable RangeError; an unlinked one still throws ERR_VM_MODULE_LINK_FAILURE. Behavior for the cyclic cases is unchanged.
There was a problem hiding this comment.
LGTM — the iterative worklist rewrite in bc73a75 addresses the recursion-depth concern, and the pre-walk now cleanly converts the crash into a catchable ERR_VM_MODULE_LINK_FAILURE.
Extended reasoning...
Overview
This PR fixes a segfault in node:vm SourceTextModule when a cyclic module graph is linked + evaluated from inside the linker callback. The change adds a static isModuleGraphLinked() helper in src/jsc/bindings/NodeVMSourceTextModule.cpp that pre-walks the dependency graph (iterative worklist + visited set) and verifies every requestedModules() entry is present in the corresponding record's loadedModules() before handing off to JSC's record->link(). If a request is missing, instantiate() throws ERR_VM_MODULE_LINK_FAILURE with Node's "request for '' is not in cache" message instead of letting JSModuleLoader::getImportedModule() dereference an end() iterator. Three subprocess regression tests in test/js/node/vm/vm.test.ts cover the crash case, a self-import, and the canonical link-then-evaluate cyclic pattern.
Prior review
On the first revision I flagged that the recursive form of isModuleGraphLinked() could overflow the native stack on a very deep linear chain before innerModuleLinking's own isSafeToRecurse() guard could throw. The author rewrote it iteratively in bc73a75 (exactly the suggested shape) and verified a 50k-deep chain now surfaces a catchable RangeError rather than segfaulting. That thread is resolved and the current diff reflects the fix.
Security risks
None introduced. The change is purely defensive validation on the instantiate() path: it inspects already-built JSC module records (no user-callback invocation, no allocation that can trip GC mid-walk since HashSet/Vector of raw pointers are walked synchronously with no JS reentry) and either passes through unchanged or throws an error where the process previously crashed. It does not touch context isolation, sandbox boundaries, or evaluation semantics.
Level of scrutiny
Moderate. Native JSC-binding code in node:vm warrants care, but the change is narrow and additive: the happy path (isModuleGraphLinked returns true → fall through to the existing record->link()) is behaviorally identical to before. The ModuleMapKey { request.m_specifier.impl(), request.type() } lookup mirrors how link() populates loadedModules() via setImportedModule(globalObject, requested[i], ...), so false positives on well-formed graphs are guarded against — and the canonical-cyclic-pattern test exercises exactly that.
Other factors
- The bug-hunting system found no issues on the current revision.
- The PR description documents that
vm.test.ts, the SourceTextModule leak/GC suites, and node-paralleltest-vm-module-*all pass. - No CODEOWNERS entries cover the touched paths.
- The added code is well-commented, including the rationale for the iterative form.
Fixes #31623
Repro
Build a two-module
node:vmSourceTextModuleimport cycle (A imports B, B imports A) and link + evaluate each module inside the linker callback as it's first encountered:Under a debug/ASAN build it surfaces as the underlying JSC assert:
Cause
SourceTextModuleinstantiate()runs JSC's whole-graphrecord->link(), which walks every reachable record viainnerModuleLinkingand callsJSModuleLoader::getImportedModule()for each requested module.getImportedModule()looks the request up in the referrer'sloadedModules(), which is only populated bysetImportedModule()during that module's own nativelink().In the link+evaluate-inside-the-linker pattern, module B finishes its
[kLink]and callsinstantiate()while module A is still suspended mid-link()(awaiting its own linker promise for B). A'ssetImportedModule()calls haven't run yet, so A'sloadedModules()is empty. When B'srecord->link()descends into A and iterates A's requested modules,getImportedModule(A, "b")finds nothing → dereferences theend()iterator: an assert in debug, a read of0xFFFFFFFFFFFFFFF8(offset 8 pastend()) in release.Fix
Before
instantiate()calls the whole-graphrecord->link(), pre-walk the dependency graph exactly the wayinnerModuleLinkingdoes. If any request is missing from a record'sloadedModules(), the graph isn't fully linked, so throw a catchableERR_VM_MODULE_LINK_FAILUREwith Node's message (request for '<specifier>' is not in cache) instead of letting JSC crash. Avisitedset keeps the walk finite on cycles.The canonical cyclic pattern — link the whole graph first (linker just returns the module), then evaluate the root once — already populates every record's
loadedModules()before anyinstantiate(), so it is unaffected. Same for acyclic incremental linking.Verification
bun repro.tsnow throwsERR_VM_MODULE_LINK_FAILURE: request for 'b' is not in cacheand exits 1 instead of segfaulting — matching Node.a.ab() === "B",b.ba() === "A").test/js/node/vm/vm.test.ts(node:vm SourceTextModule cyclic graph linking): the cyclic case fails (crashes) without the fix under bothbun bd(the JSC assert) and the release build (SIGSEGV), and passes with it; self-import and canonical cases guard the working paths.test/js/node/vm/vm.test.ts, the SourceTextModule leak/GC suites, and the node-paralleltest-vm-module-{link,errors,reevaluate,synthetic}tests all pass.