Repository navigation
[JSC] Module loader: a load that a withdrawn termination cut leaves nothing that a later load waits for - #966
Conversation
|
Status The PR is reworked a second time after a self-review. It is the third of a stack: #975 (test hooks), #976 (link cleanup), then this one. Only the last commit is new. It stays a draft until a maintainer answers the two questions in the comment below. How to reproduce on In Bun 1.4.3, with import vm from "node:vm";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
let spin = true;
Bun.plugin({
name: "spin",
setup(build) {
build.onLoad({ filter: /dep\.mjs$/ }, () => {
if (spin) for (;;) {} // The vm timeout lands here, inside the fetch hook of the loader.
return { loader: "js", contents: "export const dep = 'from plugin';" };
});
},
});
try {
vm.runInNewContext("load()", { load: () => require("./root.mjs") }, { timeout: 100 });
} catch (e) {
console.log("caught", e.code); // ERR_SCRIPT_EXECUTION_TIMEOUT
}
spin = false;
console.log("second import:", (await import("./root.mjs")).dep); // never printedWith this head the second |
|
Preview build of d061c78: |
b37d4b7 to
6d21521
Compare
6d21521 to
7a095d5
Compare
71a1e59 to
da54582
Compare
|
@dylan-conway, two questions about 1. A load that was in flight when the termination came. What happens to it after the withdrawal?
With (a), a module that awaits such an This PR already does (c) for an 2. Which jobs
The PR stays a draft until then. I will rework it to the answers. |
da54582 to
b3a3c0a
Compare
7a095d5 to
7d08903
Compare
b3a3c0a to
ac90b80
Compare
7d08903 to
3def166
Compare
ac90b80 to
cb3fc7f
Compare
3def166 to
3859604
Compare
…othing that a later load waits for An embedder can withdraw a termination (VM::cancelTermination(): node:vm's `timeout` in Bun) and go on with the same VM and the same module loader. A termination begins at any exception check, so it can end a load step. The loader then kept state that made a later load of the same modules wait for ever: - JSPromise::rejectWithCaughtException() leaves a termination pending and the promise pending. So the module promise or the load promise of the entry whose step was cut was never settled, and a later import() of that module, or of a module that imports it, waited for it. - MicrotaskQueue::drainImpl() cleared the queue when a job ended with a termination. That dropped the jobs of every load in flight. Now: - MicrotaskQueue::clearAfterTermination() sets the jobs of the module loader (isModuleLoaderInternalMicrotask()) aside, on the default queue of the VM, and VM::cancelTermination() puts them back. They run at the next checkpoint and go on with the loads that were in flight. A termination that nobody withdraws drops them as before. - A step that a termination cuts ends its own load. A cut makeModule() takes its entry, which has no record, out of the registry. A cut loadRequestedModules() takes the load promise out of its entry. Both then do the same for every load that already waits for theirs (abandonLoadsWaitingFor()): each entry up to the roots keeps its record, and whoever asks for it next takes its own steps. - The abandoned promises are rejected with the loader, a value that script cannot have. An import() that waited at a root gets its job back on the queue and asks again at the next checkpoint. A load that evaluates its module (a synchronous load, the load of an entry point) is dropped: its caller got the termination. A step that begins to wait for an abandoned promise later asks again too (askAgainForAbandonedLoad()). The value reaches no registry entry and no caller. - A termination behind FinishLoadingImportedModule finds the module loaded, and the step settles its promise. - innerModuleLoading() ends a graph loading state whose drain an exception ended, also an exception that a host hook threw. moduleGraphLoadingError() has no trap check in front of the rejection of its state. - A synchronous load hands the jobs that it did not run to the synchronous load that it runs inside, if there is one, and else to the queue of the VM. When an exception is pending behind loadModule(), the first job of its own load is not handed on: nothing loads and evaluates the module for a caller that got the exception. - moduleLoadTopRejected() does not keep the error of a load in an entry that got no fetch. A fetch hook that throws, or a termination behind it, leaves such an entry, and the next load of the module starts the fetch. The termination stays pending in every case. An import() whose own job the termination cut stays pending, as before. In a VM that runs compiled code (aot/runtime), which has its own records of the loads, nothing changes. Tests: JSTests/stress/module-load-cut-short-by-termination.js, -graphs.js and -sync.js take a termination at each trap check of the load of six graphs, for import() and for loadModuleSync(), then load the same modules again. -nested.js cuts a load inside the fetch hook of another load.
cb3fc7f to
e6e90a9
Compare
3859604 to
d061c78
Compare
Related to #866, #492
Problem
node:vm'stimeoutcuts a module load, a laterimport()never settles. Onmainwith [JSC] Test hooks for a termination inside a module load: $vm.callWithTerminationAtTrapCheck(), $vm.moduleLoaderLoadSync(), setModuleLoaderFetchHook() #975:two, trap check 25, later loads 0: a later import() is still pending.JSPromise::rejectWithCaughtException()leaves the promise of the cut load step pending, and the registry entry keeps answering with it.MicrotaskQueue::drainImpl()clears the queue when a job ends with a termination. Every load in flight loses its jobs.Fix
VM::cancelTermination()puts them back on the VM's queue.abandonLoadsWaitingFor()).import()that waited asks the host again. Arequire()that was cut is not evaluated later.module-load-cut-short-by-termination*.jstests,testaot native module-programs.Background
RETURN_IF_EXCEPTIONpolls for it).VM::cancelTermination()withdraws it.Downsides
import()whose own job the termination cut stays pending, as onmain. A module that awaits it never finishes.--aot) is unchanged..text+8,256 B. A load that meets no termination: 50,159 to 50,180 instructions per freshimport().Notes
Draft until a maintainer answers questions 1 and 2 in the comment below. They also decide #866.
Stack. #975 (test hooks), #976 (link cleanup), then this PR. Only the last commit is new.
Who can reach it. There is no user report and no issue. Two routes reach the hang on released Bun:
require()of an ES module graph that anode:vmtimeoutorbreakOnSigintcuts in its load phase, then animport()of the same graph;An
import()alone under the limit did not reach it. Node v26.3.0 fails the first route withERR_INTERNAL_ASSERTION. oven-sh/bun#30598 and oven-sh/bun#33411 would add callers ofVM::cancelTermination(), and both are stale.What changed in this revision (the third). Self-reviewed a second time: 43 concerns raised. Addressed in the engine:
MicrotaskQueue::clearAfterTermination()sets the loader's jobs aside, andVM::cancelTermination()puts them back. A termination that nobody withdraws drops them as onmain. It applies to the default queue of the VM only, and not in a VM that runs compiled code.abandonLoadsWaitingFor()hands animport()that waited back to the queue, where it asks again. The job of a load that evaluates (a synchronous load, an entry point) is dropped.askAgainForAbandonedLoad()could get no promise from the host for a module of a cycle (ASSERT(loadedPromise), a null dereference in release). A step that waited for the requests of its module now loads them again itself, and the other steps drop the stale row of[[LoadedModules]]first.moduleLoadTopRejected()takes theThrowScope(the arm stopped a run with--validateExceptionChecks=true).moduleGraphLoadingError()is the upstream line again, with no trap check in front of the rejection.flags()is under the guard where [JSC] A load by name starts again when clearAll() overtook its fetch #853 adds the same accessor, so the two conflict in text.import(), twoimport()in flight, a second cut at three counts, a synchronous later load, the final state of eachimport()that was in flight, jobs of script that stay dropped, and a nested test that runs the ask-again arms throughsetModuleLoaderFetchHook()of [JSC] Test hooks for a termination inside a module load: $vm.callWithTerminationAtTrapCheck(), $vm.moduleLoaderLoadSync(), setModuleLoaderFetchHook() #975.Not addressed here, and needed in the Bun PR that takes this engine:
moveLoadedModule()andkeepPreloadModuleinBunPlugin.cppread "record and no pending load" as loaded. An abandoned entry is in that state. They must askentry->isLoaded().onLoad,Bun__onFulfillAsyncModule) have no exit for a termination. They can callmakeModuleWasCutShort().require()after a cut returns{}from Bun's require map (require(): remove the cache entry of a built-in whose load a node:vm timeout ended bun#47543 lists it as not fixed).require()of an ES module that imports a CommonJS module: a laterimport()rejects at 22 of 210 cut positions (Failed to evaluate module,Export named 'x' not found).evaluateCommonJSModuleOnce()inJSCommonJSModule.cppmoves the source out of the module object before it runs it. The engine is not involved: the result is the same with and without [JSC] A link that a termination cuts leaves no environment on a module that is linked again #976.What changed in the fourth revision. The loader code is the same. The tests no longer rely on the order of the cuts: a garbage collection drops what the VM compiled, the next loads of the same text have 4 to 22 more trap checks, and a walk then goes back some places. With
--largeHeapSize=1048576the walk of the third revision stopped ata module is stopped, and with a smaller count it was not. Each round now tells the kind of its cut from the modules that had begun to run. The tests pass with that option, with--slowPathAllocsBetweenGCs=100and with--collectContinuously=true. The hooks of #975 changed too: the call withdraws only its own termination, and no termination is pending before the n-th check.Measured on a debug build of this head (
--useJIT=0).--validateExceptionChecks=true.twowalk reachesloadAgainAfterAbandonedLoad()28 times, and the nested test reachesaskAgainForAbandonedLoad()22 times for each outer load.import(): a laterimport()of the root waits at 104 of 272 rounds, all in the part of the walk where the module is evaluated. The cause is the first bullet of Downsides.testaot native module-programs: 8 of 8.aot-follow-up-1031, WebKit 115632d plus this stack): the repro of the status comment printssecond import: from plugin, and Bun 1.4.3 hangs there. Three tests fortest/js/node/vm/vm.test.ts, which the Bun PR will add, pass.require()with six kinds of later load (1,200 runs): 0 later loads pending, 0 aborts, 0 modules evaluated twice. With no later load, the module of the cutrequire()was evaluated at the next checkpoint at 6 of 200 positions (4 onmain). A laterrequire()of the same root returned the{}placeholder at 146 of 200 positions: the Bun item above.Residue.
innerModuleLoading()ends its graph loading state when a host hook throws. A load that was in flight and ends later no longer goes on with that drain.require()of an importer from inside a fetch hook, with no termination, stops a debug build atASSERT(needsErrorReaction != module->loadedModules().contains(...))ininnerModuleLoading().Considered. Bun's
Bun__evictUnfinishedModules()(oven-sh/bun#44833), called where the time limit ends, passes the first Bun test on an engine without this PR. It evaluates modules twice at some cut positions, leaves animport()that was in flight pending, and cannot repair the shell.Order with the other open PRs. #975 and #976 first. #853 adds the same
flags()accessor: whichever lands second drops its copy. #941 edits the same lines ofloadModuleSync(). #492 rewrites the New arm, which this PR leaves as it is. #866 (evaluation) and this PR do not touch the same exits. With both merged, animport()of a stopped root that was in flight rejects withnullwhile the one that was cut waits: question 1 covers it.Cost for a load that meets no termination.
bin/jscofbun-webkit-linux-amd64-lto-aot(the flavor that Bun links), #976 (cb3fc7f) against this PR (3859604).llvm-size,llvm-nm --print-size, and user instructions fromperf_event_openwith--useJIT=0 --useConcurrentGC=0(median of 5):.text: +8,256 B (37,862,387 to 37,870,643). 53 symbols change size.hostLoadImportedModule(9,966 B),runInternalMicrotask(6,646 B),finishLoadingImportedModule(3,097 B),moduleLoadStep(3,073 B).innerModuleLoading: +28 B, the call in its exception exit.drainWithUseCallOnEachMicrotask: +16 B, the three cold arms.VM::setHasTerminationRequest()is inlined where a termination is taken:VMTraps::handleTraps()+73 B,waitSyncImpl()+43 B each. AnAtomics.wait()that times out runs 1,531 instructions with and without this PR.import(): 50,159 to 50,180 instructions (+21, 0.04%). Repeatedimport(): 12,698 to 12,696.mainb83d8a1 runs 109,747,212 and [JSC] Test hooks for a termination inside a module load: $vm.callWithTerminationAtTrapCheck(), $vm.moduleLoaderLoadSync(), setModuleLoaderFetchHook() #975 109,313,055, with no change of loader code between them and [JSC] A link that a termination cuts leaves no environment on a module that is linked again #976. So the builds differ by up to 2.4% there for other reasons, and this line shows no cost.getRegisteredMayBeNull(),endAbandonedLoad(),Deque::clear()) are called only from rejection arms, exception exits and the termination path.MicrotaskQueue(40 bytes). No allocation, queue entry or promise is added for a load that succeeds.aot/runtimechange size by the same inlining (Modules::startImport-503 B,Modules::evaluateSynchronously+243 B). They run only with an image.Release builds (3859604; the head since then, d061c78, differs only in
jsc.cppof #975). On theltobuild of linux amd64 the three loader tests take 93 to 143 ms and the nested test 20 ms. All four pass on theassertions-aotbuild too. On theltobuild of #976 (cb3fc7f) all four fail:two, trap check 25, later loads 0: a later import() is still pending,chain, trap check 25,two, trap check 26(synchronous), andsiblings, import(), trap check 27 of the load inside the hook: the outer load: root.js is still pending.