Repository navigation
[JSC] Module loader: the loader's jobs survive a cut checkpoint, and a load step that a termination cuts ends its own load - #966
Draft
robobun wants to merge 1 commit into
Conversation
Collaborator
Author
|
Status The PR is reworked after a self-review. It is now the third of a stack: #975 (test hooks), #976 (link cleanup), then this one. Only the last commit is new. 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 PR the second The PR is a draft until the cost lines are measured on the release builds of this head. |
|
Preview build of b37d4b7: |
…a load step that a termination cuts ends its own load 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() keeps the jobs of the module loader (isModuleLoaderInternalMicrotask()) on the queue of the VM. They run at the next checkpoint and go on with the loads that were in flight, as the rest of a synchronous queue already does. - 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 import() or the top-level load at the root stays pending. - The abandoned promises are rejected with the loader, a value that script cannot have. A step that begins to wait for one of them afterwards is code that runs after the withdrawal. It asks the host again with its own promise (askAgainForAbandonedLoad()), and the continuation of a top-level load or of an import() does the same. 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. moduleGraphLoadingError() rejects the promise of its state also when a termination begins while it copies the error. - loadModuleSync() hands back what its queue holds when an exception is pending behind loadModule(), except the first job of its own load: 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. In a VM that runs compiled code (aot/runtime), which has its own records of the loads, the step exits are as before. Tests: JSTests/stress/module-load-cut-short-by-termination.js (import() and the checkpoint that runs its jobs) and module-load-cut-short-by-termination-sync.js (loadModuleSync()) take a termination at each trap check of the load of five graphs, then load the same modules again.
robobun
force-pushed
the
robobun/85266cfa/module-load-cut-by-termination
branch
from
November 1, 2026 12:35
b37d4b7 to
6d21521
Compare
robobun
changed the base branch from
main
to
robobun/85266cfa/module-environment-cleanup-scope
November 1, 2026 12:36
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Related to #866, #492
Problem
node:vm'stimeoutcuts a module load, a laterimport()of those modules never settles. No user has reported it. Onmainwith [JSC] $vm.callWithTerminationAtTrapCheck() and $vm.moduleLoaderLoadSync(): test hooks for a termination inside a module load #975:two, trap check 24, 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
MicrotaskQueue::clearAfterTermination()).abandonLoadsWaitingFor()).module-load-cut-short-by-termination*.jstests,testaot native module-programs, Bun'svm.test.ts.Background
RETURN_IF_EXCEPTIONpolls for it).VM::cancelTermination()withdraws it.Downsides
import()that was cut, and one that already waited for the cut step, stay pending, as onmain.--aot) keeps the hang.Notes
Stack. This PR is the third of three: #975 (test hooks), #976 (link cleanup), then this one. Only the last commit is new. It is a draft until the cost lines below are measured on the release builds of this head.
Question for a maintainer (@dylan-conway, @Jarred-Sumner), which also decides #866.
import()that a withdrawn termination cut settle, and with what? Here it stays pending, as onmain. [JSC] A module that a termination stops records null and rejects its capability with null #866 rejects the capability of a module that was stopped in its evaluation.Who can reach it.
node:vmtimeoutandbreakOnSigintaround arequire()orimport(), and Ctrl+C inbun --interactive. Planned callers ofVM::cancelTermination(): oven-sh/bun#30598 (bun test --timeout) and oven-sh/bun#33411 (REPL Ctrl+C). There is no user report. Node v26.3.0 fails the same scenario withERR_INTERNAL_ASSERTION.What changed since the first revision. Self-reviewed: 11 concerns raised, 10 addressed. Not addressed: the gate for compiled code is still per VM (last bullet). Not done of the coverage that was asked for: a
testaotcase with an image, and a pass under the debugger.main. The relaxedASSERTof the New arm is inmain(AOT: crashes and wrong answers found since #942, and faster builds of large functions #967), so that hunk is gone.moduleLoadTopRejected()does not keep the error of a load in such an entry. The first revision removed the entry by its state and evaluated a module twice when a fetch hook loaded its own importer.undefined. A step that starts to wait for an abandoned load afterwards asks the host again, so nothing caches the value and no caller sees it.moduleGraphLoadingError()rejects the promise of its state also when a termination begins while it copies the error.isModuleLoaderInternalMicrotask(), the one that the synchronous queue uses.loadModuleSync()does not hand back the first job of its own load when an exception is pending behindloadModule().removeIfper abandon. A debug-only post-condition on a cut load job (assertCutLoadJobLeftNoPromiseToWaitFor()).import().Measured on a debug build of this head (
--useJIT=0).module-load-cut-short-by-termination.js: 8 walks plus the failing graph.-sync.js: 7 walks. Both pass, also with--validateExceptionChecks=true. Onmainwith [JSC] $vm.callWithTerminationAtTrapCheck() and $vm.moduleLoaderLoadSync(): test hooks for a termination inside a module load #975 both fail at the first load-phase cut:trap check 24andtrap check 26on the release build that CI made for [JSC] InitializeEnvironment: arm the cleanup of the module environment before the step that sets it #976, one check later on a debug build.$vm.callWithTerminationAtTrapCheck()aroundrequire()of a three-module graph: 200 cut positions, 6 later orders, 1,200 runs. 0 later loads pending, 0 aborts, 0 modules evaluated twice.require()is evaluated at the next checkpoint at 6 of 200 positions (4 onmain, measured on an older base). A step that a termination cuts behind FinishLoadingImportedModule settles its promise, also the step of the root.import()and outerrequire()): every load that did not hit the assertion below has its module. On Bun 1.4.3 the outerimport()stays pending.testaot native module-programs: 8 of 8.Residue.
import()stays pending at 21 of 126 cut positions of theawaitgraph. That is the evaluation, which [JSC] A module that a termination stops records null and rejects its capability with null #866 owns. The test allows it only in the evaluation part of the walk.import()that waits for such a job stays pending.require()after a cut returns{}from Bun's require map. That is require(): remove the cache entry of a built-in whose load a node:vm timeout ended bun#47543.require()of an importer from inside a fetch hook, with no termination at all, stops a debug build atASSERT(needsErrorReaction != module->loadedModules().contains(...))(JSModuleLoader.cpp:1192).Order with the other open PRs. #975 and #976 first. #866 (evaluation) and this PR do not touch the same exits. #492 rewrites the New arm, which this PR now leaves as it is. #427 partitions the same queue: the keep rule is one function, and a set-aside works on top of it.
Cost for a load that meets no termination. To be measured on the
ltoandlto-aotbuilds of this head againstmain:.text, the size ofhostLoadImportedModule,innerModuleLoading,runInternalMicrotask, the drain loop, and instructions per fresh and per repeated import.