-
Notifications
You must be signed in to change notification settings - Fork 5.1k
node:vm: break Strong<> cycle through NodeVMScriptFetcher importModuleDynamically callback #29866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 avm.Scriptinstalls onglobalThis— can outlive the wrapper; once the wrapper is collected theWeak<>clears andimport()from the surviving closure throwsERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. Pre-PR theStrong<>tied callback lifetime to the SourceProvider (i.e. "any code from this source alive ⇒ callback alive"), which is the contractimport()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_dynamicImportCallbackis downgraded fromStrong<Unknown>toWeak<JSCell>. To compensate, each call site adds a normal GC edge from a designated "owner" to the callback:NodeVMScript:m_dynamicImportCallbackWriteBarriervisited invisitChildren(NodeVMScript.cpp:140 / NodeVMScript.h:80-95).vm.compileFunction: a privateimporterPrivateNameproperty on the returned outerJSFunction(NodeVM.cpp:1338-1343).NodeVMSourceTextModule:m_dynamicImportCallbackWriteBarrier.The intended invariant is owner alive ⇒ callback alive ⇒ Weak handle valid.
Why the owner is the wrong anchor
NodeVMScriptFetcheris reachable viaSourceCode → SourceProvider → SourceOrigin → RefPtr<ScriptFetcher>. In JSC, every nestedFunctionExecutableparsed from a given source is a sub-range view into the sameSourceProvider. 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 viacallerSourceOrigin(vm), which walks to the executing code's SourceOrigin → fetcher (seeZig::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 outerJSFunctionobject, and certainly not theNodeVMScriptwrapper. None of the three new GC edges are reachable from an inner closure. So:fetcher->dynamicImportCallback()then returnsjsUndefined()(NodeVMScriptFetcher.h:19-24), andNodeVM::importModulefalls into the!dynamicImportCallback.isCallable()branch and throwsERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.Step-by-step repro (
vm.compileFunction)vm.compileFunctioncreates a fetcher whosem_dynamicImportCallbackisWeak<callback>, and storescallbackas a private property on the returned outerfn.fn()allocates an inner arrow closure; itsFunctionExecutableholds aSourceCodethat points tofn'sSourceProvider, which holdsSourceOrigin{…, Ref<fetcher>}.fn = nullremoves the only GC root forcallback(the private property).inner's scope chain does not reference the outerJSFunctionobject.fnandcallback; the fetcher'sWeak<>clears. The fetcher itself survives becauseinner → FunctionExecutable → SourceProvider → SourceOrigin → RefPtr<fetcher>.inner()evaluatesimport("x"), JSC walks to the caller's SourceOrigin, finds the fetcher, callsdynamicImportCallback()→jsUndefined(), andNodeVM::importModulethrowsERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING.The same shape applies to
new vm.Script(...).runInThisContext()installing a closure onglobalThisand then dropping theScript— a very common pattern.Why the new tests don't catch it
Both "survives GC" tests (
vm.Scriptandvm.compileFunction) hold the outerscript/fnacross 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 contractimport()needs. Node.js gets the equivalent guarantee by stashing the registry key inhost_defined_optionson thev8::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
SourceProviderheap-cell visitation) own the callback via aWriteBarrier, so the GC can seeclosure → executable → provider → callbackas a normal edge rather than an unconditional root. Alternatively, keep theStrong<>on the fetcher but break the cycle by giving the wrapper aWeakHandleOwnerthat clears theStrong<>infinalize()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.There was a problem hiding this comment.
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:
The SourceProvider-scoped guarantee ("any code from this source alive ⇒ callback alive") is the correct contract, but expressing it without
Strong<>needs anaddOpaqueRoot(fetcher)inFunctionExecutable/GlobalExecutable::visitChildrenImplplus aWeakHandleOwner::isReachableFromOpaqueRootson the fetcher'sWeak<callback>—ScriptExecutabledoesn't visitSourceProvider/SourceOrigin/ScriptFetcherat all today, and neither WebCoreScriptFetchersubclass 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:
importModuleDynamicallyclosure that can reach the result — module linker caches, the pattern in the original report) and has no workaround.import()) and has a workaround: keep theScript/Module/compiled function referenced.m_owneris alreadyWeakon main with the same wrapper-scoped limitation (inner closure after wrapper collection already seesowner === undefinedin 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.