Skip to content

bake: do not rewrite a recorded module failure when require() hits top-level await - #37779

Closed
robobun wants to merge 6 commits into
mainfrom
farm/a5364c7f/hmr-require-tla-error-not-mutated
Closed

robobun wants to merge 6 commits into
mainfrom
farm/a5364c7f/hmr-require-tla-error-not-mutated

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In the dev server, require() of a module that uses top-level await (itself or through a static import) throws Cannot require "X" because "Y" uses top-level await, but 'require' is a synchronous operation. In two cases the X is the wrong module.
  • Nested: with outer.ts requiring inner.ts, which requires tla.ts, require("./outer") said Cannot require "outer.ts", a module that can be required, while the stack pointed at the require("./tla") in inner.ts.
  • Recorded failure: after an import() of a module has failed this way, an unrelated require() that hits the recorded failure renames it, and a later import() of the same module then names a module it never involved.
  • Cause: require() produced the message by overwriting message on the loader's own error and rethrowing it. That one object is shared with every enclosing require() and with the module's recorded failure, so each require() it passed through renamed it.

Fix

  • require() now throws a new plain Error carrying the message and leaves the caught loader error alone.
  • Why that is right: the internal error only ever leaves the loader through require(), so the conversion happens exactly once, at the require() that failed, and from there it propagates unchanged like any other evaluation error. bun build attaches its equivalent error at the same place.
  • Message text and error name are unchanged, so the direct and static-import cases report exactly what they did before. Two leftovers that only existed because the internal error escaped (a global Error.asyncId type augmentation and a name override) are deleted.
  • Verified by a new dev-server test that exercises the direct, static-import, nested and recorded-failure paths in one route. Against a bun without the fix it fails on the four renamed messages; with the fix it and the rest of the esm suite pass.

Background

  • The dev server (bake) ships its own module loader to the page. require(), import() and static imports of user modules all go through it, and this error message is produced there.
  • A module with top-level await is asynchronous and cannot be loaded by the synchronous require(). The loader reports this with an internal error naming the offending module, which may be a static dependency of the module that was required rather than that module itself.
  • When a module's body throws during evaluation, the loader records the error as that module's failure and rethrows the same object on every later load of it, so one error object can outlive the call that first threw it.
  • A required module's body may itself call require(), so one failing call unwinds through several require() frames, and every one of them sees the same error object.

[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/esm.test.ts
bun test v1.4.0 (b9547997f)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-F8oKG1
^[[0;30mdev|^[[0m Started development server: http://localhost:35809
^[[0;30mdev|^[[0m ^[[32mBundled page in 279ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 117ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded i
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-bP4f4p
^[[0;30mdev|^[[0m Started development server: http://localhost:38707
^[[0;30mdev|^[[0m ^[[32mBundled page in 3ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 2ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded in 1ms^[[0m^[[2m:^[[0m state.ts
(pass)  DEV:esm-1: live bindings with `var` [211.23ms]
^[[0;30mdev|^[[0m Started development server: http://localhost:36845
^[[0;
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/esm.test.ts
bun test v1.4.0 (b9547997f)

test/bake/dev/esm.test.ts:
Dev server testing directory: /tmp/bun-dev-test-Z7rv5y
^[[0;30mdev|^[[0m Started development server: http://localhost:34563
^[[0;30mdev|^[[0m ^[[32mBundled page in 148ms^[[0m^[[2m:^[[0m routes/index.ts ^[[2m+ 1 more^[[0m
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "routes/index.ts" is a root module that does not self-accept.
^[[0;30mdev|^[[0m ^[[32mReloaded in 79ms^[[0m^[[2m:^[[0m routes/index.ts
^[[0;30mdev|^[[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
^[[0;30mdev|^[[0m 
^[[0;30mdev|^[[0m Module "state.ts" is not accepted by routes/index.ts,
^[[0;30mdev|^[[0m ^[[36m[x2]^[[0m ^[[32mReloaded in
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     b9547997f3
  features     baseline

22 deps, 107 codegen, 1176 objects in 1021ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (da3851e57)

Checked 107 installs across 153 packages (no changes) [30.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (da3851e57)

Checked 1 install across 2 packages (no changes) [3.00ms]
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] fetch zlib
[zlib] up to date
[6/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (da3851e57)

Checked 129 installs across 147 packages (no changes) [23.00ms]
[8/1237] subst deps/zlib/zlib.h
[9/1237] gen .bind.ts → GeneratedBindings.cpp
[10/1237] subst deps/zlib/zconf.h
[11/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/
... (truncated)
diff hotspot
src/runtime/bake/hmr-module.ts | 14 +++-----
 test/bake/dev/esm.test.ts      | 76 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 81 insertions(+), 9 deletions(-)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                            reads  edits  tests
src/runtime/bake/hmr-module.ts      6      8      0
test/bake/dev/esm.test.ts           3      5      0

root cause · written by the author bot

HMRModule.require() handled an AsyncImportError by overwriting the caught object's message and rethrowing that same object, while the loader also stored that object by reference as the module's recorded failure and rethrew it on every later load, so each enclosing require() the shared object passed through renamed it after the module that was most recently required rather than the one that had actually failed. The fix has require() throw a fresh Error carrying the rewritten message instead of mutating the caught one, so a recorded failure keeps the message from the point where it was record…

Original description

What does this PR do?

In the dev server's module loader (src/runtime/bake/hmr-module.ts), HMRModule.require() turns the loader's internal AsyncImportError (thrown when the required module, or one of its static imports, uses top-level await) into the user-facing message by assigning e.message on the error it caught and rethrowing that same object.

The object is shared. A module whose body fails on such a require() records the error as its failure and rethrows the same object on every later load, so every require() the recorded error later propagates through renames it again, and the message ends up naming whichever module was require()d most recently:

// tla.ts:                   await 1; export const value = "tla";
// requires-tla.ts:          require("./tla"); export const value = 1;
// imports-requires-tla.ts:  import "./requires-tla"; export const value = 1;

await import("../requires-tla");
// Cannot require "tla.ts" because "tla.ts" uses top-level await, but 'require' is a synchronous operation.
// (recorded as requires-tla.ts's failure)
require("../imports-requires-tla");
// Cannot require "imports-requires-tla.ts" because "tla.ts" ...   (rewrote the recorded error in place)
await import("../requires-tla");
// Cannot require "imports-requires-tla.ts" because "tla.ts" ...   (wrong: this import never involved that module)

The in-place rewrite also renames the error once per enclosing require() when the failing require() is inside a required module's body. With outer.ts requiring inner.ts, which requires tla.ts, require("./outer") reported Cannot require "outer.ts" because "tla.ts" ..., naming a module that can be required, while the error's stack pointed at the require("./tla") in inner.ts.

require() now throws a new, plain Error with the message instead of mutating the caught one. AsyncImportError is only thrown by loadModuleSync, and require() is the only way out of the loader for it (import() and static imports go through loadModuleAsync, which never throws it), so the conversion happens exactly once, at the require() call that failed. From there the error propagates through enclosing require() calls, and through a module's recorded failure, unchanged, the same way every other error thrown while evaluating a module does. It is also where bun build reports the equivalent build error (the diagnostic in LinkerContext.rs is attached to the require() call whose import graph contains the top-level await), and this runtime is documented as mirroring bun build. The message text and the error's name ("Error" in both cases: AsyncImportError inherited it) are unchanged, so the direct and static-import cases, including the existing "cannot require a module with top level await" test, report exactly what they did before; only the two cases above change. As a side effect the browser console now shows Error: Cannot require ... with a stack starting at the user's require() call instead of the minified class name, the loader's internal frames and an asyncId property.

Also deleted, since the AsyncImportError instance no longer leaves the loader and these two lines were only there on account of it escaping: the declare global { interface Error { asyncId?: string } } augmentation at the bottom of the file (the only asyncId read is on the AsyncImportError instance itself, which declares the field; nothing needed the augmentation for type-checking), and the own-property name override in AsyncImportError's constructor (a subclass of Error already reports name === "Error" through the prototype, so the override changed nothing observable either). The catch (e: any) in require() is now a bare catch (e) like the other catch clauses in the file; instanceof narrows it.

Related: #37742 records failures on more loader paths and matches the module name loosely in its requireWithDepThatFailedToRequire case because of this. With this change a recorded failure is never an AsyncImportError, so that match can be exact and its state !== State.Error guard becomes redundant (it stays correct). The Cannot require expectations in #37759 and #37765 are direct requires and are unaffected.

How did you verify your code works?

New test in test/bake/dev/esm.test.ts, "the error for requiring a module with top level await names the require call that failed". One route handler goes through every path out of require()'s catch: a direct require() of a top-level-await module and one through a static import chain (both messages unchanged), the nested require() chain, and a failure recorded by import() that is then rethrown by a direct require(), by a require() of a static importer, and by a second import().

With USE_SYSTEM_BUN=1 it fails on the four renamed messages (outer.ts, requires-tla.ts, and imports-requires-tla.ts twice). With bun bd test it passes, as does the rest of test/bake/dev/esm.test.ts.

…stead of rewriting the caught one

HMRModule.require() produced its "Cannot require ..." message by assigning
e.message on the AsyncImportError it caught and rethrowing the same object.
That object is shared: a module whose body failed on such a require()
records it as its failure and rethrows it on every later load, so every
require() the error later passed through renamed it to whatever module that
call was requiring, and the failing require() inside a required module's
body was renamed once per enclosing require() as well.

require() is the only place an AsyncImportError leaves the loader, so it
now throws a new plain Error with the message once, at the require() call
that failed, and enclosing require() calls pass it through unchanged like
any other module evaluation error.
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 PM PT - Aug 12th, 2026

❌ @robobun, your commit b954799 has 3 failures in Build #93514 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37779

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

bun-37779 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready to merge from my side (code unchanged since cee958f; b954799 is an empty commit that re-ran CI).

Reproduced on main with the new test in test/bake/dev/esm.test.ts (USE_SYSTEM_BUN=1 bun test test/bake/dev/esm.test.ts -t "names the require call that failed"): after import() of a module whose body require()s a top-level-await module has failed, a require() of one of its importers renamed the recorded error to that importer, and the next import() of the failed module reported the importer's name. The test passes with the fix (bun bd test test/bake/dev/esm.test.ts, all 18 tests in the file), and the evidence check confirms it fails without the fix and passes with it on both the ASAN and release builds.

CI build 93514 finished with 189 lanes passed; test/bake passed on every lane that ran. The 3 failed lanes are test/cli/install/bun-install.test.ts (alpine aarch64), test/cli/install/migration/complex-workspace.test.ts (debian aarch64) and test/js/third_party/grpc-js/test-tonic.test.ts (windows 2019), all failing while downloading from github.com (ConnectionClosed on a tarball, sharp's libvips download hanging up, ECONNRESET fetching protoc); none of them load the dev server runtime this PR changes, and they have been reported separately. The 2 darwin 26 aarch64 lanes expired without finding an agent, as in every build today. I will not push again for these; the diff is not going to change.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c5930d26-a888-4f03-9d82-5178d88382aa

📥 Commits

Reviewing files that changed from the base of the PR and between 626034f and 6570657.

📒 Files selected for processing (2)
  • src/runtime/bake/hmr-module.ts
  • test/bake/dev/esm.test.ts

Walkthrough

HMRModule.require now creates a new error for top-level-await failures. A new ESM development test checks the message across direct, nested, and import-related failure paths.

Changes

Top-level-await require errors

Layer / File(s) Summary
Preserve require error context
src/runtime/bake/hmr-module.ts, test/bake/dev/esm.test.ts
HMRModule.require throws a new Error without mutating the caught AsyncImportError. The ESM test validates messages for multiple nested require() and import() attempts.
Possibly related PRs
  • oven-sh/bun#37765: Both changes handle top-level-await failures in hmr-module.ts and ESM tests.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main fix: preserving recorded module failures when require() encounters top-level await.
Description check ✅ Passed The description explains the problem, fix, background, and verification results, including the required template sections.

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

@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.

LGTM — the fix is correct and the test covers every propagation path (direct, static-import chain, nested require, recorded-failure rethrow). The inline note about the now-dead declare global { interface Error { asyncId } } block is a cleanup nit with zero runtime effect and doesn't block.

Extended reasoning...

Overview

This PR changes one code path in HMRModule.require() (src/runtime/bake/hmr-module.ts): instead of mutating e.message on a caught AsyncImportError and rethrowing that same object, it throws a fresh new Error(...) with the same message text. A new test in test/bake/dev/esm.test.ts exercises every path the error can propagate through.

Correctness

The bug is well-characterized: the caught AsyncImportError object is shared — it gets recorded as a module's failure in finishLoadModuleAsync and rethrown on every later load, so each subsequent require() that catches it via instanceof AsyncImportError renames it in place. Throwing a fresh plain Error means the conversion happens exactly once (at the innermost failing require()), and from there it propagates unchanged like any other module-body error — outer require() catches see a plain Error, fail the instanceof check, and rethrow untouched. I verified AsyncImportError is only thrown by loadModuleSync and only escapes the loader via require() (loadModuleAsync awaits async deps rather than throwing), so no other conversion site is needed. The message text and name are unchanged for the direct case, so the existing "cannot require a module with top level await" test is unaffected.

Security risks

None. This is dev-server (bake/HMR) error-message construction; no user input reaches new sinks.

Level of scrutiny

Low-to-medium. Dev-server-only TypeScript, ~6 lines of production change, well-isolated, thoroughly tested with exact-message assertions on a 7-case matrix. The PR description demonstrates the author traced every producer and consumer of AsyncImportError and cross-referenced the equivalent bun build diagnostic location.

Other factors

The one inline finding is a nit: the declare global { interface Error { asyncId?: string } } block near the bottom of the file documented a runtime shape (user-visible errors carrying .asyncId) that this PR removes. It's a type-only ambient declaration with no runtime footprint, and the actual .asyncId read at line 136 typechecks via the AsyncImportError class field regardless. Worth cleaning up per REVIEW.md's dead-code rule, but it doesn't affect correctness and shouldn't block the merge.

Comment thread src/runtime/bake/hmr-module.ts Resolved
Nothing reads asyncId off an Error anymore: the only error that carried it
no longer leaves the loader.
Comment thread src/runtime/bake/hmr-module.ts Resolved
Comment thread src/runtime/bake/hmr-module.ts Resolved
The instance no longer leaves the loader, so nothing observes its name.

@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.

LGTM — the two dead-code cleanups from earlier review rounds (the declare global augmentation and the name override in AsyncImportError) are now applied.

What was reviewed:

  • Traced AsyncImportError's only throw site (loadModuleSync) and only catch site (require()) — the instance can no longer reach user code or a recorded mod.failure, so both deletions are safe.
  • Checked the nested-require and recorded-failure propagation paths against the new test's expectations; the plain Error fails the instanceof AsyncImportError guard in every enclosing require(), so the message stays fixed.
  • Confirmed nothing else in src/runtime/bake/ reads .asyncId or imports loadModuleSync.
Extended reasoning...

Overview

This PR changes one catch block in src/runtime/bake/hmr-module.ts's HMRModule.require(): instead of mutating e.message on the caught AsyncImportError and rethrowing the same object, it throws a fresh new Error(...) with the same message text. Two pieces of code that only existed to shape the escaping AsyncImportError instance are deleted as now-dead: the Object.defineProperty(this, "name", { value: "Error" }) in its constructor and the declare global { interface Error { asyncId?: string } } type augmentation. A new devTest in test/bake/dev/esm.test.ts covers direct require, static-import-chain require, nested require chains, and the recorded-failure rethrow path (via both require() and import()).

Security risks

None. This is dev-server module-loader code that formats an error message; no auth, crypto, filesystem, or untrusted-input parsing is involved. The message interpolates bundler-resolved module ids, not user-controlled strings.

Level of scrutiny

Low-to-medium. The runtime change is a three-line semantic change (mutate-and-rethrow → throw-new) in development-only HMR code, plus two mechanical deletions of code the PR itself makes dead. I traced the control flow: AsyncImportError is thrown only inside loadModuleSync, which is reached only from HMRModule.require() (directly and via parseEsmDependencies recursion); no other file in src/runtime/bake/ imports loadModuleSync. So the conversion happens exactly once at the innermost failing require(), and from there the plain Error fails every enclosing instanceof AsyncImportError check and propagates unchanged — including through finishLoadModuleAsync's mod.failure = e recording. The existing direct-require test's message is byte-identical.

Other factors

Both prior review comments (from earlier runs of this reviewer) have been addressed in follow-up commits fdb315d and df79945, and the comment-cop nit about the long inline comment was cut down in 7694286. The new test asserts exact messages via toEqual on a JSON object rather than substring matches, follows the file's devTest/minimalFramework conventions, and the PR description confirms it fails under USE_SYSTEM_BUN=1 on the four renamed cases and passes with the fix. No outstanding reviewer comments remain.

@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.

LGTM — the two dead-code cleanups from earlier review rounds are in, and the final catch (e) change lets instanceof narrow correctly.

What was reviewed:

  • Traced every path an AsyncImportError can take: thrown only at hmr-module.ts:338 in loadModuleSync, reachable only via require()'s catch at :131, so the conversion to a plain Error happens exactly once and recorded failures are never AsyncImportError instances.
  • Walked each of the seven test cases through the loader (nested-require chain, recorded-failure rethrow via require/static-import/import) — all match the expected cannotRequire("tla.ts", "tla.ts") under the new code and diverge under the old.
  • Confirmed nothing else in src/runtime/bake/ reads .asyncId or references AsyncImportError, so the deleted name override and declare global block leave no dangling users.
Extended reasoning...

Overview

This PR fixes an error-message bug in the bake dev server's HMR module loader. HMRModule.require() previously converted the loader-internal AsyncImportError into the user-facing "Cannot require ... uses top-level await" message by assigning e.message on the caught object and rethrowing it. Because that same object could be recorded as a module's failure and rethrown on later loads, subsequent require() calls that propagated it would rename it in place, so the message ended up naming whichever module was required most recently rather than the call that actually failed. The fix throws a fresh new Error(...) instead of mutating the caught one, and deletes two pieces of code that only mattered when the AsyncImportError instance escaped to user code: the Object.defineProperty(this, "name", { value: "Error" }) override in its constructor, and the declare global { interface Error { asyncId?: string } } type augmentation. A new dev-server test in test/bake/dev/esm.test.ts covers all seven paths through the catch (direct require, require-through-static-import, nested require chain, recorded failure via import() then rethrown three ways).

Security risks

None. This is dev-server-only error-message formatting in the HMR runtime; it does not touch auth, crypto, permissions, or any production code path.

Level of scrutiny

Low-to-medium. The runtime change is three lines in a single catch block, plus two deletions of code this PR makes dead. The behavioral surface is narrow (one error message in one loader path) and the existing "cannot require a module with top level await" test still passes with identical output. I've reviewed this PR across three runs now — the first two flagged the dead declare global block and the dead name override, both of which were removed in fdb315d and df79945 respectively; the comment-cop paragraph-comment nit was addressed in 7694286; and the final commit cee958f is a trivial catch (e: any) → catch (e) cleanup that lets TypeScript's instanceof narrowing type e.asyncId without the global augmentation.

Other factors

I traced the control flow to confirm the PR description's claim that AsyncImportError never escapes the loader after this change: it is constructed only at hmr-module.ts:338 inside loadModuleSync, which is reached only from require() (directly or recursively via parseEsmDependencies), and require()'s catch now discards the instance. loadModuleAsync never throws it. The recorded mod.failure is therefore always a plain Error after this change, so the instanceof AsyncImportError check on a rethrown recorded failure is correctly false and the message is preserved. The new test asserts exact messages for every branch and the PR description confirms it fails on system Bun and passes on the debug build. The bug hunting system found nothing.

@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.

LGTM — both dead-code cleanups from earlier review rounds are in, and the fix itself is a minimal, well-tested change.

Checked that AsyncImportError is thrown only from loadModuleSync and that require() is its only exit from the loader, so the new Error conversion happens exactly once at the innermost failing require() and the recorded-failure / nested-require paths now propagate an immutable message. Verified catch (e) + instanceof narrowing reaches e.asyncId via the class's own field declaration (no need for the removed global augmentation), and that dropping the name override is a no-op since subclasses inherit Error.prototype.name === "Error". The new test exercises direct, static-import-chain, nested-require, and recorded-failure-rethrow paths with exact message assertions.

Extended reasoning...

Overview

Two files touched. In src/runtime/bake/hmr-module.ts, HMRModule.require() now throws a fresh new Error(...) when it catches the loader-internal AsyncImportError, instead of mutating e.message on the caught object and rethrowing it. The catch (e: any) becomes catch (e) (narrowed by instanceof). Two pieces of code that only existed because the AsyncImportError instance used to escape to user code are deleted: the Object.defineProperty(this, "name", { value: "Error" }) in its constructor and the declare global { interface Error { asyncId?: string } } augmentation. In test/bake/dev/esm.test.ts, a new devTest covers seven load paths and asserts exact error messages for each.

Security risks

None. This is dev-server-only TypeScript (the bake HMR runtime, bundled into the dev client/server), and the change affects only the text and identity of an error object thrown for a build-time-detectable condition (require of a TLA module). No auth, crypto, filesystem, or network surface.

Level of scrutiny

Low-to-moderate. The runtime change is effectively one line of semantics (e.message = ...; throw e → throw new Error(...)), and the mechanism is clear: the old code shared a mutable error object across a module's recorded failure and every enclosing require() catch, so later propagations renamed it. Grep confirms AsyncImportError is constructed only at loadModuleSync:338, loadModuleSync is reached only from require() (directly and via the recursive parseEsmDependencies call), and nothing else in src/runtime/bake/ reads .asyncId or references the class — so the conversion in require()'s catch is exhaustive and the two deletions are genuinely dead. The message text is byte-identical to before, and name remains "Error" (inherited from Error.prototype), so the existing "cannot require a module with top level await" test's expectations still hold.

Other factors

All three prior review comments on this PR (my two dead-code notes and the comment-cop's long-comment flag) are resolved in the current diff — the declare global block, the name override, and the multi-line comment are all gone/shortened. The new test is placed alongside the existing TLA-require test in esm.test.ts, uses minimalFramework + dev.fetch().json() like neighboring tests, asserts exact strings via toEqual on the full response object, and the PR states it fails under USE_SYSTEM_BUN=1 on the four renamed cases and passes with the fix. The PR also notes its interaction with #37742/#37759/#37765, none of which conflict. No outstanding reviewer concerns; the bug hunting system found nothing this run.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #39488, which consolidates the open Bake PRs from this period into one branch. It carries this PR's change and its test, reworked where needed (see the commit in #39488 that names this PR). Closing this one in its favor.

@robobun robobun closed this Aug 18, 2026
auto-merge was automatically disabled August 18, 2026 18:56

Pull request was closed

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.

2 participants