From 3d62981ca42b94c765fe1ae6800a42001f4e8207 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sat, 17 Oct 2026 07:28:26 +0900 Subject: [PATCH 1/4] bake: free the bundler plugin when the app config is rejected --- src/runtime/bake/config/bundler.rs | 8 ++++++ src/runtime/bake/config/mod.rs | 39 ++++++++++++++++++------------ test/bake/app-options.test.ts | 31 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/runtime/bake/config/bundler.rs b/src/runtime/bake/config/bundler.rs index 9408dfa4dbb1..6415e920bd6f 100644 --- a/src/runtime/bake/config/bundler.rs +++ b/src/runtime/bake/config/bundler.rs @@ -46,6 +46,14 @@ impl Default for BuildConfigSubset { } impl SplitBundlerOptions { + /// `PluginJscExt::destroy` is the paired destructor of `Plugin::create` + /// in `parse_plugin_array`: it unprotects the JSCell. + pub(super) fn destroy_plugin(&mut self) { + if let Some(p) = self.plugin.take() { + Plugin::destroy(p.as_ptr()); + } + } + pub(super) fn parse_plugin_array( &mut self, plugin_array: JSValue, diff --git a/src/runtime/bake/config/mod.rs b/src/runtime/bake/config/mod.rs index a4b9afe644a4..df97a2b83e80 100644 --- a/src/runtime/bake/config/mod.rs +++ b/src/runtime/bake/config/mod.rs @@ -1,7 +1,5 @@ //! The Bake config: one type for both `bun index.html` (no config) and `bun ./app.ts` (an `app` export). -use crate::api::js_bundler::Plugin; -use crate::api::js_bundler::js_bundler::PluginJscExt as _; use bun_alloc::Arena; use bun_core::{ZBox, strings}; use bun_jsc::{JSGlobalObject, JSValue, JsResult}; @@ -29,13 +27,7 @@ pub(crate) struct UserOptions { impl Drop for UserOptions { fn drop(&mut self) { - if let Some(p) = self.bundler_options.plugin { - // `p` is the FFI handle returned by `Plugin::create` in - // `parse_plugin_array`; `PluginJscExt::destroy` is its paired - // (safe) destructor — it null-checks via `opaque_ref` and - // unprotect()s the JSCell / tombstones the C++ object. - Plugin::destroy(p.as_ptr()); - } + self.bundler_options.destroy_plugin(); } } @@ -87,6 +79,26 @@ impl UserOptions { } } + match Self::parse_framework_root_and_plugins(config, global, &mut bundler_options) { + Ok((framework, root)) => Ok(UserOptions { + root, + framework, + bundler_options, + arena, + }), + Err(err) => { + // `UserOptions` was never built, so its `Drop` will not free the plugin. + bundler_options.destroy_plugin(); + Err(err) + } + } + } + + fn parse_framework_root_and_plugins( + config: JSValue, + global: &JSGlobalObject, + bundler_options: &mut SplitBundlerOptions, + ) -> JsResult<(Framework, ZBox)> { let framework = Framework::from_js( match config.get(global, "framework")? { Some(v) => v, @@ -98,7 +110,7 @@ impl UserOptions { } }, global, - &mut bundler_options, + bundler_options, )?; let root = if let Some(slice) = config.get_optional_slice(global, "root")? { @@ -117,11 +129,6 @@ impl UserOptions { bundler_options.parse_plugin_array(plugin_array, global)?; } - Ok(UserOptions { - root, - framework, - bundler_options, - arena, - }) + Ok((framework, root)) } } diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index f9cf2159749e..bd473b4d16c9 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -88,3 +88,34 @@ test('Bun.serve({ app }) reports a framework entry point that a "browser" map di expect(stdout).toBe("threw: Framework is missing required files!\n"); expect(exitCode).toBe(0); }); + +test.concurrent("Bun.serve({ app }) frees the bundler plugin when the config is rejected", async () => { + using dir = tempDir("bake-app-plugin-leak", { + "fixture.ts": ` + import { heapStats } from "bun:jsc"; + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + // The framework's plugins are parsed first, then root fails the type check. + const app = { framework: { fileSystemRouterTypes: [fsr], plugins: [] }, root: 123 }; + const count = () => heapStats().protectedObjectTypeCounts.BundlerPlugin ?? 0; + const before = count(); + for (let i = 0; i < 10; i++) { + try { + Bun.serve({ port: 0, development: true, app, fetch: () => new Response() }).stop(true); + } catch {} + } + console.log("leaked plugins: " + (count() - before)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + 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).toBe("leaked plugins: 0\n"); + expect(exitCode).toBe(0); +}); From 31b72c234be524184318d75eec53d21708879f35 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sat, 17 Oct 2026 07:28:27 +0900 Subject: [PATCH 2/4] bake: read fileSystemRouterTypes[].ignoreDirs from its own array --- src/runtime/bake/config/framework.rs | 10 +++++----- test/bake/app-options.test.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/runtime/bake/config/framework.rs b/src/runtime/bake/config/framework.rs index ca76226873f7..9c4d647d603d 100644 --- a/src/runtime/bake/config/framework.rs +++ b/src/runtime/bake/config/framework.rs @@ -497,11 +497,11 @@ impl Framework { ]) }; - let ignore_dirs = if let Some(exts_js) = fsr_opts.get(global, "ignoreDirs")? { + let ignore_dirs = if let Some(dirs_js) = fsr_opts.get(global, "ignoreDirs")? { 'exts: { - if exts_js.is_array() { - let mut it_2 = array.array_iterator(global)?; - let mut dirs = Vec::with_capacity(len as usize); + if dirs_js.is_array() { + let mut it_2 = dirs_js.array_iterator(global)?; + let mut dirs = Vec::with_capacity(dirs_js.get_length(global)? as usize); while let Some(array_item) = it_2.next()? { dirs.push(array_item.to_utf8(global)?); } @@ -509,7 +509,7 @@ impl Framework { } return Err(global.throw_invalid_arguments(format_args!( - "'ignoreDirs' must be an array of strings or \"*\" for all extensions" + "'ignoreDirs' must be an array of strings" ))); } } else { diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index bd473b4d16c9..a9bd1fa15925 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -119,3 +119,32 @@ test.concurrent("Bun.serve({ app }) frees the bundler plugin when the config is expect(stdout).toBe("leaked plugins: 0\n"); expect(exitCode).toBe(0); }); + +test.concurrent("Bun.serve({ app }) skips the directories in fileSystemRouterTypes[].ignoreDirs", async () => { + using dir = tempDir("bake-app-ignore-dirs", { + "server.ts": `export function render() { return new Response("ok"); }`, + "routes/index.ts": `export default 1;`, + // Both routes are invalid. Only the one outside ignoreDirs is scanned and reported. + "routes/skipped/[].ts": `export default 1;`, + "routes/scanned/[].ts": `export default 1;`, + "fixture.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts", ignoreDirs: ["skipped"] }; + Bun.serve({ port: 0, development: true, app: { framework: { fileSystemRouterTypes: [fsr] } } }).stop(true); + console.log("started"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + 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).toContain("scanned/[].ts"); + expect(stderr).not.toContain("skipped/[].ts"); + expect(stdout).toBe("started\n"); + expect(exitCode).toBe(0); +}); From ed30f723a7d914220cf2adbfa29ea33beda5ff2d Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sat, 17 Oct 2026 07:28:27 +0900 Subject: [PATCH 3/4] bake: give the too-many-params route error a cursor --- src/runtime/bake/router/mod.rs | 2 ++ test/bake/app-options.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/runtime/bake/router/mod.rs b/src/runtime/bake/router/mod.rs index 156c9167efc3..7542115bbf73 100644 --- a/src/runtime/bake/router/mod.rs +++ b/src/runtime/bake/router/mod.rs @@ -708,6 +708,8 @@ impl FrameworkRouter { if param_count > 64 { log.write(format_args!("Pattern cannot have more than 64 param")); + log.cursor_at = 0; + log.cursor_len = u32::try_from(full_rel_path.len()).expect("int cast"); ctx.on_router_syntax_error(full_rel_path, log)?; arena_state.reset_retain_with_limit(8 * 1024 * 1024); continue 'outer; diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index a9bd1fa15925..e6a36ad2ffd4 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -148,3 +148,29 @@ test.concurrent("Bun.serve({ app }) skips the directories in fileSystemRouterTyp expect(stdout).toBe("started\n"); expect(exitCode).toBe(0); }); + +test.concurrent("Bun.serve({ app }) reports a route with more than 64 params", async () => { + const route = Array.from({ length: 65 }, (_, i) => `[p${i}]`).join("/"); + using dir = tempDir("bake-app-too-many-params", { + "server.ts": `export function render() { return new Response("ok"); }`, + [`routes/${route}.ts`]: `export default 1;`, + "fixture.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + Bun.serve({ port: 0, development: true, app: { framework: { fileSystemRouterTypes: [fsr] } } }).stop(true); + console.log("started"); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + 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).toContain("Pattern cannot have more than 64 param"); + expect(stdout).toBe("started\n"); + expect(exitCode).toBe(0); +}); From 6dac533caa9c571ca89866ab8346953d3c597494 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sat, 17 Oct 2026 08:27:40 +0900 Subject: [PATCH 4/4] bake: check that the leak test's config is rejected --- test/bake/app-options.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index e6a36ad2ffd4..d686e9b5eb22 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -98,11 +98,16 @@ test.concurrent("Bun.serve({ app }) frees the bundler plugin when the config is const app = { framework: { fileSystemRouterTypes: [fsr], plugins: [] }, root: 123 }; const count = () => heapStats().protectedObjectTypeCounts.BundlerPlugin ?? 0; const before = count(); + const errors = new Set(); for (let i = 0; i < 10; i++) { try { Bun.serve({ port: 0, development: true, app, fetch: () => new Response() }).stop(true); - } catch {} + errors.add("no error"); + } catch (e) { + errors.add(e.code); + } } + console.log("errors: " + [...errors].join(", ")); console.log("leaked plugins: " + (count() - before)); `, }); @@ -116,7 +121,7 @@ test.concurrent("Bun.serve({ app }) frees the bundler plugin when the config is }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("leaked plugins: 0\n"); + expect(stdout).toBe("errors: ERR_INVALID_ARG_TYPE\nleaked plugins: 0\n"); expect(exitCode).toBe(0); });