Skip to content
Closed
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
40 changes: 18 additions & 22 deletions src/bundler/entry_points.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,26 +255,28 @@ impl ClientEntryPoint {

#[derive(Default)]
pub struct ServerEntryPoint {
/// The generated wrapper source for `bun:main`. Always a valid slice
/// (either empty or owned by `bun.default_allocator`) so readers never
/// see `undefined` memory regardless of the `generated` flag's state.
pub contents: Box<[u8]>,
/// Set once `reload_entry_point` has run, i.e. the VM has a `main` path
/// that `bun:main` should wrap. The wrapper source itself is not stored;
/// it is regenerated on demand by [`generate_source`] each time `bun:main`
/// is fetched, so there is no cached buffer that can go stale between the
/// call to `generate()` and a later `import("bun:main")`.
pub generated: bool,
}

// `deinit` only freed `contents` and reset flags; with `Box<[u8]>` this is the
// auto-generated `Drop`, so no explicit impl is needed.

impl ServerEntryPoint {
pub fn generate(
entry: &mut ServerEntryPoint,
is_hot_reload_enabled: bool,
path_to_use: &[u8],
_is_hot_reload_enabled: bool,
_path_to_use: &[u8],
) -> Result<(), bun_core::Error> {
// Use the global arena so this buffer's lifetime is decoupled
// from whichever arena the caller's VM happens to be using; the
// slice is read later from `getHardcodedModule` which outlives any
// per-transpile arena.
entry.generated = true;
Ok(())
}

Comment on lines 268 to +275

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.

🟡 ServerEntryPoint::generate() now ignores both _is_hot_reload_enabled and _path_to_use and unconditionally returns Ok(()), leaving dead parameters and an infallible Result return type. Per CLAUDE.md ("Delete dead code in the same PR that makes it dead … parameters discarded in the body"), drop the dead params and change the return to (); the jsc_hooks.rs wrapper can then stop threading watch/entry_path and stop calling .is_ok() on a never-Err value (the RuntimeHooks fn-pointer signature can adapt in the wrapper if you'd rather not touch VirtualMachine.rs).

Extended reasoning...

What changed

Before this PR, ServerEntryPoint::generate() consumed is_hot_reload_enabled and path_to_use to render the wrapper source into entry.contents, and the write! calls could (nominally) fail, justifying Result<(), bun_core::Error>. After this PR the rendering moved to the new pure generate_source(), and generate() (src/bundler/entry_points.rs:267-275) is reduced to:

pub fn generate(
    entry: &mut ServerEntryPoint,
    _is_hot_reload_enabled: bool,
    _path_to_use: &[u8],
) -> Result<(), bun_core::Error> {
    entry.generated = true;
    Ok(())
}

Both non-self parameters are discarded (underscore-prefixed) and the return is unconditionally Ok(()).

Why it's flagged

CLAUDE.md:239 states this as required scope, not optional cleanup:

Delete dead code in the same PR that makes it dead (required scope — name the deletions in the description): … fields nothing reads, parameters discarded in the body, guards a new validator makes redundant.

This PR is exactly the change that made these parameters dead, so per the project's own contributing guide they should be removed here rather than left for a follow-up.

Dead plumbing in the caller chain

Walking outward from generate():

  1. src/runtime/jsc_hooks.rs:608-616generate_entry_point(_vm, watch, entry_path) -> bool threads watch and entry_path through solely to discard them, and calls .is_ok() on a Result that can never be Err. The doc comment on this function ("Returns false on error (the error is already logged into vm.log by generate)") is now also stale — generate no longer logs anything and never fails.
  2. src/jsc/VirtualMachine.rs:1639 — the RuntimeHooks.generate_entry_point fn-pointer signature carries watch: bool, entry_path: &[u8] and returns bool.
  3. src/jsc/VirtualMachine.rs:2289-2291 — the call site branches on the bool and has an Err(bun_core::err!("ServerEntryPointGenerate")) arm that is now unreachable (the only condition under which the hook returns false is runtime_state().is_null(), which would already have prevented the VM from booting).

Step-by-step proof it's dead

  • generate() has exactly one caller: generate_entry_point at jsc_hooks.rs:615 (verified by grep — ServerEntryPoint::generate( appears nowhere else).
  • That caller passes watch and entry_path straight through; neither is read anywhere else in the wrapper.
  • generate()'s body contains no ?, no return Err, no fallible call — only entry.generated = true; Ok(()). So .is_ok() at jsc_hooks.rs:615 is statically true.
  • Therefore generate_entry_point returns true whenever runtime_state() is non-null, and the watch/entry_path arguments contribute nothing to its result or side effects.

Impact

No runtime impact — this is purely dead surface area. But it leaves a misleading API: a reader of RuntimeHooks.generate_entry_point's signature and doc comment will assume entry-point generation can fail and depends on watch/entry_path, when in fact the real inputs are now read at fetch time in get_hardcoded_module via vm.main() / vm.is_watcher_enabled().

Suggested fix

Minimal (keeps the cross-crate RuntimeHooks vtable shape untouched):

impl ServerEntryPoint {
    pub fn mark_generated(entry: &mut ServerEntryPoint) {
        entry.generated = true;
    }
    // generate_source() unchanged
}

and in jsc_hooks.rs:

fn generate_entry_point(_vm: &VirtualMachine, _watch: bool, _entry_path: &[u8]) -> bool {
    let state = runtime_state();
    if state.is_null() { return false; }
    ServerEntryPoint::mark_generated(unsafe { &mut (*state).entry_point });
    true
}

Full cleanup (preferred, since RuntimeHooks is an in-tree Rust fn-pointer table with a single implementation, not a stable C ABI): change the vtable entry to fn(vm: &VirtualMachine), drop the watch/entry_path plumbing and the unreachable ServerEntryPointGenerate error branch at VirtualMachine.rs:2289-2291, and update the stale doc comments at jsc_hooks.rs:605-607 and VirtualMachine.rs:1636-1638.

/// Build the synthetic `bun:main` wrapper that imports `path_to_use` and
/// auto-starts `Bun.serve` on its default export. Pure function of its
/// arguments; called each time `bun:main` is fetched.
pub fn generate_source(is_hot_reload_enabled: bool, path_to_use: &[u8]) -> Vec<u8> {
let code: Vec<u8> = 'brk: {
if is_hot_reload_enabled {
let mut v: Vec<u8> = Vec::new();
Expand Down Expand Up @@ -313,7 +315,7 @@ impl ServerEntryPoint {
}}\n",
strings::format_escapes(path_to_use, strings::QuoteEscapeFormatFlags { quote_char: b'\'', ..Default::default() }),
)
.map_err(|_| bun_core::err!("FormatError"))?;
.expect("write! into Vec<u8> is infallible");
break 'brk v;
}
let mut v: Vec<u8> = Vec::new();
Expand All @@ -338,16 +340,10 @@ impl ServerEntryPoint {
}}\n",
strings::format_escapes(path_to_use, strings::QuoteEscapeFormatFlags { quote_char: b'"', ..Default::default() }),
)
.map_err(|_| bun_core::err!("FormatError"))?;
.expect("write! into Vec<u8> is infallible");
v
};

// Free the previous buffer on regenerate (hot reload) instead of
// leaking it. `contents` is either "" or a previously generated buffer.
// (Handled implicitly: assigning to `Box<[u8]>` drops the old one.)
entry.contents = code.into_boxed_slice();
entry.generated = true;
Ok(())
code
}
}

Expand Down
20 changes: 14 additions & 6 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3426,7 +3426,7 @@ fn js_synthetic_module(name: &'static [u8], specifier: &bun_core::String) -> Own
/// before `ServerEntryPoint::generate` has run, or `bun:internal-for-testing`
/// without the opt-in flag).
fn get_hardcoded_module(
_jsc_vm: *mut VirtualMachine,
jsc_vm: *mut VirtualMachine,
specifier: &bun_core::String,
hardcoded: HardcodedModule,
) -> Option<OwnedResolvedSource> {
Expand All @@ -3437,21 +3437,29 @@ fn get_hardcoded_module(

match hardcoded {
HardcodedModule::BunMain => {
// Synthetic `bun:main` wrapper — pulls source from this thread's
// `RuntimeState.entry_point`.
// Synthetic `bun:main` wrapper. Regenerate the source on demand
// from `vm.main` rather than reading a stored buffer, so there is
// no cached slice that can dangle between `reload_entry_point`
// and a later `import("bun:main")` (Sentry BUN-36H7).
let state = runtime_state();
if state.is_null() {
return None;
}
// SAFETY: `state` is the live per-thread `RuntimeState` boxed in
// `init_runtime_state`; no other `&mut` to `entry_point` is held.
let ep = unsafe { &(*state).entry_point };
if !ep.generated {
if !unsafe { &(*state).entry_point }.generated {
return None;
}
// SAFETY: `jsc_vm` is the live per-thread VM (fn contract).
let vm = unsafe { &*jsc_vm };
let main = vm.main();
if main.is_empty() {
return None;
}
let code = ServerEntryPoint::generate_source(vm.is_watcher_enabled(), main);
use bun_jsc::resolved_source::Tag;
Some(OwnedResolvedSource::from(ResolvedSource {
source_code: bun_core::String::clone_utf8(&ep.contents),
source_code: bun_core::String::clone_utf8(&code),
// +1 each: ~SourceProvider() derefs `specifier` and
// `source_url` once all uses are done (see ZigSourceProvider.cpp).
specifier: specifier.dupe_ref(),
Expand Down
71 changes: 69 additions & 2 deletions test/js/bun/resolve/bun-main-entry-point.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isDebug, tempDir } from "harness";
import { bunEnv, bunExe, isDebug, isWindows, tempDir } from "harness";
import { writeFileSync } from "node:fs";
import { join } from "node:path";

Expand All @@ -13,7 +13,9 @@ import { join } from "node:path";
// free-then-reallocate on each reload.

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 file-header comment here (lines 6-13) and the --hot reloads test comment (~lines 166-169) still describe bun:main as "backed by ServerEntryPoint.contents" with a "free-then-reallocate on each reload" — the exact mechanism this PR removes. Since this PR already edits this file and the whole point of the change is that there is no stored contents buffer anymore, these two comments should be updated in the same pass to describe the regenerate-on-fetch behavior.

Extended reasoning...

What's stale

Two comments in test/js/bun/resolve/bun-main-entry-point.test.ts describe the implementation that this PR deletes:

  1. File header, lines 6-13:

    bun:main is backed by ServerEntryPoint.contents — a slice that is regenerated on every hot-reload cycle. Previously the backing logger.Source defaulted to undefined ... These tests exercise the read path directly and the regenerate path under --hot so ASAN covers the new free-then-reallocate on each reload.

  2. --hot reloads test, ~lines 166-169:

    Each reload calls ServerEntryPoint.generate() again, which now frees the previous contents buffer before allocating a fresh one. Drive several reloads ... under ASAN this catches any use-after-free of the prior buffer.

Why they're wrong after this PR

Step through the new code:

  1. ServerEntryPoint (src/bundler/entry_points.rs) now has only pub generated: bool. The contents: Box<[u8]> field is gone.
  2. ServerEntryPoint::generate() is now a two-liner: entry.generated = true; Ok(()). It allocates nothing and frees nothing — there is no "free-then-reallocate on each reload".
  3. The wrapper source is produced by the new ServerEntryPoint::generate_source(is_hot_reload_enabled, path_to_use) -> Vec<u8>, a pure function called from get_hardcoded_module at fetch time using vm.main() and vm.is_watcher_enabled().
  4. So bun:main is no longer "backed by ServerEntryPoint.contents"; it is regenerated on demand, and there is no "prior buffer" for ASAN to catch a UAF on.

Both comments therefore describe a mechanism that no longer exists — and not incidentally: removing that mechanism is the entire point of this PR.

Why it's in scope for this PR

The PR already edits this exact file: it changes the imports, rewrites stripAsanWarning, and adds a new test directly between the header comment and the --hot test. The new test's own comment (lines ~90-99) even states the correct new behavior ("The wrapper source is now regenerated on demand from vm.main at fetch time, so there is no stored buffer that can go stale"), which now sits a few lines away from a header comment asserting the opposite. A future reader will see two contradictory descriptions in the same file.

Impact

No runtime effect — this is documentation only. But the stale comments are actively misleading: they point at a field (ServerEntryPoint.contents) and a lifecycle ("free-then-reallocate") that someone debugging bun:main will go looking for and not find. The --hot test's stated rationale ("under ASAN this catches any use-after-free of the prior buffer") no longer matches what the test actually exercises.

Suggested fix

Rewrite both comments to match the new model, e.g.:

  • Header: "bun:main is a synthetic wrapper whose source is regenerated on demand from vm.main() each time it is fetched (no cached buffer). These tests exercise the fetch path directly and across --hot reloads."
  • --hot test: "Each reload re-fetches bun:main, which regenerates the wrapper source from the current vm.main(). Drive several reloads and verify it re-evaluates correctly each time."


function stripAsanWarning(stderr: string): string[] {
return stderr.split("\n").filter(l => l.length > 0 && !l.startsWith("WARNING: ASAN interferes"));
return stderr
.split("\n")
.filter(l => l.length > 0 && !l.startsWith("WARNING: ASAN interferes") && !l.startsWith("debug warn:"));
}

test.concurrent("dynamic import('bun:main') returns the wrapper module", async () => {
Expand Down Expand Up @@ -83,6 +85,71 @@ test.concurrent("import('bun:main') from a preload (before the module map is pop
});
});

// Sentry BUN-36H7 / https://github.com/oven-sh/bun/issues/27192:
// import("bun:main") inside a compiled standalone executable faulted in
// getHardcodedModule reading the stored `entry_point.contents` slice
// (SEGV at a page-aligned high address, i.e. a freed mimalloc segment).
// The wrapper source is now regenerated on demand from `vm.main` at fetch
// time, so there is no stored buffer that can go stale between
// `reload_entry_point` and the fetch. This exercises the exact crash
// scenario: the bundled entry both (a) is reached via the initial
// bun:main fetch at boot and (b) explicitly re-imports bun:main after the
// top-level evaluation has completed.
Comment on lines +90 to +99

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.

🟡 nit: per repo convention (root CLAUDE.md), regression-test comments should be just the issue URL — the Sentry signature, freed-mimalloc diagnosis, and fix mechanism here all duplicate the PR description and will drift as the implementation changes. Suggest reducing this block to // https://github.com/oven-sh/bun/issues/27192 (and likewise dropping (Sentry BUN-36H7) from the new comment in jsc_hooks.rs).

Extended reasoning...

What

Root CLAUDE.md (line 241) states the repo convention for test comments explicitly:

Comments carry only durable non-obvious content … no bug history — that belongs in the PR description. Regression tests get exactly one comment: the issue URL.

The new comment block at test/js/bun/resolve/bun-main-entry-point.test.ts:90-99 is ten lines of bug history: the Sentry crash signature (BUN-36H7), the SEGV symptom ("page-aligned high address, i.e. a freed mimalloc segment"), the old mechanism (entry_point.contents read from getHardcodedModule), and how the fix works ("regenerated on demand from vm.main"). All of this is already in the PR description nearly verbatim — the "Cause" and "Fix" sections cover it word for word.

Why it matters

The rule exists because bug-history comments go stale. This one already references implementation internals that this very PR removes — entry_point.contents no longer exists after this change, and reload_entry_point no longer writes a buffer — so the comment is describing a code shape that's gone. The next time someone touches ServerEntryPoint they'll have to either update three copies of the same narrative (PR description, test comment, and the new (Sentry BUN-36H7) reference at src/runtime/jsc_hooks.rs:3440-3443) or let them drift. The issue URL alone is durable: it points to the canonical record and never needs editing.

The same file does have a pre-existing long bug-history comment at lines 6-13 (from #29450), so the author was following file-local precedent — but adding another instance compounds the problem rather than fixing it, and CLAUDE.md is unambiguous that this is a hard rule, not a suggestion.

Step-by-step

  1. CLAUDE.md:241 → "Regression tests get exactly one comment: the issue URL."
  2. New test added at bun-main-entry-point.test.ts:100 is a regression test for issue Segmentation fault at address 0x2813E000000 #27192 / Sentry BUN-36H7.
  3. Comment at lines 90-99 contains: Sentry ID, SEGV diagnosis, freed-mimalloc explanation, old-vs-new implementation description, and test-scenario rationale — i.e. bug history, not the issue URL alone.
  4. Every sentence in that comment also appears in the PR description's "What" / "Cause" / "Fix" sections.
  5. ⇒ The comment violates the stated convention and duplicates the PR description.

Fix

Replace lines 90-99 with:

// https://github.com/oven-sh/bun/issues/27192

Optionally also trim the new source comment at src/runtime/jsc_hooks.rs:3440-3443 to drop the (Sentry BUN-36H7) tag, since the issue link in the test already provides the back-reference and the PR description carries the Sentry ID.

test.concurrent(
"import('bun:main') in a compiled standalone executable",
async () => {
using dir = tempDir("bun-main-compile", {
"package.json": "{}",
"entry.mjs": `
// bun:main statically imports this file, so awaiting it at the top
// level would be a TLA self-cycle. Defer to a task so bun:main
// finishes evaluating first, then re-import it from user code.
setImmediate(async () => {
try {
Bun.gc(true);
const m = await import("bun:main");
if (m[Symbol.toStringTag] !== "Module") throw new Error("expected module namespace");
const keys = Object.keys(m);
if (keys.length !== 0) throw new Error("expected empty wrapper namespace, got keys: " + keys.join(","));
console.log("OK");
} catch (e) {
console.error(String(e));
process.exit(1);
}
});
`,
});
const outfile = join(String(dir), isWindows ? "out.exe" : "out");
{
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--compile", "./entry.mjs", "--outfile", outfile],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stderr, exitCode }).toEqual({ stderr: expect.not.stringContaining("error:"), exitCode: 0 });
void stdout;
}
await using proc = Bun.spawn({
cmd: [outfile],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr: stripAsanWarning(stderr), exitCode, signalCode: proc.signalCode }).toEqual({
stdout: "OK\n",
stderr: [],
exitCode: 0,
signalCode: null,
});
},
isDebug ? 120_000 : 60_000,
);

test.concurrent(
"ServerEntryPoint regenerates cleanly across --hot reloads",
async () => {
Expand Down
Loading