Repository navigation
Conversation
|
Preview build of ff829f0: |
robobun
force-pushed
the
robobun/47703b81/import-links-its-promise-at-the-call
branch
from
November 11, 2026 18:11
fc0aece to
c6770f9
Compare
…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
force-pushed
the
robobun/47703b81/import-links-its-promise-at-the-call
branch
from
November 11, 2026 18:42
c6770f9 to
ff829f0
Compare
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.
Problem
import()gives the program its own promise.resolve()links it to the loader's promise from a queued job (runtime/JSGlobalObjectFunctions.cpp:838).AbstractModuleRecord::importPromiseGatesAsyncDependency()finds no module body that awaits it. Bun's test runner asks that walk.Fix
globalFuncImportModule()adds the reaction at the call. A program cannot make such a promise.thenand the species ofPromiseare the originals: the job then calls nothing of the program's. Every other promise goes throughresolve().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
thenor anawaitleaves on a pending promise. The walk follows reactions from the loader's promise to the module body that awaits.thentoo.stress/module-loader-promise-then-tampered.js(skipped,TODO(bun)) then passes, but Bun'splugins.test.tsexpects the call of the replacedthen.Downsides
import()in a module thatrequire()loads in Bun settles one job earlier (Y1 Y2 Y3 HbecomesY1 Y2 Hbeside a chain ofawait null).import().thenor the species is replaced, the walk does not find who awaits animport().Notes
How this was run. The
jscand thebunbelow are the debug ASAN build of 264cf6d, withJSGlobalObjectFunctions.cppof this change compiled with the lane's flags and linked in front oflibJavaScriptCore.a. The disassembly ofglobalFuncImportModulein 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.Promise[Symbol.species]right after the call ofimport(). The job thatresolve()queued read that getter when it ran. With no job, nothing reads it.Promise, one with a replacedPromise[Symbol.species], one with a replacedPromise.prototype.then. Eachimport()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.jsstays skipped. It aborts with and without the change.JSTestsaboutimport(), 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 ofstress(*import*,module-loader-*,*thenable*,promise-*,async-await-tick-count.js) and 68 ofmodules(import,tla,top-level,dynamicordeferin the name).jscasksimportPromiseGatesAsyncDependency()between the call and the job. Bun's test runner does. Three tests oftest/js/bun/test/mock/mock-module.test.tsin the Bun change have a mock factory ask in that job: they end withnever finished loadingon the old engine and pass on this one.Queued jobs. A breakpoint on
JSC::MarkedMicrotaskDeque::enqueue,--useJIT=0, a loop of 100await import()of an evaluated module, less the count of a loop of 0: 13.00 jobs for eachimport()before, 12.00 after.Where
import()settles. A chain ofawait nullcounts its turns beside oneimport(). The number is the turn at which theimport()settles.jsc, debug build:thenreplacedthenthen, evaluated beforeBun (debug build), the same chain:
then/ missingimport()in an ES module thatrequire()loadsY1 Y2 Y3 H Y4Y1 Y2 H Y3 Y4node:vm,importModuleDynamicallyreturns a pending promise, the program adds athento it after the callthen, the job that it queues,import()settlesIn 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 givesY1 Y2 Y3 Y4 Y5 Y6 Hfor that row, and the same order as Bun for thenode:vmrow.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
thenand species are the originals, marked or not. It changes thenode:vmrow: the reaction ofimport()then stands in front of the program's ownthen.thenand the species are. A replacedthenthen gets no call forimport(), and the walk finds who awaits animport()in a program that has replacedthenor 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'splugins.test.ts("patched Promise methods can't observe module loading") compare other ways to load a module with whatimport()shows to replaced methods, and they expect that call.