Skip to content

module loader: coalesce concurrent embedder fetches of the same module - #33417

Open
robobun wants to merge 5 commits into
mainfrom
farm/a0f5d423/coalesce-in-flight-module-fetches
Open

module loader: coalesce concurrent embedder fetches of the same module#33417
robobun wants to merge 5 commits into
mainfrom
farm/a0f5d423/coalesce-in-flight-module-fetches

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Concurrent dynamic import()s of the same specifier run the module loader once per importer instead of once per module. A Bun.plugin onLoad handler fires N times, and a plain file is transpiled N times; the first result to settle wins the module registry, so the other loads' side effects happen but their output is thrown away.

Reproduction

let loads = 0;
Bun.plugin({
  name: "virt",
  setup(build) {
    build.onResolve({ filter: /.*/, namespace: "virt" }, a => ({ path: a.path, namespace: "virt" }));
    build.onLoad({ filter: /.*/, namespace: "virt" }, () => {
      loads++;
      return { contents: `export const n = ${loads};`, loader: "js" };
    });
  },
});

const mods = await Promise.all([import("virt:x"), import("virt:x"), import("virt:x")]);
console.log(loads, mods.map(m => m.n));
// bun 1.4.0: 3 [ 1, 1, 1 ]   <- loader ran 3x, module 1 won
// expected:  1 [ 1, 1, 1 ]

Not specific to virtual modules. The same three concurrent import("./mod.mjs") calls run a file-namespace onLoad three times, and without a plugin they transpile the file three times.

Cause

JSModuleLoader::loadModule's dynamic-import overload dedupes an in-flight load by looking up the module registry entry and reusing entry->ensureFetchPromise(). But the entry for a top-level dynamic import is not created until the first fetch settles, inside the ModuleLoadTopSettled microtask (provideFetchensureRegistered). Until then getRegisteredMayBeNull() returns null, so every importer that arrives in that window falls through to fetch()GlobalObject::moduleLoaderFetch → a fresh embedder fetch. Bun__onFulfillAsyncModule already carried a comment describing this; the FIXME there asked for exactly the behavior this PR implements.

The static-import (graph) path does not have the bug: it registers the entry and pipes the fetch promise into it before returning.

Fix

GlobalObject::moduleLoaderFetch keeps the in-flight embedder fetch promise and hands the same promise to later callers until it settles. The cache:

  • is keyed on (specifier, ScriptFetchParameters::Type, hostDefinedImportType), the same triple the module registry keys on, so it can never merge two fetches the registry treats as distinct modules (import(p) vs import(p, { with: { type: "text" } }), and import(p) vs import(p, { with: { unknown: "x" } }), stay separate);
  • drops its entry when the fetch promise settles, via a reaction attached before the loader attaches its own — the registry entry created by provideFetch takes over from there, so mock.module() and hot reload still see a clean slate;
  • lives on Zig::GlobalObject (one per worker / per --isolate global) as a JSMap, so the GC visits it with the rest of the global's members;
  • is cleared in GlobalObject::reload() alongside the module registry and requireMap.

Only the async branch changes. The vm.m_synchronousModuleQueue branch that require(esm) takes is untouched: it still re-issues its own synchronous fetch, which is what keeps require("./x.mjs") working while an import("./x.mjs") of the same file is mid-transpile.

Verification

bun bd test test/js/bun/resolve/concurrent-dynamic-import.test.ts — 9 pass. Four of them fail on the unfixed debug build (load: 3 where load: 1 is expected). New coverage:

  • a plugin-provided specifier, a file-namespace onLoad, and the failing-loader path (all three importers reject, loader ran once)
  • coalescing is per specifier, and a later import of an already-registered module adds no load
  • the two import-attribute cases above, which must not coalesce
  • require(esm) racing a dynamic import of the same module still resolves synchronously
  • mock.module() after a settled fetch still wins, proving the in-flight entry was released

Also green: test/js/bun/plugin/, test/js/bun/test/mock/, test/js/node/module/, test/js/bun/worker/, test/cli/hot/, and test/js/bun/resolve/ (the 3 failures there, load the same file 2000 times, load the same empty JS file 2000 times and require(esm) sync queue is a GC root, time out identically on main in this container).

GC stress

300 distinct virtual modules × 4 concurrent importers each, under BUN_JSC_collectContinuously=1 on a debug+ASAN build: exactly 300 loads, every module resolves to its own contents, no sanitizer reports.

Relationship to the other open module-loader PRs

Independent of #33184 (require(esm) racing a static import): that bug needs a registry entry already at Fetching, and this PR does not create one — the coalescing happens below the registry. The require(esm) race test added here passes on main too and is there as a guardrail.

The key triple matches where #32999 takes the registry's own key, so the two compose.

JSC's dynamic-import path calls moduleLoaderFetch without first registering
a module registry entry, so its in-flight dedupe has nothing to match until
the first fetch settles. Every importer that arrived before then started its
own fetch: three concurrent import()s of one specifier ran a plugin's onLoad
three times (and re-transpiled the file three times), with the first result
winning the registry.

Keep the in-flight embedder fetch promise per (specifier, fetch type,
host-defined import type) and hand the same promise to later callers until it
settles. The synchronous fetch path (require(esm)) is untouched and still
re-issues its own fetch.
@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:42 AM PT - Jul 6th, 2026

@robobun, your commit bb23ac5 has 1 failures in Build #68790 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33417

That installs a local version of the PR into your bun-33417 executable, so you can run:

bun-33417 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Dynamic Import fails when multiple Vite plugins import same file #22367 - Concurrent dynamic import() of the same file from multiple Vite plugins causes "Cannot access 'default' before initialization" — exactly the duplicate-fetch race this PR coalesces

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #22367

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 06f9bd48-9381-4dca-857c-8a7f137ab82c

📥 Commits

Reviewing files that changed from the base of the PR and between 4951432 and bb23ac5.

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h

Walkthrough

This PR adds in-flight module fetch coalescing in Zig::GlobalObject, reuses concurrent async fetches by key, clears tracked fetches on reload, updates a loader comment, and expands tests for concurrent dynamic import behavior.

Changes

In-flight module fetch coalescing

Layer / File(s) Summary
In-flight fetch state declarations
src/jsc/bindings/ZigGlobalObject.h
Adds in-flight fetch accessors, tracking/clearing methods, a new PromiseFunctions enumerator, updated promiseFunctionsSize, and a GC-tracked JSMap member.
Core in-flight fetch implementation
src/jsc/bindings/ZigGlobalObject.cpp
Initializes the in-flight fetch map, adds key building and settle handling, implements lookup/register/clear helpers, clears tracked fetches on reload, and maps the new promise handler id.
Async fetch path integration and comment update
src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ModuleLoader.cpp
moduleLoaderFetch() now reuses existing in-flight promises or tracks new async fetches, and the module-loader comment is shortened to match the settle behavior.
Concurrent dynamic import coalescing tests
test/js/bun/resolve/concurrent-dynamic-import.test.ts
Adds shared plugin/fixture helpers and coverage for coalescing, per-key isolation, rejection fanout, require/import racing, and post-settlement mock behavior.

Possibly related PRs

  • oven-sh/bun#29393: Shares the same Bun__onFulfillAsyncModule settlement/race path touched by the comment update here.

Suggested labels: jsc, module-loader, tests

Suggested reviewers: Jarred-Sumner, Electroid

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: deduping concurrent module fetches.
Description check ✅ Passed The description covers the bug, fix, and verification steps, including reproduction and test results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/resolve/concurrent-dynamic-import.test.ts`:
- Around line 65-244: These subprocess-spawning tests are still declared with
plain test() even though they all use runFixture/Bun.spawn and tempDir
filesystem I/O, so they should be converted to concurrent execution. Update each
test declaration in concurrent-dynamic-import.test.ts to use test.concurrent (or
wrap the group in describe.concurrent if appropriate) while keeping the existing
assertions and fixtures unchanged.
- Around line 214-244: The test is asserting the `bun test` banner on the wrong
stream; in this fixture the runner output is written to `stderr`, while `stdout`
may be empty. Update the `runFixture` assertion in `a settled fetch stops being
shared once the module registry owns it` to check the `stderr` value alongside
the existing pass/fail expectations, using the `stdout`, `stderr`, and
`exitCode` variables from the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4aa1c80d-1e64-4d25-9526-0266d01abdba

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca9ebc and c01acfb.

📒 Files selected for processing (4)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/bun/resolve/concurrent-dynamic-import.test.ts

Comment thread test/js/bun/resolve/concurrent-dynamic-import.test.ts Outdated
Comment thread test/js/bun/resolve/concurrent-dynamic-import.test.ts Outdated
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — went through all three. One applied, one declined with evidence, and the issue suggestion doesn't hold up.

test.concurrent — applied (c837440)

All 9 tests spawn their own subprocess into their own tempDir, so they're independent. Wall clock for the file went from 6.5s to 3.1s.

bun test banner stream — declined, the assertions are already on the right streams

The banner goes to stdout; the per-file results and the N pass / N fail summary go to stderr:

$ bun test x.test.ts 2>/dev/null          # stdout only
bun test v1.4.0 (c01acfb78)

$ bun test x.test.ts 2>&1 1>/dev/null     # stderr only

../../tmp/streamcheck/x.test.ts:
(pass) t [1.47ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [1.74s]

So expect(stderr).toContain("1 pass") and expect(stdout).toEqual(stringContaining("bun test")) are each checking the stream that actually carries them — an earlier revision of this test asserted stdout: "" and failed with the banner in the diff, which is how I found the split. test/cli/test/bun-test.test.ts uses stdout: "ignore" because it only wants the summary, not because the banner is on stderr. I've added a one-line comment there so the next reader doesn't have to rediscover it.

Fixes #22367 — not adding it, this PR doesn't fix that

I cloned the reproduction repo from #22367 and ran it against main:

$ bun --bun run build
minimalLoadConfigPlugin
some.config.js: { version: { name: "59add5d" } }
some.config.js: { version: { name: "59add5d" } }
vite v7.1.4 building for production...
✓ built in 107ms

It already passes on main, so there's nothing here to fix. The mechanism in that issue's stack trace was a microtask drain inside native spawnSync (processTicksAndRejections between spawnSync and loadConfig), which let the second importer read the namespace's default binding while the first importer's evaluation was still inside execSync. That drain is gone on main:

import { execSync } from "node:child_process";
let ran = false;
Promise.resolve().then(() => { ran = true; });
execSync("true");
console.log("microtask ran during execSync:", ran);  // false

Coalescing the duplicate fetch doesn't change the evaluation ordering that #22367 depended on — both importers already chained onto one loadPromise before this PR, because the second one's ModuleLoadTopSettled finds the registry entry the first one created. #22367 looks fixed by something else and can probably be closed on its own.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
test/js/bun/resolve/concurrent-dynamic-import.test.ts (1)

214-245: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Comment contradicts actual bun test subprocess output routing.

The new comment states bun test prints its banner to stdout and everything else to stderr. This is the opposite of documented/observed behavior: when bun test is run as a subprocess, the pass/fail summary and banner are written to stderr, while stdout remains empty. A prior review on this same test flagged exactly this stream mismatch (asserting the banner on the wrong stream), and that comment was not marked as addressed. If the assertion after this comment still checks stdout for the pass/fail text, it will be incorrect/flaky.

🔍 Supporting evidence

A confirmed GitHub issue on this exact behavior: "proc.stderr.toString() // result of \"bun test\"...All tests passed" / "proc.stdout.toString() // empty".

Please verify the assertion checks stderr (not stdout) for the pass/fail banner in this test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/resolve/concurrent-dynamic-import.test.ts` around lines 214 -
245, The subprocess stream assertion in the concurrent dynamic import test is
checking the wrong output stream for the bun test banner. Update the test around
runFixture in concurrent-dynamic-import.test.ts so the pass/fail summary and
banner are asserted on stderr, not stdout, and keep stdout only for any non-test
output; use the existing stderr/stdout checks in the test body as the place to
correct this stream expectation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@test/js/bun/resolve/concurrent-dynamic-import.test.ts`:
- Around line 214-245: The subprocess stream assertion in the concurrent dynamic
import test is checking the wrong output stream for the bun test banner. Update
the test around runFixture in concurrent-dynamic-import.test.ts so the pass/fail
summary and banner are asserted on stderr, not stdout, and keep stdout only for
any non-test output; use the existing stderr/stdout checks in the test body as
the place to correct this stream expectation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 55eaa4cb-6506-43b9-a26e-5c2c0fa05f81

📥 Commits

Reviewing files that changed from the base of the PR and between c01acfb and c837440.

📒 Files selected for processing (1)
  • test/js/bun/resolve/concurrent-dynamic-import.test.ts

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

The re-review re-raises the bun test stream point as a "Major" finding. It's the same one CodeRabbit withdrew on the inline thread ("Thanks for the correction … Withdrawing this one"), so flagging it again for anyone skimming the latest review: the comment in the test is right, and the assertions are on the streams that actually carry the output.

The banner and the summary go to different streams, which is what the finding conflates:

$ bun test x.test.ts 2>/dev/null          # stdout only
bun test v1.4.0 (c8374408)

$ bun test x.test.ts 2>&1 1>/dev/null     # stderr only

x.test.ts:
(pass) t [1.47ms]

 1 pass
 0 fail

The test asserts stderr for 1 pass / 0 fail and stdout for the banner, and it passes. If they were swapped it would fail — an earlier revision asserted stdout: "" and failed with the banner in the diff, which is how the split was found in the first place. Both measurements above are already the non-TTY subprocess case the fixture uses (stdio: ["ignore", "pipe", "pipe"]).

No code change for this one. The test.concurrent suggestion from the same review was applied in c837440.

Comment thread test/js/bun/resolve/concurrent-dynamic-import.test.ts
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both points addressed.

Dead if (!cached) in inFlightModuleFetch — correct, fixed in 4951432

Checked it against the vendored source rather than the repo comment, because JSOrderedHashTable::getImpl does return an empty JSValue on a miss. The wrapper is what normalizes it:

// vendor/WebKit/Source/JavaScriptCore/runtime/JSOrderedHashTable.h
ALWAYS_INLINE JSValue get(JSGlobalObject* globalObject, JSValue key)
{
    JSValue result = getImpl(globalObject, ...);
    return result.isEmpty() ? jsUndefined() : result;   // <-
}

So !cached is never true and the branch is dead, exactly as you said. Collapsed to the single expression — the downcast is the miss check, since jsUndefined() isn't a cell. Kept a two-line comment at the site: "a miss yields jsUndefined(), never an empty JSValue, so the downcast is the miss check" is the non-obvious fact that made the dead branch look reasonable to begin with, and the caller's RETURN_IF_EXCEPTION still covers the pending-exception case (where get also returns jsUndefined()).

Leaving the pre-existing instances of the same pattern alone (if (entryValue) after requireMap()->get() in BunPlugin.cpp, if (entry) in JSCommonJSModule.cpp) — harmless there, and not this PR's bug class.

test.concurrent — already applied in c837440

Same suggestion came from CodeRabbit earlier in the review; all 9 tests are test.concurrent now. Wall clock for the file went 6.5s → 3.1s.

Re-verified after the change: 9/9 pass, and the GC stress (300 virtual modules × 4 concurrent importers under BUN_JSC_collectContinuously=1 + ASAN) still reports exactly 300 loads.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Clearing the map on reload cannot detach the settle reactions already
attached to the promises it held. A fetch still in flight across a reload
would settle later and remove its key from the map, evicting the entry a
post-reload fetch had registered for the same specifier.

Put the generation in the key so the stale reaction removes a key that is
no longer live. Costs a few characters on a string the fetch already builds.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Stale settle reaction across --hot reload — fixed in bb23ac5

Good find. Verified the premise before acting: reload() keeps the same GlobalObject (hence the same JSMap), and clearInFlightModuleFetches() only empties the map — the settle reaction lives on the promise, so it survives the clear and later removes by key alone, evicting whatever a post-reload fetch registered at that key.

Fixed by putting a generation in the flattened fetch key and retiring it on clear. Pre-reload fetches are keyed 0:…, post-reload 1:…, so the stale reaction removes a key that names nothing. No reload means generation stays 0 and nothing changes.

Chose this over the identity check because passing the promise as reaction context is ambiguous: performPromiseThenWithContext wraps a non-null userContext in an InternalFieldTuple only when m_asyncContextData is live, and the dispatcher unwraps one level unconditionally — hand it an InternalFieldTuple and it gets mis-unwrapped as (key, asyncContext) whenever no async context is active. The generation rides in a string the fetch already builds, so it costs no allocation.

No test: reproducing the window needs --hot, a file change while a fetch is mid-transpile, and a post-reload concurrent import landing in a specific microtask gap. That race is only deterministic with instrumentation in src/, so a test would be flaky rather than load-bearing. test/cli/hot/ (15 pass) covers the reload() path itself.

On the CI red

Build 68760 had two failures, neither in this diff:

  • darwin 26 aarch64 - test-bunbuildkite-agent artifact download timed out after 120s. It never executed a test.
  • ubuntu 25.04 x64-baseline - test-buntest/js/bun/util/v8-heap-snapshot.test.ts killed by SIGKILL, no core file (an external kill: OOM or runner timeout, not an assertion).

The second one deserved more than a shrug, since the only way this PR could bloat a heap snapshot is if the in-flight map retained its promises. So I measured it rather than assuming. Same probe, 400 specifiers × 3 concurrent importers, Bun.gc(true) then heapStats().objectTypeCounts.Promise:

build retained promises
this branch delta: 1600
same branch, my src/ changes reverted to the base commit delta: 1600

Identical. The 4-per-module is entirely JSC's registry entry (m_fetchPromise + m_modulePromise + m_loadPromise, plus the import chain) — the map retains nothing once a fetch settles, which is also what the mock.module test asserts behaviorally. So this PR has no steady-state memory footprint and cannot be the cause.

v8-heap-snapshot.test.ts JSON.parses a whole V8 heap snapshot and materializes every node and edge as JS objects; it is the kind of test that gets OOM-killed under parallel load. This push re-runs CI, which should give a clean read on that lane.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generation counter in bb23ac5 closes the --hot stale-reaction window I flagged — nice fix. I'm deferring rather than approving because this is core module-loader C++ (JSC promise reactions, GC-visited state on ZigGlobalObject, interaction with require(esm)'s sync queue and hot reload), which is worth a human pass even with all nits addressed.

Extended reasoning...

Overview

This PR adds an in-flight fetch coalescing cache to Zig::GlobalObject so that N concurrent import() calls of the same specifier share one embedder fetch instead of each re-running the loader/transpiler. Touches ZigGlobalObject.{h,cpp} (new JSMap lazy property, key builder, settle reaction, PromiseFunctions enumerator, reload() clear + generation counter), ModuleLoader.cpp (comment-only), and adds 8 subprocess tests to concurrent-dynamic-import.test.ts.

Since my last review the author addressed all three of my prior comments: test.concurrent (c837440), the dead if (!cached) guard (4951432), and the --hot stale-reaction eviction race — fixed in bb23ac5 by prefixing the fetch key with a generation counter that clearInFlightModuleFetches() bumps, so a pre-reload settle reaction removes a key that no post-reload entry can occupy. That's the exact hardening I suggested and it reads correctly.

Security risks

None identified. The cache is per-GlobalObject (per worker/isolate), keyed on internal module-registry inputs, and only affects whether a redundant fetch is issued — not what any importer receives. No user-controlled data reaches a new trust boundary.

Level of scrutiny

High. moduleLoaderFetch is on the hot path for every dynamic import; the change wires a GC-visited JSMap into ZigGlobalObject, attaches promise reactions via performPromiseThenWithContext, and interacts with hot reload, mock.module(), and the vm.m_synchronousModuleQueue branch that require(esm) takes. The design (key triple mirrors the module registry key, entry dropped on settle before the loader's own reaction runs, sync path deliberately bypassed) is well-reasoned and the test matrix is thorough, but this is exactly the class of change where a maintainer who owns the module loader should confirm the layering and the interaction with #33184/#32999 mentioned in the description.

Other factors

Test coverage is strong (9 tests: virtual + file namespaces, failure fanout, per-key isolation, both import-attribute distinctions, require(esm) race guardrail, mock.module invalidation), the author ran a GC-stress pass under collectContinuously + ASAN, and CodeRabbit's actionable feedback was applied. No bugs were found by the bug hunter this round. I'm not approving solely because module-loader semantics changes are outside the "simple/mechanical" bar for auto-approval, not because of any outstanding concern with the code.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, one CI lane is stuck on infra

Build 68790 at bb23ac5: 281 jobs passed, 1 failed.

The one failure is darwin 26 aarch64 - test-bun, and it never executed a test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Same agent (darwin-aarch64-26-3-1-1), same 120s artifact-download timeout, on build 68760 and 68790 — two different commits. That reproduces deterministically, so re-pushing to re-roll would just land on it again. Needs someone with write access to re-run the job, or for that agent to get unstuck.

The one test-level failure from the previous build was a flake, and I checked rather than assumed

Build 68760 also had test/js/bun/util/v8-heap-snapshot.test.ts killed by SIGKILL (no core) on ubuntu 25.04 x64-baseline. On build 68790 all 68 x64-baseline test jobs passed and that annotation is gone — no error annotations at all this build.

Before writing it off I measured the only way this PR could plausibly bloat a heap snapshot (the in-flight map retaining its promises). 400 specifiers × 3 concurrent importers, Bun.gc(true), then heapStats().objectTypeCounts.Promise:

build retained promises
this branch delta: 1600
identical branch, src/ reverted to the base commit delta: 1600

Identical — the 4-per-module is entirely JSC's own registry entry (m_fetchPromise + m_modulePromise + m_loadPromise, plus the import chain). The map retains nothing once a fetch settles. v8-heap-snapshot.test.ts JSON.parses an entire V8 heap snapshot and materializes every node and edge as JS objects; it gets OOM-killed under parallel load.

Where the PR stands

Ready for review whenever someone has a moment.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI result for bb23ac5: 284 passed, 2 failed — both on darwin lanes, neither from this diff

Build 68790 finished. Updating my earlier comment, which was written while it was still running and only saw the first failure.

1. darwin 26 aarch64 - test-bun — infra, never ran a test

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Same agent (darwin-aarch64-26-3-1-1), same 120s timeout, on builds 68760 and 68790 — two different commits. It reproduces deterministically, so a re-push wouldn't clear it.

2. darwin 14 x64 - test-bun — a flaky PTY test, checked rather than assumed

test/js/bun/terminal/terminal.test.ts, 88 pass / 1 fail: Bun.spawn with terminal option > creates subprocess with terminal attached timed out at 90s. The test awaits a marker from the terminal data callback with nothing wiring process exit to a rejection, so if the PTY read loses a race it hangs until the timeout rather than failing fast.

This one spawns bun -e, so it was worth ruling out a module-loader hang rather than waving at it:

  • 16 interleaved runs of the test file, 8 per binary (my branch vs. the same branch with src/ reverted to the base commit): 0 failures on either. Earlier runs that did fail were the first run after a rebuild, i.e. a busy machine; the failures scattered across different PTY tests each time, which is the signature of a load-sensitive timeout rather than a regression.
  • Subprocess startup, 40 spawns each, interleaved: median 685.9 ms without the fix, 691.8 ms with — a ~6 ms delta against ±150 ms round-to-round variance. No meaningful cost, so the "extra startup tips a marginal timeout" theory doesn't hold either.
  • bun -e "console.log(...)" × 60: all 60 printed correctly, no hang. A hang in moduleLoaderFetch would be ubiquitous, not one PTY test on one agent — the other 284 jobs spawn bun -e constantly and passed.

Where that leaves it

Every test lane is green except two darwin agents: one that cannot download the build artifact, and one that lost a PTY race. The previous build's v8-heap-snapshot.test.ts SIGKILL did not recur, consistent with the promise-retention A/B in my earlier comment (1600 retained both with and without the fix).

Not re-pushing to re-roll: the aarch64 artifact timeout is deterministic on that agent, so a re-roll would clear the PTY flake and keep the red. Needs someone with write access to re-run the two darwin jobs, or to merge past them.

The diff itself is unchanged and green: 9 tests in concurrent-dynamic-import.test.ts (4 fail without the fix), all review threads resolved, every actionable bot finding applied.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant