Repository navigation
vm: memory leak on vm.compileFunction with importModuleDynamically #44211
Description
Activity
- addedvmIssues and PRs related to the vm subsystem.Issues and PRs related to the vm subsystem.
on Aug 11, 2022 importModuleDynamically()callback is invoked everytime theimportstatement in the code paased to thevmis evaluated. The essential problem here is that it takes avm.Scriptinstance(created byvm.runInThisContext()) or aFunctioninstance(created byvm.compileFunction()) as the second argument. Since we can not know when to evaluate theimportstatement(it may be scheduled to evaluate in one year...), we have to keep track thevm.Scriptinstance or theFunctioninstance forever.However, I went through all JS files in
/lib, and I didn't spot any usage of the second argument.
e.g.

Maybe we can fix this issue by disposing it. It's not backward compatible, but we have to admit this is a pitfall of the design.
In case anyone who is confused, the dilemma we have now:
- The lifetime of
CompiledFnEntry(stored inEnvironment::id_to_function_map) is bounded to theFunctioncreated as a result ofvm.compileFunction(), since thatFunctioncan be alive for a long time, theCompiledFnEntryis leaked! - In the scenario of
vm.runInThisContext(), theContextifyScript(stored inEnvironment::id_to_script_map) is not strongly referenced at all, so it is a subject to GC. However, it is required when evaluating animportstatement in the code paased tovm, hence, we might run into a segmentation fault inif it is GC'ed prematurely. CheckLines 585 to 595 in 1531ef1
int type = options->Get(context, HostDefinedOptions::kType) .As<Number>() ->Int32Value(context) .ToChecked(); uint32_t id = options->Get(context, HostDefinedOptions::kID) .As<Number>() ->Uint32Value(context) .ToChecked(); if (type == ScriptType::kScript) { contextify::ContextifyScript* wrap = env->id_to_script_map.find(id)->second; object = wrap->object(); Segmentation faultwhen executing worker witheval: truecontaining dynamic import afterawait#43205 (comment) for how to trigger :).
The fix I proposed above works for either of these two issues.
- The lifetime of
I would like to hear your ideas :) @legendecas
Reacted by Toni Villena@ywave620 hey, thank you for digging into this.
The second argument of the
importModuleDynamicallyis essential for userland hooks to resolve the specifier with the initiating script, as defined by the spec: https://tc39.es/ecma262/#sec-hostimportmoduledynamically. I don't think removing the argumentreferencing scriptmakes sense here.As for the segfaults of issues like #43205, I believe a V8 refactoring is the best effort to settle a solution that fits our use case on host defined options: https://chromium-review.googlesource.com/c/v8/v8/+/3172764.
I'm drafting a PR based on the V8 refactoring to show how the crash can be fixed: #44923.
@legendecas
Great! Some thoughts on your work:- In the first sight, host defined options should be something belonged to a script, rather than a context.
- In addition, the solution you come up with prevents us from evaluating two scripts in the same context but with different
importModuleDynamically()callback for each.
Discussion on the
importModuleDynamically()callback:I'm afraid that you and node/vm group have made a mistake in this point from the very beginning. Please correct me if I'm wrong!
- First of all, I believe
referencingScriptOrModulestated in the spec (should) corresponds to the second parameter ofimportModuleDynamically()documented in https://nodejs.org/api/vm.html#vmruninthiscontextcode-options - From the spec,
referencingScriptOrModuleis the active script or module, rather than the script resulted fromvm.runInThisContext(). Check here:
https://tc39.es/ecma262/#sec-import-call-runtime-semantics-evaluation
The term "context" used in v8 and the Node.js VM module refers to different entities. It is important to disambiguate those "contexts" here. When the dynamic import call is evaluated, the
referencingScriptOrModuleis deducted from the topmost script context or module context's host-defined options, a.k.a. "active script or module". This doesn't prevent us from setting differentimportModuleDynamicallycallbacks for eachvm.Script.Reacted by ywave620In the latest node, the actual object passed as the second argument of
importModuleDynamically()callback is not the correct value forreferencingScriptOrModulementioned in the spec, do you agree? @legendecas The actual object is the initiatingvm.Script, while the correct one is the "active script or module".The value passed to the userland hooks is not the exact same value defined in the ecma262 host hooks. Node.js passes its own corresponding implementation-defined values to userland hooks.
Then this design does not make sense to me. It definitely leads to a memory leak. We have to keep track of every
vm.Scriptobject resulted fromvm.runInThisContext()because we do not know whether or when theimportwill be executedOnce the
vm.Scriptand all its corresponding script contexts are not reachable from GC roots, we can safely assert the script will not be evaluated again and reclaim its related resources. That's why we need to mark theContextifyScriptas a weak wrap.So this is a contradictory design, and that is why we have a crash in #43205 (comment). We need to pass the
vm.Scriptinstance to theimportModuleDynamically()as the 2nd argument, however, it may be GC'ed beforeimportModuleDynamically()is invoked.That is the problem that moving away from the id-based reference table and saving the wrap object in the script/module context is trying to resolve -- the solution illustrated at #44923 :)
I have a proposed fix for
vm.compileFunction()in #46785 - I think this should solve the leak for functions (when the importModuleDynamically is noop), but it needs module wraps to be fixed as well to be really useful (because usually one creates some modules in that callback).#44923 doesn't fix the leak of
vm.compileFunction()(#42080), by the way, because the problematic cycle described in the OP of #46785 still exists there, it just switches the reference through WeakMap key -> value (that's still strong reference) to a symbol property reference (also strong). The example in the OP here can actually do without thevm.compileFunction()call - just create lots ofvm.SyntheticModule()that seem to be unreachable once evaluated, and the process is still going to crash out of OOM, because the modules are also leaking.Here is my take of the whole issue of lifetime management around these wrappers. What we need to tell V8 is that:
- The
node::CompiledFnEntry/node::ContextifyScript/node::ModuleWrapJS wrappers must be kept alive when the compiledv8::Function/v8::Script/v8::Moduleis still executable - otherwise we get a use-after-free whenimport()is initiated within them. - The
node::CompiledFnEntry/node::ContextifyScript/node::ModuleWrapJS wrappers should be GC'ed once the compiled ``v8::Function/v8::Script`/`v8::Module` is no longer executable - otherwise we get a leak.
But how Node.js traces the native objects makes it difficult to inform V8's GC about this.
To achieve (1),
node::CompiledFnEntrymaintains a strong global reference to its JS wrapper, which we'll reset once the compiledv8::Functionis no longer reachable. But V8's GC isn't actually informed that the strong global reference is going to be reset once thev8::Functionis GC'ed (the "reclaim in weak callback" pattern doesn't actually tell V8's GC about this, because V8's GC obviously can't understand the semantics of a random C++ function), so in its eye the strong global reference just comes out of nowhere and shall remain reachable for who-knows-how-long. The compiled function is a legit JS value that can be passed around in JS, and in this case, as part of the API contract, it ends up in a closure that is referenced by the strongnode::CompiledFnEntrywrapper. So V8 then thinks this "out of nowhere" reference tonode::CompiledFnEntrywrapper in turn would keep the compiledFunctionreachable for who-knows-how-long, so it never attempts to GC that function, hence the weak callback is never going to be called to reset the strong global reference even when it's no longer accessible by the users, and we have the leak in (2). This isn't too difficult to fix, we can just switch to a proper GC-aware reference from the compiledv8::Functiontonode::CompiledFnEntryJS wrapper for (1) (both are legit JS values, so the existing API, e.g.v8::Object::Set()/v8::Object::SetPrivate()already allows us to easily inform V8's GC about this reference), and make the global reference to thenode:::CompiledFnEntryJS wrapper weak so that the compiled function is going to be the only thing that keeps thenode::CompiledFnEntryJS wrapper alive (2) in the eye of V8's GC, then it's fully capable of dealing with the cycle here.Things are a bit more complicated for
v8::Script/v8::Modulebecause they are not legit JS values, the existing V8 API does not allow us to create the GC-aware reference that we need. And that's why we need https://chromium-review.googlesource.com/c/v8/v8/+/3172764, because it gives us a way to create a GC-awarev8::Script/v8::Module->node::ContextifyScript/node::ModuleWrapJS wrapper reference using the new host defined options API (technically it's always possible, V8-internals-wise, to set this link up, V8 just doesn't provide the proper casts/APIs for the embedders to set them up, and the new host defined options API just happens to do the job).Currently to achieve (2),
node::ContextifyScriptmakes the strong global reference to its JS wrapper weak, so once the publicvm.ContextifyScriptis unreachable we will destroy the wrapper as well as the native object. But that's a bit too early, becausev8::Scriptcan still be executable whenvm.ContextifyScriptis unreachable, then V8 could GC thenode::ContextifyScriptwrapper too early, resulting in the a use-after-free segfault in (1) like #43205. With the new host defined options APIs, we'd be able to create a GC-awarev8::Script->node::ContextifyScriptJS wrapper reference similarly and fix the segfault.In the case of
node::ModuleWrap, to achieve (1) it currently maintains a strong global to its JS wrapper, and there's no way for V8 to know when to destroy them, so there is also the leak in (2) even if you just create a bunch ofvm.SyntheticModules. However migrating to the new host defined options API is not enough to achieve (2), because it has an additional strong global reference to thev8::Moduleitself (this isn't an issue for scripts becausenode::ContextifyScriptonly needs to referencev8::UnboundScript, which isn't where the host-defined options are stored). We still need to do something about it or otherwise V8 would think this is just going to keep thev8::Modulealive forever, which in turn keepsnode::ModuleWrapalive forever. I am not sure if #44923 does the job though - it tries to achieve (2) by making the global strong reference tov8::Moduleweak and then destructs thenode::ModuleWrapin the weak callback, but I think that might be a bit too early and it could run into (1) again, since if we make the global strong reference tov8::Moduleweak, there isn't actually anything else from the JS land that keeps it alive, but we need it to be held alive byvm.SyntheticModule(who holds a reference to thenode::ModuleWrapJS wrapper). I think what we need here is probably another way to create a GC-awarenode::ModuleWraptov8::Moduleback reference (instead of making it weak and using the weak callback - which, again, is not something that V8's GC understands).Reacted by Chengzhong Wu, Herbie Wildwood, Joseph Sutton, Andrii Oriekhov, Jimmy Guzman and Kirill GroshkovReacted by Valentin Semirulnik, Minijus L, Xuguang Mei, Simen Bekkhus, Andrew Schmadel, Maciej Holyszko, Herbie Wildwood, Andrii Oriekhov and Jimmy GuzmanReacted by Valentin Semirulnik- The
So I gave this another look while working on another memory issue and I found out why the original fix to vm.compileFunction crashed #47096 - the initial assumption was that we could rely on the
Functionreturned byvm.compileFunction()being alive to keepnode::CompiledFnEntryalive. But that was not true - the top-level function could already be GC'ed whileimport()is initiated inside, in this case V8 would derive the referrer from the calling function's script - and the calling function is not necessarily the top-level function (the one returned byvm.compileFunction()), it could just be the innermost function that needs a closure, which was the case in the reproduction of #47096, and that's why we got a use-after-free, because we saw that the top-level function was GC'ed and mistakenly thought that the rest can be discarded, which was actually too soon.And then I realized that we might not actually need
CompiledFnEntryat all. Its whole purpose was to maintain a "some host defined option" -> "top-level function" mapping. We have been using a number since V8 only allows primitives as host defined options. But with the stage-3 proposal that allows symbols (which are also primitives) to be used as weak map keys, we could use symbols to maintain the mapping. So as long as whatever that would initiateimport()is alive, it should keep that symbol alive, and we can use the weak map semantics to keep the wrappers that's supposed to be passed into the callback alive. And all these are known by V8, so V8 can detect cycles. WIP here #48510 - locally it fixes the original test and the regression that caused the revert forvm.compileFunction(). I also added a commit forModuleWrapthat made the OOM in the test case in the OP here go away locally, but will need more investigation to be certain.Reacted by Adrian Z., Thomas Dimson, Joe Lencioni, Rishi Sharma, Michael Kriese, CVO, Valentin Semirulnik, Kirill Groshkov, Aron Woost and Andrii OriekhovReacted by Thomas Dimson, aabtop and Rishi SharmaReacted by Ben Limmer, William Lahti, Thomas Dimson, Joe Lencioni, Will Slattum, Benjie, Rishi Sharma, Michael Kriese, Mac, Valentin Semirulnik and 7 more29 remaining items
Version
v18.6.0
Platform
Darwin
Subsystem
vm
What steps will reproduce the bug?
Run node with
node --experimental-vm-modules --max-heap-size=128 test.js, and "test.js" as:How often does it reproduce? Is there a required condition?
always
What is the expected behavior?
Should not crash as OOM.
What do you see instead?
The program crashed with OOM shortly.
Additional information
Opening this issue to track the problem #44198 (comment).