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..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,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 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; 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(); +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(); + // 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(100); -process.stdout.write(JSON.stringify({ status: res.status, len: body.length, pulls })); +await Bun.sleep(20); 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..aba05f55cb6f 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,57 @@ 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; + // 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 + .split("\n") + .filter(Boolean) + .map(l => JSON.parse(l)), + 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, - }); - }); - } });