Skip to content

[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
robobun/85266cfa/module-environment-cleanup-scopefrom
robobun/85266cfa/module-load-cut-by-termination
Draft

robobun wants to merge 1 commit into
robobun/85266cfa/module-environment-cleanup-scopefrom
robobun/85266cfa/module-load-cut-by-termination

Conversation

@robobun

@robobun robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #866, #492

Problem

Fix

  • The loader's jobs stay on the VM's queue for the next checkpoint (MicrotaskQueue::clearAfterTermination()).
  • The cut step ends its own load. Its entry leaves the registry or gives up its load promise, and so does each load that waits for it (abandonLoadsWaitingFor()).
  • A step that starts to wait for an abandoned load afterwards asks the host again.
  • Verified: the two module-load-cut-short-by-termination*.js tests, testaot native module-programs, Bun's vm.test.ts.

Background

  • A termination stops script. It begins at any exception check (RETURN_IF_EXCEPTION polls for it). VM::cancelTermination() withdraws it.
  • A registry entry answers with a module promise (the record) and a load promise (the record with its imports loaded). One job settles each.
  • Considered: run the cut job again. That calls the cut fetch hook with no time limit.

Downsides

  • The import() that was cut, and one that already waited for the cut step, stay pending, as on main.
  • Kept jobs evaluate modules after the time limit. A VM with compiled code (--aot) keeps the hang.
  • Cost for a load that meets no termination: see Notes.
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.

  1. Does an import() that a withdrawn termination cut settle, and with what? Here it stays pending, as on main. [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.
  2. May the loader's jobs outlive a cut checkpoint? Here they do, so module bodies run after the time limit, as async work that the limited call started does. The alternative keeps only the four registry jobs and the pipe job.

Who can reach it. node:vm timeout and breakOnSigint around a require() or import(), and Ctrl+C in bun --interactive. Planned callers of VM::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 with ERR_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 testaot case with an image, and a pass under the debugger.

  • Rebased on main. The relaxed ASSERT of the New arm is in main (AOT: crashes and wrong answers found since #942, and faster builds of large functions #967), so that hunk is gone.
  • The New arm has no rollback any more. An entry that got no fetch stays, and 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.
  • The abandon value is the loader cell, not 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.
  • One keep predicate: 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 behind loadModule().
  • Dead arms removed. One removeIf per abandon. A debug-only post-condition on a cut load job (assertCutLoadJobLeftNoPromiseToWaitFor()).
  • The tests run with a loader per round (no path trick), without time limits, and check the cut import().
  • Not addressed: the gate for compiled code is still per VM. A VM that runs an image keeps the hang, also for plain files. The comment and this body say so.

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. On main with [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 24 and trap check 26 on 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.
  • Bun, debug build with this engine, $vm.callWithTerminationAtTrapCheck() around require() of a three-module graph: 200 cut positions, 6 later orders, 1,200 runs. 0 later loads pending, 0 aborts, 0 modules evaluated twice.
  • In that sweep the module of the cut require() is evaluated at the next checkpoint at 6 of 200 positions (4 on main, measured on an older base). A step that a termination cuts behind FinishLoadingImportedModule settles its promise, also the step of the root.
  • A load nested in a fetch hook, cut and withdrawn there (150 positions, outer import() and outer require()): every load that did not hit the assertion below has its module. On Bun 1.4.3 the outer import() stays pending.
  • testaot native module-programs: 8 of 8.

Residue.

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 lto and lto-aot builds of this head against main: .text, the size of hostLoadImportedModule, innerModuleLoading, runInternalMicrotask, the drain loop, and instructions per fresh and per repeated import.

@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
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 main with #975:

jsc --useDollarVM=1 --useJIT=0 JSTests/stress/module-load-cut-short-by-termination.js
Exception: Error: two, trap check 24, later loads 0: a later import() is still pending

In Bun 1.4.3, with root.mjs (export { dep } from "./dep.mjs";) and an empty dep.mjs beside this file:

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 printed

With this PR the second import() fulfils (debug build of Bun with this engine).

The PR is a draft until the cost lines are measured on the release builds of this head.

@github-actions

Copy link
Copy Markdown

Preview build of b37d4b7: autobuild-preview-pr-966-b37d4b7d

…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
robobun force-pushed the robobun/85266cfa/module-load-cut-by-termination branch from b37d4b7 to 6d21521 Compare November 1, 2026 12:35
@robobun robobun changed the title [JSC] Module loader: queued load jobs survive a cut checkpoint, and a load step that a termination cuts ends its own load [JSC] Module loader: the loader's jobs survive a cut checkpoint, and a load step that a termination cuts ends its own load Nov 1, 2026
@robobun
robobun changed the base branch from main to robobun/85266cfa/module-environment-cleanup-scope November 1, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant