Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/jsc/bindings/NodeVM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1570,8 +1570,11 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject

fetcher->owner(vm, function);

if (!function) {
return throwVMError(globalObject, scope, "Failed to compile function"_s);
// 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);
}
Comment on lines +1573 to 1578

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.


return JSValue::encode(function);
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/NodeVMScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT
RETURN_IF_EXCEPTION(scope, {});

fetcher->owner(vm, script);
script->setDynamicImportCallback(vm, importer);

WTF::Vector<uint8_t>& cachedData = script->cachedData();

Expand Down Expand Up @@ -242,6 +243,7 @@ void NodeVMScript::visitChildrenImpl(JSCell* cell, Visitor& visitor)
Base::visitChildren(thisObject, visitor);
visitor.append(thisObject->m_cachedExecutable);
visitor.append(thisObject->m_cachedBytecodeBuffer);
visitor.append(thisObject->m_dynamicImportCallback);
}

NodeVMScriptConstructor::NodeVMScriptConstructor(VM& vm, Structure* structure)
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/bindings/NodeVMScript.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class NodeVMScript final : public JSC::JSDestructibleObject, public SigintReceiv
void cachedDataRejected(TriState value) { m_cachedDataRejected = value; }
bool sourceMapURLParsed() const { return m_sourceMapURLParsed; }
void sourceMapURLParsed(bool value) { m_sourceMapURLParsed = value; }
void setDynamicImportCallback(JSC::VM& vm, JSC::JSValue value)
{
if (value && value.isCell())
m_dynamicImportCallback.set(vm, this, value);
}

DECLARE_VISIT_CHILDREN;

Expand All @@ -87,6 +92,9 @@ class NodeVMScript final : public JSC::JSDestructibleObject, public SigintReceiv
RefPtr<JSC::CachedBytecode> m_cachedBytecode;
JSC::WriteBarrier<JSC::JSUint8Array> m_cachedBytecodeBuffer;
JSC::WriteBarrier<JSC::ProgramExecutable> m_cachedExecutable;
// Keeps the importModuleDynamically callback alive; NodeVMScriptFetcher only
// holds a Weak reference to it to avoid an uncollectable Strong<> cycle.
JSC::WriteBarrier<JSC::Unknown> m_dynamicImportCallback;
ScriptOptions m_options;
bool m_cachedDataProduced = false;
bool m_sourceMapURLParsed = false;
Expand Down
36 changes: 26 additions & 10 deletions src/jsc/bindings/NodeVMScriptFetcher.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {

Type fetcherType() const final { return Type::NodeVM; }

JSC::JSValue dynamicImportCallback() const { return m_dynamicImportCallback.get(); }
JSC::JSValue dynamicImportCallback() const
{
if (auto* cell = m_dynamicImportCallback.get())
return JSC::JSValue(cell);
return JSC::jsUndefined();
}

JSC::JSValue owner() const
{
Expand All @@ -42,19 +47,30 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
}

private:
JSC::Strong<JSC::Unknown> m_dynamicImportCallback;
// m_owner is the NodeVMScript / JSFunction / module wrapper that holds this
// fetcher via m_source -> SourceProvider -> SourceOrigin -> RefPtr<fetcher>.
// A Strong handle here would form an uncollectable cycle (the owner keeps
// the fetcher alive via RefPtr, and the fetcher would keep the owner alive
// as a GC root). Use Weak instead: when the owner is collected its
// SourceCode chain drops the last RefPtr to this fetcher.
// This fetcher is RefCounted and reachable from its owning JSCell via
// m_source -> SourceProvider -> SourceOrigin -> RefPtr<fetcher>. Holding
// either the owner or the importModuleDynamically callback via Strong<>
// would create an uncollectable cycle whenever the callback's closure can
// reach the owner (a common pattern in module linker caches). Both are
// therefore held weakly here; the owning NodeVMScript / NodeVMSourceTextModule /
// compiled JSFunction is responsible for keeping the callback alive via a
// normal GC edge (WriteBarrier / property) so that the Weak handle remains
// valid for as long as the owner is reachable.
//
// Note that nested closures share this fetcher via the SourceProvider but
// do not keep the owner alive, so a nested closure that outlives a dropped
// owner will observe a cleared m_dynamicImportCallback and import() will
// fail with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. Closing that gap without
// reintroducing the Strong<> cycle requires adding the fetcher as an opaque
// root from ScriptExecutable's visitChildren in WebKit.
JSC::Weak<JSC::JSCell> m_dynamicImportCallback;
JSC::Weak<JSC::JSCell> m_owner;
bool m_isUsingDefaultLoader = false;

NodeVMScriptFetcher(JSC::VM& vm, JSC::JSValue dynamicImportCallback, JSC::JSValue owner)
: m_dynamicImportCallback(vm, dynamicImportCallback)
NodeVMScriptFetcher(JSC::VM&, JSC::JSValue dynamicImportCallback, JSC::JSValue owner)
{
if (dynamicImportCallback.isCell())
m_dynamicImportCallback = JSC::Weak<JSC::JSCell>(dynamicImportCallback.asCell());
if (owner.isCell())
m_owner = JSC::Weak<JSC::JSCell>(owner.asCell());
}
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/bindings/NodeVMSourceTextModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ NodeVMSourceTextModule* NodeVMSourceTextModule::create(VM& vm, JSGlobalObject* g
RETURN_IF_EXCEPTION(scope, nullptr);
NodeVMSourceTextModule* ptr = new (NotNull, allocateCell<NodeVMSourceTextModule>(vm)) NodeVMSourceTextModule(
vm, zigGlobalObject->NodeVMSourceTextModuleStructure(), WTF::move(identifier), contextValue,
WTF::move(sourceCode), moduleWrapper, initializeImportMeta);
WTF::move(sourceCode), moduleWrapper, initializeImportMeta, dynamicImportCallback);
RETURN_IF_EXCEPTION(scope, nullptr);
ptr->finishCreation(vm);

Expand Down Expand Up @@ -562,6 +562,7 @@ void NodeVMSourceTextModule::visitChildrenImpl(JSCell* cell, Visitor& visitor)
visitor.append(vmModule->m_cachedExecutable);
visitor.append(vmModule->m_cachedBytecodeBuffer);
visitor.append(vmModule->m_initializeImportMeta);
visitor.append(vmModule->m_dynamicImportCallback);
}

DEFINE_VISIT_CHILDREN(NodeVMSourceTextModule);
Expand Down
6 changes: 5 additions & 1 deletion src/jsc/bindings/NodeVMSourceTextModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,18 @@ class NodeVMSourceTextModule final : public NodeVMModule {
WriteBarrier<ModuleProgramExecutable> m_cachedExecutable;
WriteBarrier<JSUint8Array> m_cachedBytecodeBuffer;
WriteBarrier<Unknown> m_initializeImportMeta;
// Keeps the importModuleDynamically callback alive; NodeVMScriptFetcher only
// holds a Weak reference to it to avoid an uncollectable Strong<> cycle.
WriteBarrier<Unknown> m_dynamicImportCallback;
RefPtr<CachedBytecode> m_bytecode;
SourceCode m_sourceCode;
bool m_hasTopLevelAwait { false };
bool m_linkCalled { false };

NodeVMSourceTextModule(JSC::VM& vm, JSC::Structure* structure, WTF::String identifier, JSValue context, SourceCode sourceCode, JSValue moduleWrapper, JSValue initializeImportMeta)
NodeVMSourceTextModule(JSC::VM& vm, JSC::Structure* structure, WTF::String identifier, JSValue context, SourceCode sourceCode, JSValue moduleWrapper, JSValue initializeImportMeta, JSValue dynamicImportCallback)
: Base(vm, structure, WTF::move(identifier), context, moduleWrapper)
, m_initializeImportMeta(initializeImportMeta && !initializeImportMeta.isUndefined() ? initializeImportMeta : JSValue(), JSC::WriteBarrierEarlyInit)
, m_dynamicImportCallback(dynamicImportCallback && dynamicImportCallback.isCell() ? dynamicImportCallback : JSValue(), JSC::WriteBarrierEarlyInit)
, m_sourceCode(WTF::move(sourceCode))
{
}
Expand Down
96 changes: 96 additions & 0 deletions test/js/node/vm/vm-script-fetcher-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,100 @@ describe("node:vm NodeVMScriptFetcher leak", () => {

await expectMaxObjectTypeCount(expect, "Script", baseline + 20);
});

// Regression: NodeVMScriptFetcher also held m_dynamicImportCallback via
// JSC::Strong, so any importModuleDynamically closure that could reach the
// resulting script/module (a common pattern in module linker caches) formed
// an uncollectable cycle: script -> m_source -> SourceProvider -> SourceOrigin
// -> RefPtr<NodeVMScriptFetcher> -> Strong<callback> -> closure -> script.

test("vm.Script with importModuleDynamically referencing the script should not leak", async () => {
const baseline = heapStats().objectTypeCounts.Script || 0;

function iteration() {
const holder: { script?: vm.Script } = {};
holder.script = new vm.Script("1 + 1", {
importModuleDynamically: () => {
// Closure references the script via `holder`, forming a cycle through
// the fetcher's callback reference.
return holder.script;
},
});
}
for (let i = 0; i < 500; i++) iteration();

await expectMaxObjectTypeCount(expect, "Script", baseline + 20);
});

test("vm.SourceTextModule with importModuleDynamically referencing the module should not leak", async () => {
const baseline = heapStats().objectTypeCounts.NodeVMSourceTextModule || 0;

function iteration() {
const cache = new Map<string, any>();
const mod = new vm.SourceTextModule("export const a = 1;", {
importModuleDynamically: specifier => cache.get(specifier),
});
cache.set("self", mod);
}
for (let i = 0; i < 500; i++) iteration();

await expectMaxObjectTypeCount(expect, "NodeVMSourceTextModule", baseline + 20);
});

test("vm.compileFunction with importModuleDynamically referencing the function should not leak", async () => {
const baseline = heapStats().objectTypeCounts.FunctionExecutable || 0;

function iteration() {
const holder: { fn?: Function } = {};
holder.fn = vm.compileFunction("return 1", [], {
importModuleDynamically: () => {
return holder.fn;
},
});
}
for (let i = 0; i < 500; i++) iteration();

await expectMaxObjectTypeCount(expect, "FunctionExecutable", baseline + 50);
});

test("vm.Script importModuleDynamically callback survives GC while script is alive", async () => {
// After making the fetcher's callback Weak, the owning script must keep the
// callback alive so that import() still works after a GC.
let called = 0;
const script = new vm.Script('import("kept").catch(() => {});', {
importModuleDynamically: () => {
called++;
throw new Error("callback reached");
},
});

Bun.gc(true);
await Bun.sleep(0);
Bun.gc(true);

script.runInThisContext();
await Bun.sleep(0);

// If the Weak handle had been cleared, import() would have rejected with
// ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING without invoking the callback.
expect(called).toBe(1);
});

test("vm.compileFunction importModuleDynamically callback survives GC while function is alive", async () => {
let called = 0;
const fn = vm.compileFunction('return import("kept").catch(() => {});', [], {
importModuleDynamically: () => {
called++;
throw new Error("callback reached");
},
});

Bun.gc(true);
await Bun.sleep(0);
Bun.gc(true);

await fn();

expect(called).toBe(1);
});
});
Loading