From 6b57bbbc00cfed0af5baa8176f77b256ba748076 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:55:18 +0000 Subject: [PATCH 1/3] test(serve): run serve-error-handler-stream cases in one subprocess Twelve concurrent debug/ASAN subprocesses each starting a server for a single request made this file the slowest on the debian x64-asan lane at 39s. The fixture now starts one server, runs all twelve (path, Connection) combinations against it, and prints the observed status/len/pulls for each as a JSON array; the test asserts the whole array in one toEqual so a regression shows exactly which cases truncated. The per-chunk Bun.sleep(4) / Bun.sleep(10) delays are replaced with Bun.sleep(0), which still yields to a macrotask and so still reaches the Pending branch in do_render_stream / handle_reject. Reverting the #33816 src/ change reintroduces the heap-use-after-free in uws_res_has_responded under the single subprocess, so regression detection is preserved. bun bd test (local, debug+ASAN): 8.4s -> 3.8s wall, 24.6s -> 3.7s user. --- .../serve-error-handler-stream-fixture.ts | 60 ++++++--- .../http/serve-error-handler-stream.test.ts | 120 +++++++----------- 2 files changed, 89 insertions(+), 91 deletions(-) diff --git a/test/js/bun/http/serve-error-handler-stream-fixture.ts b/test/js/bun/http/serve-error-handler-stream-fixture.ts index a737a7d17144..5c7159ca74d0 100644 --- a/test/js/bun/http/serve-error-handler-stream-fixture.ts +++ b/test/js/bun/http/serve-error-handler-stream-fixture.ts @@ -1,16 +1,20 @@ // Fixture for serve-error-handler-stream.test.ts. -// Runs a server whose error() returns a streaming Response, issues one request -// to the given path, and prints {status, len, pulls} to stdout. The test -// asserts the full body was received. Runs in a subprocess so an ASAN crash -// (the pre-fix UAF in uws_res_has_responded) is observed as a test failure -// instead of killing the parent test runner before junit is written. - -const [path, closeHeader] = process.argv.slice(2); +// Starts one server whose error() returns a streaming Response, exercises every +// (path × Connection header) combination against it, and prints the observed +// {status, len, pulls} for each case as a JSON array. Runs in a subprocess so +// an ASAN crash (the pre-fix UAF in uws_res_has_responded) is observed as a +// test failure instead of killing the parent test runner before junit is +// written. const CHUNK = Buffer.alloc(64, "P").toString(); const CHUNKS = 12; let pulls = 0; +// Bun.sleep(0) is setTimeout(fn, 0): it yields to the next macrotask, which is +// all that is needed for the sink's pump promise to be observed as Pending and +// reach do_render_stream's / handle_reject's Pending branch. +const tick = () => Bun.sleep(0); + function chunkedBody() { let i = 0; return new ReadableStream({ @@ -18,7 +22,7 @@ function chunkedBody() { pulls++; if (i++ < CHUNKS) { c.enqueue(CHUNK); - await Bun.sleep(4); + await tick(); } else { c.close(); } @@ -30,7 +34,7 @@ function lazyBody() { return new ReadableStream({ async pull(c) { pulls++; - await Bun.sleep(10); + await tick(); c.enqueue(CHUNK); c.close(); }, @@ -45,7 +49,7 @@ function directBody() { pulls++; c.write(CHUNK); await c.flush(); - await Bun.sleep(4); + await tick(); } await c.end(); }, @@ -56,7 +60,7 @@ async function* iteratorBody() { for (let i = 0; i < CHUNKS; i++) { pulls++; yield CHUNK; - await Bun.sleep(4); + await tick(); } } @@ -94,12 +98,34 @@ const server = Bun.serve({ }, }); -const headers: Record = {}; -if (closeHeader === "close") headers.Connection = "close"; +const cases = [ + // Controls: neither path hits handle_reject()'s fallthrough. + { path: "/plain", close: false }, + { path: "/sync", close: false }, + // The bug: async handler rejects, error() body is truncated to its + // synchronous prefix. Run keep-alive before Connection: close so a UAF + // on the close path doesn't mask earlier results. + { path: "/async", close: false }, + { path: "/reject", close: false }, + { path: "/lazy", close: false }, + { path: "/direct", close: false }, + { path: "/iter", close: false }, + { path: "/async", close: true }, + { path: "/reject", close: true }, + { path: "/lazy", close: true }, + { path: "/direct", close: true }, + { path: "/iter", close: true }, +]; -const res = await fetch(`http://127.0.0.1:${server.port}${path}`, { headers }); -const body = await res.text(); +const results: { path: string; close: boolean; status: number; len: number; pulls: number }[] = []; +for (const { path, close } of cases) { + pulls = 0; + const headers: Record = close ? { Connection: "close" } : {}; + const res = await fetch(`http://127.0.0.1:${server.port}${path}`, { headers }); + const body = await res.text(); + results.push({ path, close, status: res.status, len: body.length, pulls }); +} // Let any orphaned producer (pre-fix) write to the freed socket. -await Bun.sleep(100); -process.stdout.write(JSON.stringify({ status: res.status, len: body.length, pulls })); +await Bun.sleep(20); +process.stdout.write(JSON.stringify(results)); server.stop(true); diff --git a/test/js/bun/http/serve-error-handler-stream.test.ts b/test/js/bun/http/serve-error-handler-stream.test.ts index 2705ed57cd2f..af9cf21ce169 100644 --- a/test/js/bun/http/serve-error-handler-stream.test.ts +++ b/test/js/bun/http/serve-error-handler-stream.test.ts @@ -6,8 +6,9 @@ // first pull awaited before enqueuing). With Connection: close the freed // socket is then written by the orphaned sink: heap-use-after-free under ASAN. // -// Each case runs in a subprocess so a pre-fix ASAN crash is observed as a -// test failure rather than killing the parent runner before junit is written. +// All cases run in one subprocess so a pre-fix ASAN crash is observed as a +// test failure rather than killing the parent runner before junit is written, +// while paying subprocess / server startup once instead of per case. import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows } from "harness"; import { join } from "node:path"; @@ -16,82 +17,53 @@ const fixture = join(import.meta.dir, "serve-error-handler-stream-fixture.ts"); const CHUNKS = 12; const CHUNK_LEN = 64; -async function runFixture(path: string, close = false) { - await using proc = Bun.spawn({ - cmd: [bunExe(), fixture, path, ...(close ? ["close"] : [])], - // Malloc=1 forces system malloc so bmalloc/libpas pools don't mask the - // UAF from ASAN. bmalloc's SystemHeap is unimplemented on Windows and - // would RELEASE_BASSERT, so leave bmalloc in place there (no ASAN lane - // on Windows anyway). - env: { ...bunEnv, ...(isWindows ? {} : { Malloc: "1" }) }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr, exitCode }; -} - describe("Bun.serve error() returning a streaming Response", () => { - // Controls: neither path hits handle_reject()'s fallthrough. - test.concurrent("control: plain streaming response completes", async () => { - const { stdout, stderr, exitCode } = await runFixture("/plain"); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 200, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS + 1 }, - exitCode: 0, + test("delivers the full stream body for every async-reject shape", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture], + // Malloc=1 forces system malloc so bmalloc/libpas pools don't mask the + // UAF from ASAN. bmalloc's SystemHeap is unimplemented on Windows and + // would RELEASE_BASSERT, so leave bmalloc in place there (no ASAN lane + // on Windows anyway). + env: { ...bunEnv, ...(isWindows ? {} : { Malloc: "1" }) }, + stdout: "pipe", + stderr: "pipe", }); - }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - test.concurrent("control: sync throw -> error() stream completes", async () => { - const { stdout, stderr, exitCode } = await runFixture("/sync"); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS + 1 }, + const full = CHUNK_LEN * CHUNKS; + // On a pre-fix crash stdout is empty and stderr holds the ASAN report; + // showing stderr in that case makes the failure self-explanatory. + expect({ + results: stdout === "" ? stderr : JSON.parse(stdout), + stderr, + exitCode, + signalCode: proc.signalCode, + }).toEqual({ + results: [ + // Controls: neither path hits handle_reject()'s fallthrough. + { path: "/plain", close: false, status: 200, len: full, pulls: CHUNKS + 1 }, + { path: "/sync", close: false, status: 597, len: full, pulls: CHUNKS + 1 }, + // async reject → error() pull-stream: render_missing() must not + // truncate the body to its synchronous prefix. + { path: "/async", close: false, status: 597, len: full, pulls: CHUNKS + 1 }, + { path: "/reject", close: false, status: 597, len: full, pulls: CHUNKS + 1 }, + // First pull awaits before enqueuing: the pre-fix fallthrough emptied + // this to Content-Length: 0. + { path: "/lazy", close: false, status: 597, len: CHUNK_LEN, pulls: 1 }, + { path: "/direct", close: false, status: 597, len: full, pulls: CHUNKS }, + { path: "/iter", close: false, status: 597, len: full, pulls: CHUNKS }, + // Connection: close — the pre-fix orphaned producer writes to a + // freed uWS response here, which is the ASAN heap-use-after-free. + { path: "/async", close: true, status: 597, len: full, pulls: CHUNKS + 1 }, + { path: "/reject", close: true, status: 597, len: full, pulls: CHUNKS + 1 }, + { path: "/lazy", close: true, status: 597, len: CHUNK_LEN, pulls: 1 }, + { path: "/direct", close: true, status: 597, len: full, pulls: CHUNKS }, + { path: "/iter", close: true, status: 597, len: full, pulls: CHUNKS }, + ], + stderr: "", exitCode: 0, + signalCode: null, }); }); - - // The bug: async handler rejects, error() body is truncated to its - // synchronous prefix. - for (const close of [false, true]) { - const tag = close ? " (Connection: close)" : ""; - - test.concurrent(`async reject -> error() pull-stream completes${tag}`, async () => { - const { stdout, stderr, exitCode } = await runFixture("/async", close); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS + 1 }, - exitCode: 0, - }); - }); - - test.concurrent(`Promise.reject -> error() stream completes${tag}`, async () => { - const { stdout, stderr, exitCode } = await runFixture("/reject", close); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS + 1 }, - exitCode: 0, - }); - }); - - test.concurrent(`async reject -> error() stream whose first pull awaits is not emptied${tag}`, async () => { - const { stdout, stderr, exitCode } = await runFixture("/lazy", close); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN, pulls: 1 }, - exitCode: 0, - }); - }); - - test.concurrent(`async reject -> error() direct stream completes${tag}`, async () => { - const { stdout, stderr, exitCode } = await runFixture("/direct", close); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS }, - exitCode: 0, - }); - }); - - test.concurrent(`async reject -> error() async-iterator body completes${tag}`, async () => { - const { stdout, stderr, exitCode } = await runFixture("/iter", close); - expect({ result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }).toEqual({ - result: { status: 597, len: CHUNK_LEN * CHUNKS, pulls: CHUNKS }, - exitCode: 0, - }); - }); - } }); From 7188e2f1d35f2d9eca241e5dc4348f0d0fef0ce8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:17:06 +0000 Subject: [PATCH 2/3] test(serve): emit per-case NDJSON so a mid-loop crash names the case Address review: batch-at-end stdout left per-case progress invisible on a hang or mid-loop ASAN abort. The fixture now prints one JSON line per case as it completes and the test parses stdout line-by-line, so partial output names every case that finished and the diff points at the first one that didn't. --- test/js/bun/http/serve-error-handler-stream-fixture.ts | 6 +++--- test/js/bun/http/serve-error-handler-stream.test.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/serve-error-handler-stream-fixture.ts b/test/js/bun/http/serve-error-handler-stream-fixture.ts index 5c7159ca74d0..10c087d5bdf2 100644 --- a/test/js/bun/http/serve-error-handler-stream-fixture.ts +++ b/test/js/bun/http/serve-error-handler-stream-fixture.ts @@ -117,15 +117,15 @@ const cases = [ { path: "/iter", close: true }, ]; -const results: { path: string; close: boolean; status: number; len: number; pulls: number }[] = []; for (const { path, close } of cases) { pulls = 0; const headers: Record = close ? { Connection: "close" } : {}; const res = await fetch(`http://127.0.0.1:${server.port}${path}`, { headers }); const body = await res.text(); - results.push({ path, close, status: res.status, len: body.length, pulls }); + // One line per case so a mid-loop crash or hang leaves the completed cases + // in stdout and the failure diff names the first one that didn't finish. + console.log(JSON.stringify({ path, close, status: res.status, len: body.length, pulls })); } // Let any orphaned producer (pre-fix) write to the freed socket. await Bun.sleep(20); -process.stdout.write(JSON.stringify(results)); server.stop(true); diff --git a/test/js/bun/http/serve-error-handler-stream.test.ts b/test/js/bun/http/serve-error-handler-stream.test.ts index af9cf21ce169..aba05f55cb6f 100644 --- a/test/js/bun/http/serve-error-handler-stream.test.ts +++ b/test/js/bun/http/serve-error-handler-stream.test.ts @@ -32,10 +32,14 @@ describe("Bun.serve error() returning a streaming Response", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const full = CHUNK_LEN * CHUNKS; - // On a pre-fix crash stdout is empty and stderr holds the ASAN report; - // showing stderr in that case makes the failure self-explanatory. + // The fixture prints one JSON line per case, so a mid-loop crash or hang + // leaves the completed cases in stdout and the diff names the first one + // that didn't finish; stderr carries the ASAN report on a crash. expect({ - results: stdout === "" ? stderr : JSON.parse(stdout), + results: stdout + .split("\n") + .filter(Boolean) + .map(l => JSON.parse(l)), stderr, exitCode, signalCode: proc.signalCode, From 52e45af082ac2c660393daee3961ee20b6aeed4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:26:01 +0000 Subject: [PATCH 3/3] test(serve): fix stale header comment (NDJSON, not JSON array) --- test/js/bun/http/serve-error-handler-stream-fixture.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/bun/http/serve-error-handler-stream-fixture.ts b/test/js/bun/http/serve-error-handler-stream-fixture.ts index 10c087d5bdf2..041884e570c2 100644 --- a/test/js/bun/http/serve-error-handler-stream-fixture.ts +++ b/test/js/bun/http/serve-error-handler-stream-fixture.ts @@ -1,10 +1,10 @@ // Fixture for serve-error-handler-stream.test.ts. // Starts one server whose error() returns a streaming Response, exercises every // (path × Connection header) combination against it, and prints the observed -// {status, len, pulls} for each case as a JSON array. Runs in a subprocess so -// an ASAN crash (the pre-fix UAF in uws_res_has_responded) is observed as a -// test failure instead of killing the parent test runner before junit is -// written. +// {status, len, pulls} for each case as one JSON line per case. Runs in a +// subprocess so an ASAN crash (the pre-fix UAF in uws_res_has_responded) is +// observed as a test failure instead of killing the parent test runner before +// junit is written. const CHUNK = Buffer.alloc(64, "P").toString(); const CHUNKS = 12;