From 9f1ebcc8e19156ca1e5e736e04b7ea562ccccaa9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:44:39 +0000 Subject: [PATCH 1/4] bundler: fail the build when every entry point is dropped instead of linking zero entry points An entry point that resolves to nothing used to be dropped without a log entry. When it was the only entry point, the linker ran with an empty chunk list and aborted in generate_chunks_in_parallel ("index out of bounds: the len is 0 but the index is 0"). Next to a live entry point, the build succeeded and silently emitted fewer outputs than entry points. Three producers dropped entry points this way: - resolve_entry_point returned a result with every path disabled (a package.json "browser" field mapping the entry point to false, or "fs" and node:* builtins without a browser polyfill under target browser). It now logs an error and returns Err like every other entry point failure. - An onResolve plugin returning external: true for an entry point. on_resolve now logs "The entry point X cannot be marked as external". - An entry point whose cwd-joined path does not fit a path buffer returned from resolve_entry_point ahead of the logging. The length guard now only skips the directory cache bust, so the resolve error is logged. generate_from_cli and run_from_js_in_new_thread also fail with "None of the entry points could be bundled" when parsing ends with graph.entry_points empty, so any remaining way of losing every entry point is a build error. The CLI drivers report entry point errors after wait_for_parse, as the JS driver already did, so the runtime parse task is never in flight at teardown. --- src/bundler/bundle_v2.rs | 41 ++++++++++--- src/bundler/transpiler.rs | 53 +++++++++++++---- test/bundler/bun-build-api.test.ts | 88 ++++++++++++++++++++++++++++ test/bundler/bundler_browser.test.ts | 81 +++++++++++++++++++++++++ test/bundler/bundler_plugin.test.ts | 78 ++++++++++++++++++++++++ test/js/web/workers/worker.test.ts | 9 +++ 6 files changed, 332 insertions(+), 18 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 548d08b4e916..e055a4fd0518 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2099,6 +2099,19 @@ pub mod bv2_impl { ); } + /// Callers require an entry point, so none after parsing means one was dropped without an error. + fn fail_if_no_entry_points(&self) -> Result<(), Error> { + if !self.graph.entry_points.is_empty() { + return Ok(()); + } + self.transpiler.log_mut().add_error( + None, + bun_ast::Loc::EMPTY, + "None of the entry points could be bundled", + ); + Err(crate::Error::BuildFailed) + } + /// `BUN_THREADPOOL_STATS=1` instrumentation hook — dump aggregate worker /// idle/busy time since the previous call. No-op when env var unset. #[inline] @@ -3843,10 +3856,7 @@ pub mod bv2_impl { // sidestep for the `&mut self` overlap. this.enqueue_entry_points_normal(unsafe { &*entry_points })?; - if this.transpiler.log().has_errors() { - return Err(crate::Error::BuildFailed); - } - + // Like `run_from_js_in_new_thread`: drain the pool, then report entry point errors. this.wait_for_parse(); this.dump_pool_stats("parse"); @@ -3858,6 +3868,7 @@ pub mod bv2_impl { if this.transpiler.log().has_errors() { return Err(crate::Error::BuildFailed); } + this.fail_if_no_entry_points()?; this.scan_for_secondary_paths(); @@ -4017,10 +4028,7 @@ pub mod bv2_impl { this.enqueue_entry_points_bake_production(entry_points)?; - if this.transpiler.log().has_errors() { - return Err(crate::Error::BuildFailed); - } - + // Drain the pool, then report entry point errors (as `generate_from_cli` does). this.wait_for_parse(); if this.transpiler.log().has_errors() { @@ -4792,6 +4800,22 @@ pub mod bv2_impl { drop(result.path); } } else { + // An external import is left as is in the importer; an external + // entry point has nothing to emit, so it is a build error (as in esbuild). + if resolve.import_record.kind == ImportKind::EntryPointBuild { + let log = this.log_for_resolution_failures( + &resolve.import_record.source_file, + resolve.import_record.original_target.bake_graph(), + ); + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "The entry point {} cannot be marked as external", + bun_core::fmt::quote(&resolve.import_record.specifier), + ), + ); + } drop(result.namespace); drop(result.path); } @@ -4986,6 +5010,7 @@ pub mod bv2_impl { if self.transpiler.log().errors > 0 { return Err(crate::Error::BuildFailed); } + self.fail_if_no_entry_points()?; self.scan_for_secondary_paths(); diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 223bb53dd035..afc910fc9bb2 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -458,15 +458,7 @@ impl<'a> Transpiler<'a> { /// retrying once on failure before reporting the error to the log. pub fn resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { match self._resolve_entry_point(entry_point) { - Ok(r) => Ok(r), - // Nothing that long names a directory whose cache could be stale - // (and the join below has a PathBuffer to fit `top_level_dir/entry/..` in). - Err(err) - if self.fs().top_level_dir.len() + entry_point.len() + 4 - > bun_paths::MAX_PATH_BYTES => - { - Err(err) - } + Ok(r) => self.reject_disabled_entry_point(r, entry_point), Err(err) => { let mut cache_bust_buf = bun_paths::PathBuffer::uninit(); @@ -477,6 +469,14 @@ impl<'a> Transpiler<'a> { // disjoint mutable borrows of `cache_bust_buf` across `break`, // so compute `busted` directly instead. let busted: bool = 'name: { + // Nothing that long names a directory whose cache could be + // stale (and neither buster name below would fit + // `cache_bust_buf`). + if self.fs().top_level_dir.len() + entry_point.len() + 4 + > bun_paths::MAX_PATH_BYTES + { + break 'name false; + } if bun_paths::is_absolute(entry_point) { let dir = bun_paths::resolve_path::dirname::( entry_point, @@ -515,7 +515,7 @@ impl<'a> Transpiler<'a> { // Only re-query if we previously had something cached. if busted { if let Ok(result) = self._resolve_entry_point(entry_point) { - return Ok(result); + return self.reject_disabled_entry_point(result, entry_point); } // ignore this error, we will print the original error } @@ -534,6 +534,39 @@ impl<'a> Transpiler<'a> { } } + /// A disabled module (no usable path) imports as `{}`, but an entry point has nothing to emit. + fn reject_disabled_entry_point( + &self, + resolved: resolver::Result, + entry_point: &[u8], + ) -> crate::Result { + if resolved.path_const().is_some() { + return Ok(resolved); + } + + // Stubbed builtins carry the "node" namespace; anything else came from a "browser" map. + if resolved.path_pair.primary.namespace == b"node" { + self.log_mut().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Cannot use Node.js builtin \"{}\" as an entry point", + bstr::BStr::new(entry_point) + ), + ); + } else { + self.log_mut().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "\"{}\" is disabled due to \"browser\" field in package.json (entry point)", + bstr::BStr::new(entry_point) + ), + ); + } + Err(crate::Error::ResolveMessage) + } + /// Load env files and build `options.define`. Idempotent — a no-op once /// `options.defines_loaded` is set. pub fn configure_defines(&mut self) -> crate::Result<()> { diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 8be40eb1b8ae..0fbcf59d3d75 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -214,6 +214,94 @@ describe("Bun.build", () => { } }); + // Runs in a child because the unfixed behavior was a process abort: the + // disabled entry point was dropped without an error and the linker ran with + // zero entry points. + test.concurrent("an entry point disabled by the package.json browser field is a build error", async () => { + using dir = tempDir("build-entry-point-disabled-by-browser-field", { + "package.json": JSON.stringify({ name: "app", browser: { "./entry.js": false } }), + "entry.js": `console.log("entry");`, + "build.mjs": ` + const returned = await Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }); + let thrown; + try { + await Bun.build({ entrypoints: ["./entry.js"], target: "browser" }); + } catch (e) { + thrown = { + isAggregateError: e instanceof AggregateError, + errors: e.errors.map(error => ({ name: error.name, level: error.level, position: error.position, message: error.message })), + }; + } + console.log(JSON.stringify({ + returned: { + success: returned.success, + outputs: returned.outputs.length, + logs: returned.logs.map(log => ({ name: log.name, level: log.level, position: log.position, message: log.message })), + }, + thrown, + })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const message = { + name: "BuildMessage", + level: "error", + position: null, + message: '"./entry.js" is disabled due to "browser" field in package.json (entry point)', + }; + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + returned: { success: false, outputs: 0, logs: [message] }, + thrown: { isAggregateError: true, errors: [message] }, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("an entry point too long for a path buffer is reported like any other missing one", async () => { + // Resolving it failed without logging anything, so the build went on + // with the entry point silently dropped: a successful build when another + // entry point was given, a crash in the linker when it was the only one. + // Runs in a child so the crash shows up as a failed assertion. + using dir = tempDir("build-api-long-entrypoint", { "valid.js": "console.log(1);" }); + const fixture = /* ts */ ` + // Longer than the path buffer on every platform, Windows included. + const long = Buffer.alloc(100_000, "a").toString(); + const report = async (entrypoints: string[]) => { + const { success, outputs, logs } = await Bun.build({ entrypoints, throw: false }); + return { success, outputs: outputs.length, logs: logs.map(log => [log.name, log.message]) }; + }; + console.log(JSON.stringify({ + alone: await report([long]), + withValidEntryPoint: await report(["./valid.js", long]), + })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + 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).toBe(""); + const notFound = { + success: false, + outputs: 0, + logs: [["BuildMessage", `ModuleNotFound resolving "${Buffer.alloc(100_000, "a").toString()}" (entry point)`]], + }; + expect(JSON.parse(stdout)).toEqual({ alone: notFound, withValidEntryPoint: notFound }); + expect(exitCode).toBe(0); + }); + test("returns output files", async () => { Bun.gc(true); const build = await Bun.build({ diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 104fee29e021..5804374078dc 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -374,6 +374,87 @@ describe("bundler", () => { }, }); + // An entry point the "browser" field maps to false has nothing to bundle. + // This used to reach the linker with zero entry points and crash + // ("index out of bounds" in generateChunksInParallel); with a second, live + // entry point it silently built only that one. + const browserFieldDisabledEntryPointFiles = { + "/package.json": /* json */ ` + { "name": "app", "browser": { "./entry.js": false } } + `, + "/entry.js": /* js */ ` + console.log("entry"); + `, + "/other.js": /* js */ ` + console.log("other"); + `, + }; + itBundled("browser/EntryPointDisabledByBrowserField", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js"], + target: "browser", + bundleErrors: { + "": ['"./entry.js" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointDisabledByBrowserFieldNextToLiveEntryPoint", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js", "./other.js"], + target: "browser", + bundleErrors: { + "": ['"./entry.js" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointDisabledByBrowserFieldOnlyAppliesToBrowserTarget", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js"], + target: "bun", + run: { + file: "/out/entry.js", + stdout: "entry", + }, + }); + itBundled("browser/EntryPointDisabledByPackageMainBrowserField", { + // The disabled module is reached through a package's "main", so the entry + // point specifier and the disabled file differ. + skipOnEsbuild: true, + backend: "cli", + files: { + "/node_modules/pkg/package.json": /* json */ ` + { "name": "pkg", "main": "./node.js", "browser": { "./node.js": false } } + `, + "/node_modules/pkg/node.js": /* js */ ` + console.log("node only"); + `, + }, + entryPointsRaw: ["pkg"], + target: "browser", + bundleErrors: { + "": ['"pkg" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointIsNodeBuiltinStubbedForBrowser", { + // Browser builds replace "fs" (and node:* builtins without a polyfill) with + // an empty module, so as entry points they have nothing to bundle either. + skipOnEsbuild: true, + backend: "cli", + files: {}, + entryPointsRaw: ["fs", "node:fs"], + target: "browser", + bundleErrors: { + "": [ + `Cannot use Node.js builtin "fs" as an entry point`, + `Cannot use Node.js builtin "node:fs" as an entry point`, + ], + }, + }); + // unsure: do we want polyfills or no-op stuff like node:* has // right now all error except bun:wrap which errors at resolve time, but is included if external const bunModules: Record = { diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index e009c304867f..3e357e85c298 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1688,4 +1688,82 @@ describe("bundler", () => { expect(exitCode).toBe(0); }); } + + // An entry point that onResolve leaves without a module used to be dropped + // without a log entry: the linker aborted on an empty chunk list when it was + // the only entry point, and the build silently emitted only the other entry + // points otherwise. A declined entry point that the package.json "browser" + // field disables gets the same error as without plugins. + test.concurrent("plugin/entry point left without a module by onResolve fails the build", async () => { + using dir = tempDir("plugin-entry-point-without-module", { + "package.json": JSON.stringify({ name: "app", browser: { "./disabled.js": false } }), + "entry.js": `console.log("entry");`, + "live.js": `console.log("live");`, + "disabled.js": `console.log("disabled");`, + "build.mjs": ` + const declined = []; + const plugins = [ + { + name: "externalize-entry", + setup(build) { + build.onResolve({ filter: /entry\\.js$/ }, args => ({ path: args.path, external: true })); + }, + }, + { + name: "decline-disabled", + setup(build) { + build.onResolve({ filter: /disabled\\.js$/ }, args => { + declined.push(args.path); + }); + }, + }, + ]; + const results = {}; + for (const [name, entrypoints] of Object.entries({ + external: ["./entry.js"], + externalNextToLiveEntryPoint: ["./entry.js", "./live.js"], + declinedThenDisabledByBrowserField: ["./disabled.js"], + })) { + const result = await Bun.build({ entrypoints, target: "browser", plugins, throw: false }); + results[name] = { + success: result.success, + logs: result.logs.map(log => log.message), + outputs: result.outputs.map(output => output.path), + }; + } + results.declined = declined; + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.mjs"], + 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).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + external: { + success: false, + logs: ['The entry point "./entry.js" cannot be marked as external'], + outputs: [], + }, + externalNextToLiveEntryPoint: { + success: false, + logs: ['The entry point "./entry.js" cannot be marked as external'], + outputs: [], + }, + declinedThenDisabledByBrowserField: { + success: false, + logs: ['"./disabled.js" is disabled due to "browser" field in package.json (entry point)'], + outputs: [], + }, + declined: ["./disabled.js"], + }); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index 92d1011a59b0..31a502be4814 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -369,6 +369,15 @@ describe("web worker", () => { expect(err.message).toBe("5"); expect(err.error).toBe(null); }); + + test("names the entry point when its path is too long for a path buffer", async () => { + // Resolving it failed without logging anything, so the event carried + // "BuildMessage: undefined". Longer than the buffer on every platform. + const specifier = "./" + Buffer.alloc(100_000, "w").toString(); + const worker = new Worker(specifier); + const [err] = await once(worker, "error"); + expect(err.message).toBe(`BuildMessage: ModuleNotFound resolving "${specifier}" (entry point)`); + }); }); describe("terminate() races and lifecycle edges", () => { From 1f277d4fa33f9df1385b0df14fdc554d4053d7e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:17:22 +0000 Subject: [PATCH 2/4] bundler, worker: drop the no-path arms that resolve_entry_point now makes unreachable --- src/bundler/bundle_v2.rs | 7 +++---- src/jsc/web_worker.rs | 13 ++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index e055a4fd0518..5e59c81c19d3 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2642,10 +2642,9 @@ pub mod bv2_impl { let result = &mut *resolve; // borrowck: clone the active path out so we don't hold a `&mut` // into `result` across the `&mut self` calls below. - let mut path: Fs::Path<'static> = match result.path() { - Some(p) => *p, - None => return Ok(None), - }; + let mut path: Fs::Path<'static> = *result + .path() + .expect("resolve_entry_point rejects disabled results and FileMap results have a path"); path.assert_file_path_is_absolute(); // borrowck: get-then-put instead of a single get-or-put. diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 3778bc99127d..1db201b529f3 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1418,11 +1418,10 @@ unsafe fn resolve_entry_point_specifier<'s>( // `Path::text` borrows the resolver's process-lifetime `dirname_store` / // `filename_store` (`Path<'static>`), NOT `resolved_entry_point` itself — // copy the slice out and let `resolved_entry_point` drop on the stack. - match resolved_entry_point.path_const() { - Some(entry_path) => Some(entry_path.text), - None => { - *error_message = BunString::static_(b"Worker entry point is missing"); - None - } - } + Some( + resolved_entry_point + .path_const() + .expect("resolve_entry_point rejects disabled results") + .text, + ) } From 7eccdddc8f9b7ac2af828f9e79e63a5020aeeb2f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:33:17 +0000 Subject: [PATCH 3/4] [autofix.ci] apply automated fixes --- src/bundler/bundle_v2.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 5e59c81c19d3..9072a0f42c6b 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2642,9 +2642,9 @@ pub mod bv2_impl { let result = &mut *resolve; // borrowck: clone the active path out so we don't hold a `&mut` // into `result` across the `&mut self` calls below. - let mut path: Fs::Path<'static> = *result - .path() - .expect("resolve_entry_point rejects disabled results and FileMap results have a path"); + let mut path: Fs::Path<'static> = *result.path().expect( + "resolve_entry_point rejects disabled results and FileMap results have a path", + ); path.assert_file_path_is_absolute(); // borrowck: get-then-put instead of a single get-or-put. From 06d768ca82af93e89f651dbe5cb400a6cb66ba68 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:33:43 +0000 Subject: [PATCH 4/4] bundler: trim the comments on the entry point error paths --- src/bundler/bundle_v2.rs | 2 -- src/bundler/transpiler.rs | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 9072a0f42c6b..7ce2ea998eed 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4799,8 +4799,6 @@ pub mod bv2_impl { drop(result.path); } } else { - // An external import is left as is in the importer; an external - // entry point has nothing to emit, so it is a build error (as in esbuild). if resolve.import_record.kind == ImportKind::EntryPointBuild { let log = this.log_for_resolution_failures( &resolve.import_record.source_file, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index afc910fc9bb2..c84341e5fa3a 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -469,9 +469,7 @@ impl<'a> Transpiler<'a> { // disjoint mutable borrows of `cache_bust_buf` across `break`, // so compute `busted` directly instead. let busted: bool = 'name: { - // Nothing that long names a directory whose cache could be - // stale (and neither buster name below would fit - // `cache_bust_buf`). + // Neither buster name below would fit `cache_bust_buf`. if self.fs().top_level_dir.len() + entry_point.len() + 4 > bun_paths::MAX_PATH_BYTES {