From 2a08c4d2a0a1dfef3d9ffd0e5169e76249303b22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:46:21 +0000 Subject: [PATCH 1/3] test(serve): a handler that throws the parsed request body must not kill the server The default Bun.serve error path prints the thrown value with the native console formatter. The client controls the shape of that value, so the depth cap from #35288 is what bounds the work. That change landed with console tests only. These two cases cover the server: - a 2000-deep array body: the printed error stays under 64 KB - a 50000-deep array body: the server answers 500 and serves the next request Both fail with the src changes of #35288 reverted, and on bun 1.4.2. --- test/js/bun/http/serve.test.ts | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e42c3d7a5974..f96e4b16ddc6 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -5438,3 +5438,81 @@ describe("requests pipelined in one read", () => { }); }); }); + +describe.concurrent("a handler that throws the parsed request body", () => { + // The default error path prints the thrown value with the native formatter. + // The client controls the shape of that value, so the formatter's depth cap + // is what keeps one request from taking the server process down. + const serverSource = ` +const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: false, + async fetch(req) { + if (new URL(req.url).pathname === "/ping") return new Response("pong"); + throw await req.json(); + }, +}); +console.log(server.port); +`; + + async function readPort(stdout: ReadableStream) { + const reader = stdout.getReader(); + const decoder = new TextDecoder(); + let line = ""; + while (!line.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + line += decoder.decode(value, { stream: true }); + } + reader.releaseLock(); + return Number(line.trim()); + } + + function nestedArray(depth: number) { + return Buffer.alloc(depth, "[").toString() + Buffer.alloc(depth, "]").toString(); + } + + it("bounds what the default error printer writes", async () => { + using dir = tempDir("serve-throw-deep-array", { "server.js": serverSource }); + // A file, not a pipe: with no depth cap this one request writes about 8 MB. + const errPath = join(String(dir), "stderr.log"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: Bun.file(errPath), + }); + const port = await readPort(proc.stdout); + + const res = await fetch(`http://127.0.0.1:${port}/api`, { method: "POST", body: nestedArray(2000) }); + expect(res.status).toBe(500); + + const ping = await fetch(`http://127.0.0.1:${port}/ping`); + expect(await ping.text()).toBe("pong"); + + const printed = readFileSync(errPath, "utf8"); + expect(printed.length).toBeLessThan(64 * 1024); + expect(printed).toContain("Array"); + }); + + it("keeps serving when the body is too deep for the printer to walk", async () => { + using dir = tempDir("serve-throw-deeper-array", { "server.js": serverSource }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "ignore", + }); + const port = await readPort(proc.stdout); + + const res = await fetch(`http://127.0.0.1:${port}/api`, { method: "POST", body: nestedArray(50_000) }); + expect(res.status).toBe(500); + + const ping = await fetch(`http://127.0.0.1:${port}/ping`); + expect(await ping.text()).toBe("pong"); + expect(proc.signalCode).toBeNull(); + }); +}); From 62c15304f666355316120e492b315abfb3235be4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:26:38 +0000 Subject: [PATCH 2/3] test(serve): stop the server before reading its exit status The signalCode check ran while the child was alive, so it could not fail. The fixture now has a /stop route. The test asks the server to stop, waits for the exit, and then reads the exit code and the signal. The exit code is 1 and not 0. An error that reached the default handler sets the exit code for an otherwise clean exit (Run::start). Also: readPort throws when stdout closes before a port line, so a fixture that fails to start does not surface as a connection error on port 0. The bounded-output case matches the exact cap marker. --- test/js/bun/http/serve.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index f96e4b16ddc6..f64443686399 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -5449,7 +5449,12 @@ const server = Bun.serve({ hostname: "127.0.0.1", development: false, async fetch(req) { - if (new URL(req.url).pathname === "/ping") return new Response("pong"); + const { pathname } = new URL(req.url); + if (pathname === "/ping") return new Response("pong"); + if (pathname === "/stop") { + server.stop(); + return new Response("bye"); + } throw await req.json(); }, }); @@ -5462,7 +5467,7 @@ console.log(server.port); let line = ""; while (!line.includes("\n")) { const { value, done } = await reader.read(); - if (done) break; + if (done) throw new Error(`the server closed stdout before it printed a port: ${JSON.stringify(line)}`); line += decoder.decode(value, { stream: true }); } reader.releaseLock(); @@ -5494,7 +5499,7 @@ console.log(server.port); const printed = readFileSync(errPath, "utf8"); expect(printed.length).toBeLessThan(64 * 1024); - expect(printed).toContain("Array"); + expect(printed).toContain("[Array ...]"); }); it("keeps serving when the body is too deep for the printer to walk", async () => { @@ -5513,6 +5518,11 @@ console.log(server.port); const ping = await fetch(`http://127.0.0.1:${port}/ping`); expect(await ping.text()).toBe("pong"); + + const stop = await fetch(`http://127.0.0.1:${port}/stop`); + expect(await stop.text()).toBe("bye"); + // 1, not 0: an error that reached the default handler sets the exit code. + expect(await proc.exited).toBe(1); expect(proc.signalCode).toBeNull(); }); }); From e78196909fa134ccbff40902d9171549fe29c630 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:59:31 +0000 Subject: [PATCH 3/3] test(serve): run the single-file fixture with -e The server fixture is one file, so both cases run it with `bun -e` and do not write a server.js into a temp directory. The first case keeps a temp directory for stderr.log. The second case needs none. --- test/js/bun/http/serve.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index f64443686399..a530ae61dc26 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -5479,13 +5479,12 @@ console.log(server.port); } it("bounds what the default error printer writes", async () => { - using dir = tempDir("serve-throw-deep-array", { "server.js": serverSource }); // A file, not a pipe: with no depth cap this one request writes about 8 MB. + using dir = tempDir("serve-throw-deep-array", {}); const errPath = join(String(dir), "stderr.log"); await using proc = Bun.spawn({ - cmd: [bunExe(), "server.js"], + cmd: [bunExe(), "-e", serverSource], env: bunEnv, - cwd: String(dir), stdout: "pipe", stderr: Bun.file(errPath), }); @@ -5503,11 +5502,9 @@ console.log(server.port); }); it("keeps serving when the body is too deep for the printer to walk", async () => { - using dir = tempDir("serve-throw-deeper-array", { "server.js": serverSource }); await using proc = Bun.spawn({ - cmd: [bunExe(), "server.js"], + cmd: [bunExe(), "-e", serverSource], env: bunEnv, - cwd: String(dir), stdout: "pipe", stderr: "ignore", });