Skip to content

node:vm: break Strong<> cycle through NodeVMScriptFetcher importModuleDynamically callback - #29866

Closed
robobun wants to merge 3 commits into
mainfrom
farm/39eb8ce9/vm-fetcher-import-callback-cycle
Closed

robobun wants to merge 3 commits into
mainfrom
farm/39eb8ce9/vm-fetcher-import-callback-cycle

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

What

NodeVMScriptFetcher (a RefCounted object, not GC-managed) held m_dynamicImportCallback as JSC::Strong<Unknown>, which is an unconditional GC root. The fetcher is kept alive by its owning NodeVMScript / NodeVMSourceTextModule / compiled JSFunction via the RefPtr chain m_source → SourceProvider → SourceOrigin → fetcher.

Whenever the user's importModuleDynamically closure could reach the resulting script/module — which is the typical shape of a module linker cache — this formed an uncollectable cycle:

owner (JSCell) ──m_source──▶ SourceProvider ──▶ SourceOrigin ──RefPtr──▶ NodeVMScriptFetcher
        ▲                                                                      │
        └──────────────── closure ◀── Strong<callback> ◀───────────────────────┘

The owner could never be collected, so the RefPtr chain never dropped to zero, so the Strong root never went away.

Fix

The fetcher now holds m_dynamicImportCallback as JSC::Weak<JSCell>, mirroring the existing treatment of m_owner. The callback's lifetime is instead tied to the owner via a normal GC edge:

  • NodeVMScript / NodeVMSourceTextModule: new WriteBarrier<Unknown> m_dynamicImportCallback visited in visitChildren.
  • vm.compileFunction: the callback is stored as a private (non-enumerable) property on the returned JSFunction.

So: owner alive ⇒ callback alive ⇒ Weak handle valid; owner unreachable ⇒ whole graph collectable.

Tests

Added to test/js/node/vm/vm-script-fetcher-leak.test.ts:

  • vm.Script / vm.SourceTextModule / vm.compileFunction with an importModuleDynamically closure that references the resulting object no longer leak (heap object-type count returns to baseline after 500 iterations; previously 500+ were retained).
  • Two correctness guards verify the callback still fires after a full GC while the owner is held — confirming the WriteBarrier/property edge keeps the Weak handle alive.

Also verified test/js/node/vm/vm.test.ts and the node-parallel test-vm-module-dynamic-import / test-vm-module-link / test-vm-module-basic / test-vm-no-dynamic-import-callback suites pass.

@coderabbitai

coderabbitai Bot commented Apr 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d03c77bb-cfa4-4414-b640-da77a7a316f1

📥 Commits

Reviewing files that changed from the base of the PR and between 98094dc and 207e4c7.

📒 Files selected for processing (7)
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/NodeVMScript.cpp
  • src/jsc/bindings/NodeVMScript.h
  • src/jsc/bindings/NodeVMScriptFetcher.h
  • src/jsc/bindings/NodeVMSourceTextModule.cpp
  • src/jsc/bindings/NodeVMSourceTextModule.h
  • test/js/node/vm/vm-script-fetcher-leak.test.ts

Walkthrough

NodeVMScriptFetcher previously held the importModuleDynamically callback via a strong reference, causing uncollectable reference cycles when the callback closed over the owning script or function. The fix downgrades that hold to a weak reference and adds strong WriteBarrier-backed pins on NodeVMScript, NodeVMSourceTextModule, and compiled functions so the callback survives exactly as long as its owner does.

Changes

GC cycle fix for importModuleDynamically callbacks

Layer / File(s) Summary
NodeVMScriptFetcher: switch callback to weak reference
src/jsc/bindings/NodeVMScriptFetcher.h
m_dynamicImportCallback is changed from JSC::Strong<JSC::Unknown> to JSC::Weak<JSC::JSCell>; the constructor initializes it only when the value is a cell; dynamicImportCallback() now returns jsUndefined() when the weak reference has been cleared by GC.
Strong callback pins on NodeVMScript, NodeVMSourceTextModule, compileFunction
src/jsc/bindings/NodeVMScript.h, src/jsc/bindings/NodeVMScript.cpp, src/jsc/bindings/NodeVMSourceTextModule.h, src/jsc/bindings/NodeVMSourceTextModule.cpp, src/jsc/bindings/NodeVM.cpp
NodeVMScript gains m_dynamicImportCallback (a WriteBarrier<Unknown>) with a setDynamicImportCallback setter wired in constructScript and appended to visitChildrenImpl. NodeVMSourceTextModule gains the same field via an extended constructor and GC visitor. vmModuleCompileFunction stores the importer onto the returned function via a private property so it is strongly retained for the function's lifetime.
Regression tests: no-leak and callback-liveness
test/js/node/vm/vm-script-fetcher-leak.test.ts
Three new tests assert no retained object growth after repeated allocations of vm.Script, vm.SourceTextModule, and vm.compileFunction when the callback closes over the created artifact. Two additional tests assert the callback is still invoked after multiple forced GC cycles while the owning script or compiled function remains alive.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: converting NodeVMScriptFetcher's Strong callback to Weak to break a garbage collection cycle.
Description check ✅ Passed The description includes both required sections: 'What' explains the GC cycle problem and fix, and verification mentions testing with the new test suite and existing tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Apr 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:26 AM PT - Jun 23rd, 2026

✅ @robobun, your commit 9d2ad4deaa20d41e871a3f61499374df2caaa1b1 passed in Build #64144! 🎉


🧪   To try this PR locally:

bunx bun-pr 29866

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

bun-29866 --bun

Comment on lines +1338 to 1343
// NodeVMScriptFetcher only holds a Weak reference to the callback to avoid
// an uncollectable cycle; keep it alive for as long as the compiled
// function is reachable by storing it as a private property.
if (importer && importer.isCell()) {
function->putDirect(vm, builtinNames(vm).importerPrivateName(), importer, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly);
}

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.

🔴 The callback's lifetime is now anchored to the outer wrapper (NodeVMScript / the JSFunction returned by compileFunction / NodeVMSourceTextModule), but the fetcher is reachable from the SourceProvider, which is shared by every nested closure parsed from that source. So an inner closure — e.g. vm.compileFunction('return () => import("x")', [], {importModuleDynamically})(), or a closure a vm.Script installs on globalThis — can outlive the wrapper; once the wrapper is collected the Weak<> clears and import() from the surviving closure throws ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. Pre-PR the Strong<> tied callback lifetime to the SourceProvider (i.e. "any code from this source alive ⇒ callback alive"), which is the contract import() actually needs; the new survives-GC tests only hold the outer owner so they don't catch this.

Extended reasoning...

What this PR changes

The fetcher's m_dynamicImportCallback is downgraded from Strong<Unknown> to Weak<JSCell>. To compensate, each call site adds a normal GC edge from a designated "owner" to the callback:

  • NodeVMScript: m_dynamicImportCallback WriteBarrier visited in visitChildren (NodeVMScript.cpp:140 / NodeVMScript.h:80-95).
  • vm.compileFunction: a private importerPrivateName property on the returned outer JSFunction (NodeVM.cpp:1338-1343).
  • NodeVMSourceTextModule: m_dynamicImportCallback WriteBarrier.

The intended invariant is owner alive ⇒ callback alive ⇒ Weak handle valid.

Why the owner is the wrong anchor

NodeVMScriptFetcher is reachable via SourceCode → SourceProvider → SourceOrigin → RefPtr<ScriptFetcher>. In JSC, every nested FunctionExecutable parsed from a given source is a sub-range view into the same SourceProvider. So an inner closure created by running the script/function keeps the SourceProvider — and therefore the fetcher — alive independently of the outer wrapper object.

import() resolves its host hook via callerSourceOrigin(vm), which walks to the executing code's SourceOrigin → fetcher (see Zig::GlobalObject::moduleLoaderImportModule → NodeVM::importModule, NodeVM.cpp:271-280). For an inner closure that path reaches the same fetcher even after the outer wrapper is gone.

The inner closure's scope chain references the outer activation/JSLexicalEnvironment, not the outer JSFunction object, and certainly not the NodeVMScript wrapper. None of the three new GC edges are reachable from an inner closure. So:

inner closure alive ⇒ SourceProvider alive ⇒ fetcher alive, but wrapper dead ⇒ Weak<callback> cleared.

fetcher->dynamicImportCallback() then returns jsUndefined() (NodeVMScriptFetcher.h:19-24), and NodeVM::importModule falls into the !dynamicImportCallback.isCallable() branch and throws ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.

Step-by-step repro (vm.compileFunction)

let fn = vm.compileFunction('return () => import("x");', [], {
  importModuleDynamically: () => mod,
});
const inner = fn();   // inner's FunctionExecutable shares fn's SourceProvider → fetcher
fn = null;            // drop the only thing rooting the callback (private property on fn)
Bun.gc(true);
await inner();        // fetcher still alive via inner's SourceProvider, but Weak<callback>
                      // has been cleared → ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING
  1. vm.compileFunction creates a fetcher whose m_dynamicImportCallback is Weak<callback>, and stores callback as a private property on the returned outer fn.
  2. fn() allocates an inner arrow closure; its FunctionExecutable holds a SourceCode that points to fn's SourceProvider, which holds SourceOrigin{…, Ref<fetcher>}.
  3. fn = null removes the only GC root for callback (the private property). inner's scope chain does not reference the outer JSFunction object.
  4. GC collects fn and callback; the fetcher's Weak<> clears. The fetcher itself survives because inner → FunctionExecutable → SourceProvider → SourceOrigin → RefPtr<fetcher>.
  5. inner() evaluates import("x"), JSC walks to the caller's SourceOrigin, finds the fetcher, calls dynamicImportCallback() → jsUndefined(), and NodeVM::importModule throws ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.

The same shape applies to new vm.Script(...).runInThisContext() installing a closure on globalThis and then dropping the Script — a very common pattern.

Why the new tests don't catch it

Both "survives GC" tests (vm.Script and vm.compileFunction) hold the outer script/fn across the GC and only then call it. They never exercise the case where an inner closure outlives the wrapper.

Comparison with pre-PR / Node.js

Pre-PR, Strong<callback> lived on the fetcher, so callback lifetime == fetcher lifetime == SourceProvider lifetime == "any code from this source is alive" — exactly the contract import() needs. Node.js gets the equivalent guarantee by stashing the registry key in host_defined_options on the v8::Script, which propagates to inner SFIs. This PR narrows the guarantee to "outer wrapper alive", which is strictly weaker and a behavioral regression.

Suggested direction

The callback needs to be rooted for the lifetime of the SourceProvider, not the wrapper. One option that still avoids the original cycle is to make the SourceProvider (or a small JSCell hung off it and visited via SourceProvider heap-cell visitation) own the callback via a WriteBarrier, so the GC can see closure → executable → provider → callback as a normal edge rather than an unconditional root. Alternatively, keep the Strong<> on the fetcher but break the cycle by giving the wrapper a WeakHandleOwner that clears the Strong<> in finalize() when the wrapper dies — though that re-introduces the "inner closure outlives wrapper" problem in the opposite direction, so the SourceProvider-anchored edge is the more correct fix.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — confirmed empirically:

// with this PR
compileFunction: outerFin=49 cbFin=49
  inner import: calls=0 code=ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING
// main (Strong<>)
compileFunction: outerFin=49 cbFin=0
  inner import: calls=1

The SourceProvider-scoped guarantee ("any code from this source alive ⇒ callback alive") is the correct contract, but expressing it without Strong<> needs an addOpaqueRoot(fetcher) in FunctionExecutable/GlobalExecutable::visitChildrenImpl plus a WeakHandleOwner::isReachableFromOpaqueRoots on the fetcher's Weak<callback> — ScriptExecutable doesn't visit SourceProvider/SourceOrigin/ScriptFetcher at all today, and neither WebCore ScriptFetcher subclass holds JS values, so there's no existing hook. That's a WebKit change.

Without it, owner-scoped is the tightest bound reachable from Bun's side. This PR picks that over the status-quo leak because:

  • The leak fires on the common shape (any importModuleDynamically closure that can reach the result — module linker caches, the pattern in the original report) and has no workaround.
  • The regression fires on the narrow shape (inner closure outlives a dropped wrapper and then calls import()) and has a workaround: keep the Script/Module/compiled function referenced.
  • m_owner is already Weak on main with the same wrapper-scoped limitation (inner closure after wrapper collection already sees owner === undefined in the callback args).

Happy to follow up with the USE(BUN_JSC_ADDITIONS) opaque-root hook in WebKit to restore the full guarantee — that should go in as its own change since it touches the engine.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use JSC::Weak and then have a hasPendingActivity? Can we be smarter aobu tthe GC lifetime here?

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author

We should probably use JSC::Weak and then have a hasPendingActivity? Can we be smarter about the GC lifetime here?

The callback is JSC::Weak now. The ideal lifetime is "any executable from this SourceProvider alive ⇒ callback alive" (so import() keeps working from nested closures even after the outer Script/JSFunction wrapper is dropped). The WeakHandleOwner::isReachableFromOpaqueRoots pattern would express that as:

// fetcher side
m_dynamicImportCallback = Weak<JSCell>(cb, &ownerSingleton, /*context*/ this);
bool NodeVMScriptFetcherWeakOwner::isReachableFromOpaqueRoots(..., void* ctx, AbstractSlotVisitor& v, ...) {
    return v.containsOpaqueRoot(ctx); // ctx == fetcher*
}

…but something GC-visited has to add the fetcher as an opaque root whenever any executable from that source is live. Today nothing does: ScriptExecutable has no visitChildren, and FunctionExecutable/GlobalExecutable::visitChildrenImpl don't touch SourceProvider/SourceOrigin/ScriptFetcher. Neither WebCore ScriptFetcher subclass holds JS values. The only place that could add the root is the executable visit path itself — i.e. a USE(BUN_JSC_ADDITIONS) block in WebKit along the lines of:

// FunctionExecutable::visitChildrenImpl / GlobalExecutable::visitChildrenImpl
if (auto* p = thisObject->source().provider())
    if (auto* f = p->sourceOrigin().fetcher(); f && f->fetcherType() == ScriptFetcher::Type::NodeVM)
        visitor.addOpaqueRoot(f);

That needs a WebKit bump (CI uses the prebuilt). Happy to do that as the follow-up; without it, owner-scoped (what this PR does) is the tightest bound reachable from Bun's side. In the meantime this trades:

  • Fixed: the uncollectable cycle whenever the importModuleDynamically closure can reach the resulting script/module (the common linker-cache shape from the report). No workaround exists today.
  • Introduced: import() from a nested closure fails with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING only if the outer Script/compiled function/Module wrapper has been dropped and collected. Workaround: keep the wrapper referenced. m_owner is already Weak on main with the same wrapper-scoped limitation (the owner arg to the callback is already undefined in that window).

@Jarred-Sumner
Jarred-Sumner force-pushed the farm/39eb8ce9/vm-fetcher-import-callback-cycle branch from 6f6a0c0 to 0ed715a Compare May 4, 2026 10:33
@robobun
robobun force-pushed the farm/39eb8ce9/vm-fetcher-import-callback-cycle branch from 0ed715a to 207e4c7 Compare June 23, 2026 05:12
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The leak is real and still on main. src/jsc/bindings/NodeVMScriptFetcher.h:45 holds the callback in JSC::Strong<JSC::Unknown> m_dynamicImportCallback. On 1.4.3-canary.1+b99371011, 500 of 500 vm.Script, vm.compileFunction and vm.SourceTextModule objects stay alive after 12 full collections when the importModuleDynamically closure can reach the object. The same loop with a callback that does not reach it retains 0 of 500. #28493 fixed the m_owner half and recorded this half as a known limitation. No other open or merged PR fixes it.

The lifetime in this diff is not the wanted one, and the changes-requested review is still open. The diff anchors the callback to the outer wrapper (NodeVMScript, the compiled JSFunction, NodeVMSourceTextModule). Nested closures share the fetcher through the SourceProvider and do not keep the wrapper alive. After the wrapper is collected, import() from a nested closure fails with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. The comment in NodeVMScriptFetcher.h and the reply of 2026-04-28 in this PR both confirm that. Main calls the callback in that case (calls: 1 for vm.Script, vm.runInThisContext and vm.compileFunction), and so does Node v26.3.0 with --experimental-vm-modules. Node shipped this wrapper anchor in v19.8.0 (nodejs/node#46785) and reverted it in v19.8.1 because it crashed applications. nodejs/node#48510 calls it unsound for the same reason.

The wanted shape scopes the callback to the source. The callback stays alive while any code compiled from that SourceProvider is alive:

  1. Keep JSC::Weak<JSC::JSCell> m_dynamicImportCallback, and give it a WeakHandleOwner whose isReachableFromOpaqueRoots returns visitor.containsOpaqueRoot(context), with the fetcher as the context. src/jsc/bindings/webcore/JSCallbackData.cpp:113 already uses this pattern.
  2. In oven-sh/WebKit, under USE(BUN_JSC_ADDITIONS), call addOpaqueRoot on a NodeVM fetcher from FunctionExecutable::visitChildrenImpl and GlobalExecutable::visitChildrenImpl. Land that first and move the WebKit pin.
  3. Remove the wrapper-side edges from this diff. Add tests for a nested closure that calls import() after its wrapper is dropped. Main and Node pass those tests, and this diff fails them.
  4. Rebase. The branch conflicts with main.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43724. It fixes the same NodeVMScriptFetcher leak with the lifetime that the review here asked for. The fetcher holds the callback in a JSC::Weak whose owner checks containsOpaqueRoot(fetcher), and the executables add the fetcher as an opaque root (oven-sh/WebKit#712).

I built #43724 at 045b123 and ran the 5 tests from this PR against that build. All 5 pass. On 1.4.3-canary.1+367d939d9 the 3 leak tests fail with 500 of 500 objects retained.

The nested closure case from the review thread here also works on that build. A closure that outlives its vm.Script or its compiled function still calls the callback (calls: 1). With this diff the same code fails with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.

@robobun robobun closed this Sep 22, 2026
cirospaciari added a commit that referenced this pull request Sep 22, 2026
The three self-cycle shapes from #29866 (module linker caches): Script,
SourceTextModule and compileFunction, one per fetcher visit site. All three
retain 500/500 on the unfixed runtime.
cirospaciari added a commit that referenced this pull request Sep 22, 2026
The three self-cycle shapes from #29866 (module linker caches): Script,
SourceTextModule and compileFunction, one per fetcher visit site. All three
retain 500/500 on the unfixed runtime.
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.

2 participants