Skip to content

[JSC] import() adds the reaction that settles its promise to the loader's promise at the call - #1037

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/47703b81/import-links-its-promise-at-the-call
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/47703b81/import-links-its-promise-at-the-call

Conversation

@robobun

@robobun robobun commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • import() gives the program its own promise. resolve() links it to the loader's promise from a queued job (runtime/JSGlobalObjectFunctions.cpp:838).
  • Until that job runs, the loader's promise has no reaction, and AbstractModuleRecord::importPromiseGatesAsyncDependency() finds no module body that awaits it. Bun's test runner asks that walk.

Fix

  • For the loader's own promise (pending, marked as handled, no reaction), globalFuncImportModule() adds the reaction at the call. A program cannot make such a promise.
  • It does so only while then and the species of Promise are the originals: the job then calls nothing of the program's. Every other promise goes through resolve().
  • Verified: stress/bun-dynamic-import-links-its-promise-at-the-call.js. It fails without the change: reads of a Promise[Symbol.species] that is replaced after the call of import(): expected 0, got 1.

Background

  • A reaction is what then or an await leaves on a pending promise. The walk follows reactions from the loader's promise to the module body that awaits.
  • Considered: add the reaction with a replaced then too. stress/module-loader-promise-then-tampered.js (skipped, TODO(bun)) then passes, but Bun's plugins.test.ts expects the call of the replaced then.

Downsides

  • An import() in a module that require() loads in Bun settles one job earlier (Y1 Y2 Y3 H becomes Y1 Y2 H beside a chain of await null).
  • A species getter that a program installs after the call is no longer read for that import().
  • While then or the species is replaced, the walk does not find who awaits an import().
Notes

How this was run. The jsc and the bun below are the debug ASAN build of 264cf6d, with JSGlobalObjectFunctions.cpp of this change compiled with the lane's flags and linked in front of libJavaScriptCore.a. The disassembly of globalFuncImportModule in both programs shows the new calls.

Tests

  • stress/bun-dynamic-import-links-its-promise-at-the-call.js, in the default mode, with --useJIT=0, with --forceEagerCompilation=1 --useConcurrentJIT=0, and with low tier-up thresholds: it fails without the change and passes with it, in each.
  • The row that fails without the change installs a getter for Promise[Symbol.species] right after the call of import(). The job that resolve() queued read that getter when it ran. With no job, nothing reads it.
  • The other rows pass with and without the change. The test has three copies of its modules: one is loaded with the original Promise, one with a replaced Promise[Symbol.species], one with a replaced Promise.prototype.then. Each import() must settle at the same turn in all three, and the replaced getter and method must each get 17 calls.
  • stress/module-loader-promise-then-tampered.js stays skipped. It aborts with and without the change.
  • 148 files of JSTests about import(), the module loader and promises, each run once with and once without the change: the same exit code, but for the new test. They are 80 files of stress (*import*, module-loader-*, *thenable*, promise-*, async-await-tick-count.js) and 68 of modules (import, tla, top-level, dynamic or defer in the name).
  • Nothing in jsc asks importPromiseGatesAsyncDependency() between the call and the job. Bun's test runner does. Three tests of test/js/bun/test/mock/mock-module.test.ts in the Bun change have a mock factory ask in that job: they end with never finished loading on the old engine and pass on this one.

Queued jobs. A breakpoint on JSC::MarkedMicrotaskDeque::enqueue, --useJIT=0, a loop of 100 await import() of an evaluated module, less the count of a loop of 0: 13.00 jobs for each import() before, 12.00 after.

Where import() settles. A chain of await null counts its turns beside one import(). The number is the turn at which the import() settles. jsc, debug build:

module before after after, then replaced after, species replaced
evaluated 9 9 9 9
new 13 13 13 13
awaits at its top level 15 15 15 15
throws 13 13 13 13
threw before 3 3 3 3
missing 5 5 5 5
exports then 14 14 14 14
exports then, evaluated before 10 10 10 10

Bun (debug build), the same chain:

before after
evaluated / builtin / awaited before / threw before / exports then / missing 9 / 13 / 9 / 3 / 10 / 3 9 / 13 / 9 / 3 / 10 / 3
import() in an ES module that require() loads Y1 Y2 Y3 H Y4 Y1 Y2 H Y3 Y4
node:vm, importModuleDynamically returns a pending promise, the program adds a then to it after the call then, the job that it queues, import() settles the same

In the require() row the loader's promise settles before the job would have run. The reaction is on it by then, and that saves the job. Node 26.3.0 gives Y1 Y2 Y3 Y4 Y5 Y6 H for that row, and the same order as Bun for the node:vm row.

Async context. The reaction carries none, before and after: performPromiseThenWithInternalMicrotask() takes an async context only for a task without a cell, and this one has the program's promise as its cell. The job entered the async context of the call only to add the reaction.

The other conditions that were tried

  • Every pending promise whose then and species are the originals, marked or not. It changes the node:vm row: the reaction of import() then stands in front of the program's own then.
  • The loader's own promise, whatever then and the species are. A replaced then then gets no call for import(), and the walk finds who awaits an import() in a program that has replaced then or the species. With the condition of this PR it does not: resolve() hands the loader's promise to functions that the walk cannot follow. Four tests of Bun's plugins.test.ts ("patched Promise methods can't observe module loading") compare other ways to load a module with what import() shows to replaced methods, and they expect that call.
  • Return the loader's promise as upstream does. Bun then loses the report of an unhandled rejection, because the loader's promise is marked as handled.

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Preview build of ff829f0: autobuild-preview-pr-1037-ff829f05

@robobun
robobun force-pushed the robobun/47703b81/import-links-its-promise-at-the-call branch from fc0aece to c6770f9 Compare November 11, 2026 18:11
…er's promise at the call

globalFuncImportModule() gives the program a promise of its own, and the
loader's promise settles it. While the loader's promise was pending, this
went through resolve(): it queues a job (PromiseResolveThenableJobFast),
and that job adds the reaction to the loader's promise.

Until that job ran, the program awaited its promise and the loader's
promise had no reaction. A walk over the reactions of the loader's promise
found nobody in that time. AbstractModuleRecord::
importPromiseGatesAsyncDependency() is such a walk, and Bun's test runner
asks it which module a pending import() holds up.

The reaction is now added at the call where that job would call nothing
of the program's: the promise is the loader's own (pending, marked as
handled, no reaction on it), and `then` and the species of Promise are the
originals. In every other case resolve() runs as before: a promise that a
host passes on from a program (importModuleDynamically of node:vm), a
replaced `then` or species, a loader's promise that is rejected at the
call.

A pending import() queues one job fewer: 13 to 12 for an evaluated
module. import() settles where it did among the other jobs, except where
the loader's promise settles before that job would have run. In Bun that
is an import() in a module that require() loads: one job earlier. A
species getter that the program installs after the call is no longer
read for that import(): the job read it once.
@robobun
robobun force-pushed the robobun/47703b81/import-links-its-promise-at-the-call branch from c6770f9 to ff829f0 Compare November 11, 2026 18:42
@robobun robobun changed the title [JSC] import() settles its promise by a reaction on the loader's promise, added at the call [JSC] import() adds the reaction that settles its promise to the loader's promise at the call Nov 11, 2026
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