Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions test/js/bun/http/serve-error-handler-stream-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

124 changes: 50 additions & 74 deletions test/js/bun/http/serve-error-handler-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
});
});
}
});
Loading