Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bun.js/HardcodedModule.zig
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ pub const HardcodedModule = enum {
.{ "bun:app", .{ .path = "bun:app" } },
.{ "bun:ffi", .{ .path = "bun:ffi" } },
.{ "bun:jsc", .{ .path = "bun:jsc" } },
.{ "bun:main", .{ .path = "bun:main" } },

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.

🟡 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

  1. process.getBuiltinModule is bound directly to Process_functionLoadBuiltinModule (BunProcess.cpp:4032) — there is no JS-side cache in front of it.
  2. Process_functionLoadBuiltinModule (BunProcess.cpp:3913) calls Bun::resolveAndFetchBuiltinModule (ModuleLoader.cpp:577), which declares a stack-local ErrorableResolvedSource res and calls Bun__resolveAndFetchBuiltinModule(&res, ...).
  3. 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", and getHardcodedModule is called (line 844).
  4. getHardcodedModule for .@"bun:main" (ModuleLoader.zig:1148-1155), when jsc_vm.entry_point.generated is true (the normal case for bun run script), returns:
    ResolvedSource{
        .source_code = bun.String.cloneUTF8(jsc_vm.entry_point.contents),
        .tag = .esm,
        .source_code_needs_deref = true,
        ...
    }
    cloneUTF8toBunStringComptime heap-allocates a fresh ref-counted WTF::StringImpl.
  5. Back in C++ (ModuleLoader.cpp:605-607), the switch on res.result.value.tag hits:
    case SyntheticModuleType::ESM:
        return {};
    and the function returns 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:

  1. ServerEntryPoint.generate() sets entry_point.generated = true and entry_point.contents = "import '/tmp/x.mjs';\n..." (~a few hundred bytes).
  2. User code calls process.getBuiltinModule("bun:main").
  3. Step 3 above: alias lookup succeeds (new in this PR).
  4. Step 4 above: cloneUTF8 allocates a WTF::StringImpl of those ~hundreds of bytes, refcount = 1.
  5. Step 5 above: case ESM: return {};res goes out of scope, no destructor runs, the StringImpl is leaked.
  6. Process_functionLoadBuiltinModule returns jsUndefined().
  7. Repeat: for (;;) process.getBuiltinModule("bun:main"); → RSS grows unbounded.

Before this PR, step 3 returned nullreturn 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's resolveAndFetchBuiltinModule, deref res.result.value.source_code before return {} in the ESM (and default) cases when source_code_needs_deref is set; or
  • In Bun__resolveAndFetchBuiltinModule (ModuleLoader.zig), special-case .@"bun:main" to return false before calling getHardcodedModule, since this caller can't do anything useful with an ESM-tagged source anyway.

.{ "bun:sqlite", .{ .path = "bun:sqlite" } },
.{ "bun:wrap", .{ .path = "bun:wrap" } },
.{ "bun:internal-for-testing", .{ .path = "bun:internal-for-testing" } },
Expand Down
32 changes: 24 additions & 8 deletions test/js/bun/resolve/bun-main-entry-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ function stripAsanWarning(stderr: string): string[] {

test.concurrent("dynamic import('bun:main') returns the wrapper module", async () => {
using dir = tempDir("bun-main-dyn", {
// package.json disables auto-install so a regression in the bun:main alias
// cannot silently fall through to fetching the npm `main` package.
"package.json": "{}",
"entry.mjs": `
const m = await import("bun:main");
if (typeof m !== "object" || m === null) throw new Error("expected module namespace");
if (m[Symbol.toStringTag] !== "Module") throw new Error("expected module namespace, got " + Object.prototype.toString.call(m));
// The wrapper has no named exports. The npm \`main\` package (what this
// resolved to before the alias fix) exports {default,length,name,prototype}.
const keys = Object.keys(m);
if (keys.length !== 0) throw new Error("expected empty wrapper namespace, got keys: " + keys.join(","));
console.log("OK");
`,
});
Expand All @@ -32,16 +39,20 @@ test.concurrent("dynamic import('bun:main') returns the wrapper module", async (
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("OK\n");
expect(stripAsanWarning(stderr)).toEqual([]);
expect(exitCode).toBe(0);
expect({ stdout, stderr: stripAsanWarning(stderr), exitCode, signalCode: proc.signalCode }).toEqual({
stdout: "OK\n",
stderr: [],
exitCode: 0,
signalCode: null,
});
});

test.concurrent("import('bun:main') from a preload (before the module map is populated)", async () => {
using dir = tempDir("bun-main-preload", {
"package.json": "{}",
"preload.mjs": `
const m = await import("bun:main");
if (typeof m !== "object" || m === null) throw new Error("expected module namespace");
if (m[Symbol.toStringTag] !== "Module") throw new Error("expected module namespace");
console.log("PRELOAD_OK");
`,
"entry.mjs": `console.log("ENTRY_OK");`,
Expand All @@ -54,9 +65,14 @@ test.concurrent("import('bun:main') from a preload (before the module map is pop
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("PRELOAD_OK\nENTRY_OK\n");
expect(stripAsanWarning(stderr)).toEqual([]);
expect(exitCode).toBe(0);
// import("bun:main") evaluates the wrapper, which evaluates entry.mjs, so
// ENTRY_OK prints before the preload's await resumes.
expect({ stdout, stderr: stripAsanWarning(stderr), exitCode, signalCode: proc.signalCode }).toEqual({
stdout: "ENTRY_OK\nPRELOAD_OK\n",
stderr: [],
exitCode: 0,
signalCode: null,
});
});

test.concurrent(
Expand Down
Loading