resolver: add bun:main to HardcodedModule.Alias (fix flaky test)#29719
Conversation
…alled
`bun:main` was in `HardcodedModule.map` but missing from
`Alias.bun_extra_alias_kvs`, so the runtime transpiler's import-record
pass (RuntimeTranspilerStore.zig:534) didn't recognise it as a builtin
and stripped the `bun:` prefix, leaving a bare `import("main")` in the
emitted JS. At runtime that fell through `_resolve` to
`resolveAndAutoInstall`, which fetched the npm `main` package over the
network. The bun-main-entry-point tests "passed" by loading that npm
package and flaked whenever the registry fetch was slow or failed.
Adding the alias keeps the specifier intact so it round-trips through
the existing `_resolve` / `getHardcodedModule` paths and resolves to the
ServerEntryPoint wrapper as intended.
Also harden the tests: disable auto-install via an empty package.json,
assert the wrapper namespace has no exports (the npm package has four),
collapse the assertions into one toEqual so a future failure surfaces
stderr/exit/signal, and update the preload test's expected output order
now that import("bun:main") actually evaluates the wrapper (entry runs
before the preload's await resumes).
|
Updated 2:08 PM PT - Apr 25th, 2026
❌ @dylan-conway, your commit 33287f5 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 29719That installs a local version of the PR into your bun-29719 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis pull request adds support for the Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
LGTM — one-line alias-table addition matching its siblings, plus test hardening; the inline nit about process.getBuiltinModule is non-blocking.
Extended reasoning...
Overview
One production line in src/bun.js/HardcodedModule.zig: adds bun:main to bun_extra_alias_kvs, alongside identically-shaped entries for bun:app, bun:ffi, bun:jsc, bun:sqlite, bun:wrap, and bun:internal-for-testing. The rest is test hardening in test/js/bun/resolve/bun-main-entry-point.test.ts (disable auto-install via package.json, tighten assertions, fix expected output order now that the wrapper actually evaluates).
I confirmed the diagnosis against src/bun.js/RuntimeTranspilerStore.zig:527-538: without this alias, Alias.get("bun:main", ...) misses, falls through to the hasPrefixComptime("bun:") branch that strips the prefix to bare "main", which then resolves/auto-installs the npm main package. Adding the alias makes it hit the continue at line 531 like every other bun:* builtin.
Security risks
None introduced — if anything this closes a minor supply-chain footgun where import("bun:main") could fetch an arbitrary npm package named main over the network. No auth, crypto, permissions, or untrusted-input parsing is touched.
Level of scrutiny
Low. The production change is a single comptime string-map entry following the exact pattern of six adjacent siblings; bun:main was already present in HardcodedModule.map and the enum, so this just brings the Alias table into agreement. Neither file is covered by CODEOWNERS.
Other factors
- The bug-hunter's nit (a small per-call
WTF::StringImplleak whenprocess.getBuiltinModule("bun:main")is called) is real but negligible: that call returnsundefined, so there's no reason to loop on it, and the leak is in pre-existingModuleLoader.cppcleanup logic rather than this diff. It's appropriately filed as a nit and doesn't block the flake fix. - The updated preload-test expectation (
ENTRY_OK\nPRELOAD_OK\n) is a behavior change, but it's the correct behavior now thatimport("bun:main")actually evaluates the wrapper instead of an npm package — the old order was an artifact of the bug. - PR description shows 30 consecutive Windows runs green plus passes on the related
import-meta,node-module-module, andbun-serve-html-entrysuites.
| .{ "bun:app", .{ .path = "bun:app" } }, | ||
| .{ "bun:ffi", .{ .path = "bun:ffi" } }, | ||
| .{ "bun:jsc", .{ .path = "bun:jsc" } }, | ||
| .{ "bun:main", .{ .path = "bun:main" } }, |
There was a problem hiding this comment.
🟡 Adding bun:main to bun_aliases makes process.getBuiltinModule("bun:main") reach getHardcodedModule, which clones entry_point.contents into a fresh bun.String and returns it with .tag = .esm; the C++ side (ModuleLoader.cpp, case SyntheticModuleType::ESM: return {};) then drops the ErrorableResolvedSource without deref'ing source_code, leaking one copy of the wrapper source per call. Tiny leak via an obscure API, but it's a new path opened by this PR — easiest fix is to deref res.result.value.source_code before return {} in the ESM case (or have the bun:main branch return null when called from Bun__resolveAndFetchBuiltinModule).
Extended reasoning...
What the bug is
process.getBuiltinModule("bun:main") now allocates a heap-backed WTF::StringImpl (a clone of jsc_vm.entry_point.contents) and discards it without decrementing its refcount. Each call leaks one copy of the entry-point wrapper source.
Before this PR, Bun__resolveAndFetchBuiltinModule returned false for "bun:main" (it wasn't in HardcodedModule.Alias.bun_aliases), so process.getBuiltinModule("bun:main") returned undefined without allocating anything. Adding the alias opens a code path that was never exercised by this caller.
Code path that triggers it
process.getBuiltinModuleis bound directly toProcess_functionLoadBuiltinModule(BunProcess.cpp:4032) — there is no JS-side cache in front of it.Process_functionLoadBuiltinModule(BunProcess.cpp:3913) callsBun::resolveAndFetchBuiltinModule(ModuleLoader.cpp:577), which declares a stack-localErrorableResolvedSource resand callsBun__resolveAndFetchBuiltinModule(&res, ...).- In Zig (
ModuleLoader.zig:837),HardcodedModule.Alias.bun_aliases.get("bun:main")now succeeds (the line added by this PR).HardcodedModule.map.get("bun:main")(line 839) yields.@"bun:main", andgetHardcodedModuleis called (line 844). getHardcodedModulefor.@"bun:main"(ModuleLoader.zig:1148-1155), whenjsc_vm.entry_point.generatedis true (the normal case forbun run script), returns:ResolvedSource{ .source_code = bun.String.cloneUTF8(jsc_vm.entry_point.contents), .tag = .esm, .source_code_needs_deref = true, ... }
cloneUTF8→toBunStringComptimeheap-allocates a fresh ref-countedWTF::StringImpl.- Back in C++ (
ModuleLoader.cpp:605-607), the switch onres.result.value.taghits:and the function returnscase SyntheticModuleType::ESM: return {};
jsUndefined()to the caller.
Why existing code doesn't prevent it
ErrorableResolvedSource is a plain typedef struct (headers-handwritten.h:132-135) with no destructor, and BunString is a tagged value type with no RAII cleanup. The SyntheticModuleType::ESM case in resolveAndFetchBuiltinModule simply returns without touching res.result.value.source_code, so the cloned string's refcount stays at 1 forever.
The other non-registry builtin reachable on this path, bun:wrap, uses String.init on a static buffer (no heap allocation) and .tag = .javascript (hits the default: branch, also return {}), so it never tickled this. bun:main is the first alias whose getHardcodedModule branch heap-allocates and is then thrown away by resolveAndFetchBuiltinModule.
Step-by-step proof
Under bun run /tmp/x.mjs:
ServerEntryPoint.generate()setsentry_point.generated = trueandentry_point.contents = "import '/tmp/x.mjs';\n..."(~a few hundred bytes).- User code calls
process.getBuiltinModule("bun:main"). - Step 3 above: alias lookup succeeds (new in this PR).
- Step 4 above:
cloneUTF8allocates aWTF::StringImplof those ~hundreds of bytes, refcount = 1. - Step 5 above:
case ESM: return {};—resgoes out of scope, no destructor runs, theStringImplis leaked. Process_functionLoadBuiltinModulereturnsjsUndefined().- Repeat:
for (;;) process.getBuiltinModule("bun:main");→ RSS grows unbounded.
Before this PR, step 3 returned null → return false at ModuleLoader.zig:837, so step 4 never ran.
Impact
A small per-call memory leak (one wrapper-source string, typically a few hundred bytes) on every call to process.getBuiltinModule("bun:main"). The call also returns undefined, so users have no reason to invoke it repeatedly — impact in real programs is negligible. It is, however, a genuine regression introduced by this PR rather than a pre-existing issue.
How to fix
Either:
- In
ModuleLoader.cpp'sresolveAndFetchBuiltinModule, derefres.result.value.source_codebeforereturn {}in theESM(anddefault) cases whensource_code_needs_derefis set; or - In
Bun__resolveAndFetchBuiltinModule(ModuleLoader.zig), special-case.@"bun:main"toreturn falsebefore callinggetHardcodedModule, since this caller can't do anything useful with an ESM-tagged source anyway.
Follow-up to #29719. `bun:main` statically imports the entry file, so `await import("bun:main")` at the entry's top level is a TLA self-cycle: `bun:main` waits for `entry.mjs` (async dep), and `entry.mjs` waits for `bun:main`'s evaluation promise. Per spec that promise never settles. The old JSC module loader broke these cycles early, which is why #29719 passed locally (tested against `892042c2`, pre-#29393). After #29393 (WebKit module-loader rewrite) the loader correctly leaves the promise unsettled, so the test now hangs and times out at 90s — see build 48023 (debian-asan, win x64, win x64-baseline). Fix: drop the top-level `await` and use `import("bun:main").then(...)` so `entry.mjs` finishes synchronously, `bun:main` finishes, and the import resolves on the next microtask. The preload and `--hot` tests are unaffected (preload isn't in `bun:main`'s dep graph). Note: this also surfaced that Bun now hangs forever on any unsettled-TLA cycle instead of exiting 13 like Node — separate PR coming for that. ## How did you verify your code works? - `bun bd test test/js/bun/resolve/bun-main-entry-point.test.ts` — 3 pass, 20 consecutive runs on Windows - `USE_SYSTEM_BUN=1 bun test ...` — 2 fail (still catches the alias bug on unfixed bun)
…n-sh#29719) ## What Add `bun:main` to `HardcodedModule.Alias.bun_extra_alias_kvs` so the runtime transpiler stops rewriting `import("bun:main")` into `import("main")`. ## Why (the flake) `bun-main-entry-point.test.ts` has been flaky since it landed in oven-sh#29450. The test was never exercising the code path it claimed to: - `bun:main` is in `HardcodedModule.map` but was missing from the **`Alias`** map - so `RuntimeTranspilerStore.zig:534` stripped the `bun:` prefix, leaving `import("main")` in the emitted JS - at runtime that fell through to `resolveAndAutoInstall`, which **fetched the npm `main` package** (`main@1000.0.1`) over the network - the test's `typeof m !== "object"` check passed against the npm package, so it "passed" - when the registry fetch was slow or failed on CI, stdout was empty → flake Confirmed by observing `require.cache` gain `~/.bun/install/cache/main@1000.0.1@@@1/index.js` after the import, and by reproducing the failure deterministically with `[install] auto = "disable"`. ## Test changes - Add `package.json: "{}"` to the temp dirs so auto-install is off — any future regression fails loudly with "Cannot find package 'main'" instead of silently passing via npm - Assert `Object.keys(m).length === 0` (the real wrapper exports nothing; the npm package exports `default,length,name,prototype`) - Collapse assertions into one `toEqual({stdout, stderr, exitCode, signalCode})` so a failure shows the child's stderr - Update the preload test's expected order — now that `import("bun:main")` actually evaluates the wrapper, entry.mjs runs before the preload's await resumes (`ENTRY_OK\nPRELOAD_OK\n`) ## How did you verify your code works? - `bun bd test test/js/bun/resolve/bun-main-entry-point.test.ts` — 3 pass, 30 consecutive runs on Windows - `USE_SYSTEM_BUN=1 bun test ...` — 2 fail (correctly catches the bug on unfixed bun) - `bun bd test test/js/bun/http/bun-serve-html-entry.test.ts -t "bun:main"` — pass - `bun bd test test/js/bun/resolve/import-meta*.test.*` — 47 pass - `bun bd test test/js/node/module/node-module-module.test.js` — 28 pass - `bun run zig:check-all` — pass
…9738) Follow-up to oven-sh#29719. `bun:main` statically imports the entry file, so `await import("bun:main")` at the entry's top level is a TLA self-cycle: `bun:main` waits for `entry.mjs` (async dep), and `entry.mjs` waits for `bun:main`'s evaluation promise. Per spec that promise never settles. The old JSC module loader broke these cycles early, which is why oven-sh#29719 passed locally (tested against `892042c2`, pre-oven-sh#29393). After oven-sh#29393 (WebKit module-loader rewrite) the loader correctly leaves the promise unsettled, so the test now hangs and times out at 90s — see build 48023 (debian-asan, win x64, win x64-baseline). Fix: drop the top-level `await` and use `import("bun:main").then(...)` so `entry.mjs` finishes synchronously, `bun:main` finishes, and the import resolves on the next microtask. The preload and `--hot` tests are unaffected (preload isn't in `bun:main`'s dep graph). Note: this also surfaced that Bun now hangs forever on any unsettled-TLA cycle instead of exiting 13 like Node — separate PR coming for that. ## How did you verify your code works? - `bun bd test test/js/bun/resolve/bun-main-entry-point.test.ts` — 3 pass, 20 consecutive runs on Windows - `USE_SYSTEM_BUN=1 bun test ...` — 2 fail (still catches the alias bug on unfixed bun)
What
Add
bun:maintoHardcodedModule.Alias.bun_extra_alias_kvsso the runtime transpiler stops rewritingimport("bun:main")intoimport("main").Why (the flake)
bun-main-entry-point.test.tshas been flaky since it landed in #29450. The test was never exercising the code path it claimed to:bun:mainis inHardcodedModule.mapbut was missing from theAliasmapRuntimeTranspilerStore.zig:534stripped thebun:prefix, leavingimport("main")in the emitted JSresolveAndAutoInstall, which fetched the npmmainpackage (main@1000.0.1) over the networktypeof m !== "object"check passed against the npm package, so it "passed"Confirmed by observing
require.cachegain~/.bun/install/cache/main@1000.0.1@@@1/index.jsafter the import, and by reproducing the failure deterministically with[install] auto = "disable".Test changes
package.json: "{}"to the temp dirs so auto-install is off — any future regression fails loudly with "Cannot find package 'main'" instead of silently passing via npmObject.keys(m).length === 0(the real wrapper exports nothing; the npm package exportsdefault,length,name,prototype)toEqual({stdout, stderr, exitCode, signalCode})so a failure shows the child's stderrimport("bun:main")actually evaluates the wrapper, entry.mjs runs before the preload's await resumes (ENTRY_OK\nPRELOAD_OK\n)How did you verify your code works?
bun bd test test/js/bun/resolve/bun-main-entry-point.test.ts— 3 pass, 30 consecutive runs on WindowsUSE_SYSTEM_BUN=1 bun test ...— 2 fail (correctly catches the bug on unfixed bun)bun bd test test/js/bun/http/bun-serve-html-entry.test.ts -t "bun:main"— passbun bd test test/js/bun/resolve/import-meta*.test.*— 47 passbun bd test test/js/node/module/node-module-module.test.js— 28 passbun run zig:check-all— pass