From 3b7d3b30087d5b189d0eaf921eb99b2aa234162a Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 19:16:48 +0100 Subject: [PATCH 1/4] fix(cli): flush NDJSON before forced exit --- packages/cli/src/cli/cmd/run.ts | 3 +- packages/cli/src/cli/stdout.ts | 43 +++++++++++++++++++++++++ packages/cli/src/headless.ts | 2 ++ packages/cli/src/index.ts | 2 ++ packages/cli/test/cli/fixture/stdout.ts | 16 +++++++++ packages/cli/test/cli/stdout.test.ts | 39 ++++++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/cli/stdout.ts create mode 100644 packages/cli/test/cli/fixture/stdout.ts create mode 100644 packages/cli/test/cli/stdout.test.ts diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 7e17273..3313b7e 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -35,6 +35,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { Stdout } from "../stdout" type ToolProps = { input: Tool.InferParameters @@ -465,7 +466,7 @@ export const RunCommand = cmd({ function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + Stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) return true } return false diff --git a/packages/cli/src/cli/stdout.ts b/packages/cli/src/cli/stdout.ts new file mode 100644 index 0000000..a10bea1 --- /dev/null +++ b/packages/cli/src/cli/stdout.ts @@ -0,0 +1,43 @@ +const state = { + closed: false, + queue: Promise.resolve(), +} + +function pipe(error: unknown) { + return error instanceof Error && "code" in error && error.code === "EPIPE" +} + +function output(chunk: string, flush = false) { + if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve() + return new Promise((resolve) => { + const drain = () => resolve() + const wait = { ready: true, done: false } + wait.ready = process.stdout.write(chunk, (error) => { + wait.done = true + if (error) state.closed = true + if (!wait.ready) process.stdout.off("drain", drain) + resolve() + }) + if (!wait.ready && !wait.done && !flush) process.stdout.once("drain", drain) + }) +} + +process.stdout.on("error", (error) => { + if (pipe(error)) { + state.closed = true + return + } + throw error +}) + +export namespace Stdout { + export function write(chunk: string) { + state.queue = state.queue.then(() => output(chunk)) + return state.queue + } + + export async function flush() { + await state.queue + await output("", true) + } +} diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 71f2eff..e5d4b35 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -19,6 +19,7 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" +import { Stdout } from "./cli/stdout" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -154,5 +155,6 @@ try { } process.exitCode = 1 } finally { + await Stdout.flush() process.exit() } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8a333a5..c0314ac 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -29,6 +29,7 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" +import { Stdout } from "./cli/stdout" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -212,5 +213,6 @@ try { // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. + await Stdout.flush() process.exit() } diff --git a/packages/cli/test/cli/fixture/stdout.ts b/packages/cli/test/cli/fixture/stdout.ts new file mode 100644 index 0000000..9810d51 --- /dev/null +++ b/packages/cli/test/cli/fixture/stdout.ts @@ -0,0 +1,16 @@ +import { Stdout } from "../../../src/cli/stdout" + +const records = Array.from({ length: 1024 }, (_, index) => + JSON.stringify({ + type: "data", + index, + value: "x".repeat(2048), + }), +) + +Array.from({ length: process.argv.includes("--epipe") ? 8 : 1 }).forEach(() => + records.forEach((record) => Stdout.write(record + "\n")), +) +Stdout.write(JSON.stringify({ type: "complete" }) + "\n") +await Stdout.flush() +process.exit() diff --git a/packages/cli/test/cli/stdout.test.ts b/packages/cli/test/cli/stdout.test.ts new file mode 100644 index 0000000..275ddbd --- /dev/null +++ b/packages/cli/test/cli/stdout.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +describe("stdout", () => { + test("flushes NDJSON beyond pipe capacity before forced exit", async () => { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts")], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) + const output = new Response(child.stdout).text() + const error = new Response(child.stderr).text() + + expect(await child.exited).toBe(0) + expect(await error).toBe("") + + const lines = (await output) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + expect(lines).toHaveLength(1025) + expect(lines.at(-1)).toEqual({ type: "complete" }) + }) + + test("treats a closed output pipe as controlled", async () => { + const child = Bun.spawn( + [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--epipe"], + { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }, + ) + await child.stdout.cancel() + + expect(await child.exited).toBe(0) + expect(await new Response(child.stderr).text()).toBe("") + }) +}) From e8a7a143e0812d500f7f877329bbcca85b4ba8f3 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 19:56:08 +0100 Subject: [PATCH 2/4] fix(cli): harden stdout drain semantics --- packages/cli/src/cli/stdout.ts | 56 ++++++++++++++----------- packages/cli/test/cli/fixture/stdout.ts | 12 ++++++ packages/cli/test/cli/stdout.test.ts | 38 ++++++++++++++++- 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/cli/stdout.ts b/packages/cli/src/cli/stdout.ts index a10bea1..192178e 100644 --- a/packages/cli/src/cli/stdout.ts +++ b/packages/cli/src/cli/stdout.ts @@ -1,43 +1,49 @@ const state = { + bound: false, closed: false, - queue: Promise.resolve(), + error: undefined as unknown, + pending: new Set>(), } -function pipe(error: unknown) { +function epipe(error: unknown) { return error instanceof Error && "code" in error && error.code === "EPIPE" } -function output(chunk: string, flush = false) { - if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve() - return new Promise((resolve) => { - const drain = () => resolve() - const wait = { ready: true, done: false } - wait.ready = process.stdout.write(chunk, (error) => { - wait.done = true - if (error) state.closed = true - if (!wait.ready) process.stdout.off("drain", drain) - resolve() - }) - if (!wait.ready && !wait.done && !flush) process.stdout.once("drain", drain) - }) -} - -process.stdout.on("error", (error) => { - if (pipe(error)) { +function fail(error: unknown) { + if (epipe(error)) { state.closed = true return } - throw error -}) + state.error = error +} + +function bind() { + if (state.bound) return + state.bound = true + process.stdout.on("error", fail) +} export namespace Stdout { export function write(chunk: string) { - state.queue = state.queue.then(() => output(chunk)) - return state.queue + bind() + if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve() + const pending = new Promise((resolve) => { + process.stdout.write(chunk, (error) => { + if (error) fail(error) + resolve() + }) + }) + state.pending.add(pending) + pending.then(() => state.pending.delete(pending)) + return pending } export async function flush() { - await state.queue - await output("", true) + while (state.pending.size) await Promise.all(state.pending) + if (state.error) throw state.error + } + + export function closed() { + return state.closed } } diff --git a/packages/cli/test/cli/fixture/stdout.ts b/packages/cli/test/cli/fixture/stdout.ts index 9810d51..1a79e14 100644 --- a/packages/cli/test/cli/fixture/stdout.ts +++ b/packages/cli/test/cli/fixture/stdout.ts @@ -1,5 +1,16 @@ import { Stdout } from "../../../src/cli/stdout" +if (process.argv.includes("--idle")) process.exit(process.stdout.listenerCount("error")) +if (process.argv.includes("--error")) { + Stdout.write("") + await Stdout.flush() + process.stdout.emit("error", Object.assign(new Error("broken stdout"), { code: "EIO" })) + await Stdout.flush().then( + () => process.exit(2), + (error) => process.exit(error instanceof Error && error.message === "broken stdout" ? 0 : 3), + ) +} + const records = Array.from({ length: 1024 }, (_, index) => JSON.stringify({ type: "data", @@ -13,4 +24,5 @@ Array.from({ length: process.argv.includes("--epipe") ? 8 : 1 }).forEach(() => ) Stdout.write(JSON.stringify({ type: "complete" }) + "\n") await Stdout.flush() +if (process.argv.includes("--epipe")) process.exit(Stdout.closed() ? 0 : 2) process.exit() diff --git a/packages/cli/test/cli/stdout.test.ts b/packages/cli/test/cli/stdout.test.ts index 275ddbd..9eb625a 100644 --- a/packages/cli/test/cli/stdout.test.ts +++ b/packages/cli/test/cli/stdout.test.ts @@ -24,14 +24,48 @@ describe("stdout", () => { test("treats a closed output pipe as controlled", async () => { const child = Bun.spawn( - [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--epipe"], + [ + "bash", + "-c", + 'set -o pipefail; "$1" "$2" --epipe | head -c 1 >/dev/null', + "--", + process.execPath, + path.join(import.meta.dir, "fixture", "stdout.ts"), + ], + { + cwd: path.join(import.meta.dir, "../.."), + stdout: "ignore", + stderr: "pipe", + }, + ) + + expect(await child.exited).toBe(0) + expect(await new Response(child.stderr).text()).toBe("") + }) + + test("does not bind an error handler until output is written", async () => { + const child = Bun.spawn( + [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--idle"], + { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }, + ) + + expect(await child.exited).toBe(0) + expect(await new Response(child.stderr).text()).toBe("") + }) + + test("surfaces unexpected output errors from flush", async () => { + const child = Bun.spawn( + [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--error"], { cwd: path.join(import.meta.dir, "../.."), stdout: "pipe", stderr: "pipe", }, ) - await child.stdout.cancel() expect(await child.exited).toBe(0) expect(await new Response(child.stderr).text()).toBe("") From 6e4b02ff03c74949821502ef368cfdc003ef6d46 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 19 Jul 2026 20:17:32 +0100 Subject: [PATCH 3/4] fix(cli): preserve exit after stdout failures --- packages/cli/src/cli/stdout.ts | 31 +++++++---- packages/cli/src/headless.ts | 7 ++- packages/cli/src/index.ts | 7 ++- packages/cli/test/cli/fixture/stdout.ts | 17 ++++-- packages/cli/test/cli/stdout.test.ts | 69 +++++++++++++------------ 5 files changed, 82 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/cli/stdout.ts b/packages/cli/src/cli/stdout.ts index 192178e..a59a0af 100644 --- a/packages/cli/src/cli/stdout.ts +++ b/packages/cli/src/cli/stdout.ts @@ -1,9 +1,9 @@ const state = { bound: false, closed: false, - error: undefined as unknown, pending: new Set>(), } +let failure: unknown function epipe(error: unknown) { return error instanceof Error && "code" in error && error.code === "EPIPE" @@ -12,9 +12,10 @@ function epipe(error: unknown) { function fail(error: unknown) { if (epipe(error)) { state.closed = true - return + return false } - state.error = error + failure = error + return true } function bind() { @@ -26,24 +27,36 @@ function bind() { export namespace Stdout { export function write(chunk: string) { bind() + if (failure !== undefined) { + const pending = Promise.reject(failure) + pending.catch(() => {}) + return pending + } if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve() - const pending = new Promise((resolve) => { + const pending = new Promise((resolve, reject) => { process.stdout.write(chunk, (error) => { - if (error) fail(error) + if (error && fail(error)) { + reject(error) + return + } resolve() }) }) state.pending.add(pending) - pending.then(() => state.pending.delete(pending)) + pending.catch(() => {}) + pending.then( + () => state.pending.delete(pending), + () => state.pending.delete(pending), + ) return pending } export async function flush() { - while (state.pending.size) await Promise.all(state.pending) - if (state.error) throw state.error + while (state.pending.size) await Promise.allSettled(state.pending) + if (failure !== undefined) throw failure } - export function closed() { + export function isClosed() { return state.closed } } diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index e5d4b35..ce56cd3 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -155,6 +155,11 @@ try { } process.exitCode = 1 } finally { - await Stdout.flush() + await Stdout.flush().catch((error) => { + Log.Default.error("stdout flush failed", { + error: error instanceof Error ? error.message : error, + }) + process.exitCode = 1 + }) process.exit() } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c0314ac..4108d7f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -213,6 +213,11 @@ try { // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. - await Stdout.flush() + await Stdout.flush().catch((error) => { + Log.Default.error("stdout flush failed", { + error: error instanceof Error ? error.message : error, + }) + process.exitCode = 1 + }) process.exit() } diff --git a/packages/cli/test/cli/fixture/stdout.ts b/packages/cli/test/cli/fixture/stdout.ts index 1a79e14..0e6e0d9 100644 --- a/packages/cli/test/cli/fixture/stdout.ts +++ b/packages/cli/test/cli/fixture/stdout.ts @@ -5,10 +5,15 @@ if (process.argv.includes("--error")) { Stdout.write("") await Stdout.flush() process.stdout.emit("error", Object.assign(new Error("broken stdout"), { code: "EIO" })) - await Stdout.flush().then( - () => process.exit(2), - (error) => process.exit(error instanceof Error && error.message === "broken stdout" ? 0 : 3), + const write = await Stdout.write("").then( + () => false, + (error) => error instanceof Error && error.message === "broken stdout", ) + const flush = await Stdout.flush().then( + () => false, + (error) => error instanceof Error && error.message === "broken stdout", + ) + process.exit(write && flush ? 0 : 2) } const records = Array.from({ length: 1024 }, (_, index) => @@ -24,5 +29,9 @@ Array.from({ length: process.argv.includes("--epipe") ? 8 : 1 }).forEach(() => ) Stdout.write(JSON.stringify({ type: "complete" }) + "\n") await Stdout.flush() -if (process.argv.includes("--epipe")) process.exit(Stdout.closed() ? 0 : 2) +if (process.argv.includes("--epipe")) { + const status = process.argv.find((arg) => arg.startsWith("--status="))?.slice("--status=".length) + if (status) await Bun.write(status, Stdout.isClosed() ? "closed" : "open") + process.exit(Stdout.isClosed() ? 0 : 2) +} process.exit() diff --git a/packages/cli/test/cli/stdout.test.ts b/packages/cli/test/cli/stdout.test.ts index 9eb625a..335d8c5 100644 --- a/packages/cli/test/cli/stdout.test.ts +++ b/packages/cli/test/cli/stdout.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" +import { $ } from "bun" import path from "path" +import { tmpdir } from "../fixture/fixture" describe("stdout", () => { test("flushes NDJSON beyond pipe capacity before forced exit", async () => { @@ -23,51 +25,50 @@ describe("stdout", () => { }) test("treats a closed output pipe as controlled", async () => { - const child = Bun.spawn( - [ - "bash", - "-c", - 'set -o pipefail; "$1" "$2" --epipe | head -c 1 >/dev/null', - "--", - process.execPath, - path.join(import.meta.dir, "fixture", "stdout.ts"), - ], - { - cwd: path.join(import.meta.dir, "../.."), - stdout: "ignore", - stderr: "pipe", - }, - ) + await using tmp = await tmpdir() + const status = path.join(tmp.path, "status") + const child = await $`${process.execPath} ${path.join( + import.meta.dir, + "fixture", + "stdout.ts", + )} --epipe --status=${status} | ${process.execPath} -e ${"await Bun.stdin.stream().getReader().read(); process.exit()"}` + .cwd(path.join(import.meta.dir, "../..")) + .quiet() + .nothrow() - expect(await child.exited).toBe(0) - expect(await new Response(child.stderr).text()).toBe("") + expect(child.exitCode).toBe(0) + expect(child.stderr.toString()).toBe("") + expect(await Bun.file(status).text()).toBe("closed") }) test("does not bind an error handler until output is written", async () => { - const child = Bun.spawn( - [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--idle"], - { - cwd: path.join(import.meta.dir, "../.."), - stdout: "pipe", - stderr: "pipe", - }, - ) + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--idle"], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) expect(await child.exited).toBe(0) expect(await new Response(child.stderr).text()).toBe("") }) - test("surfaces unexpected output errors from flush", async () => { - const child = Bun.spawn( - [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--error"], - { - cwd: path.join(import.meta.dir, "../.."), - stdout: "pipe", - stderr: "pipe", - }, - ) + test("surfaces unexpected output errors from write and flush", async () => { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--error"], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) expect(await child.exited).toBe(0) expect(await new Response(child.stderr).text()).toBe("") }) + + test("entrypoints handle flush failures before forced exit", async () => { + for (const entry of ["index.ts", "headless.ts"]) { + const source = await Bun.file(path.join(import.meta.dir, "../../src", entry)).text() + const flush = source.lastIndexOf("await Stdout.flush().catch") + expect(flush).toBeGreaterThan(-1) + expect(source.indexOf("process.exit()", flush)).toBeGreaterThan(flush) + } + }) }) From 184091ddbeb15c77d1f4cabf271098ce86183282 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 20 Jul 2026 08:19:40 +0100 Subject: [PATCH 4/4] refactor(cli): share safe stdout shutdown --- packages/cli/src/cli/shutdown.ts | 13 +++++++++++++ packages/cli/src/headless.ts | 9 ++------- packages/cli/src/index.ts | 9 ++------- packages/cli/test/cli/fixture/stdout.ts | 10 ++++++++++ packages/cli/test/cli/stdout.test.ts | 22 +++++++++++++++------- 5 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/cli/shutdown.ts diff --git a/packages/cli/src/cli/shutdown.ts b/packages/cli/src/cli/shutdown.ts new file mode 100644 index 0000000..bef93d3 --- /dev/null +++ b/packages/cli/src/cli/shutdown.ts @@ -0,0 +1,13 @@ +import { Log } from "../util/log" +import { Stdout } from "./stdout" + +export namespace Shutdown { + export async function flush() { + await Stdout.flush().catch((error) => { + Log.Default.error("stdout flush failed", { + error: error instanceof Error ? error.message : error, + }) + process.exitCode = 1 + }) + } +} diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index ce56cd3..f9140aa 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -19,7 +19,7 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" -import { Stdout } from "./cli/stdout" +import { Shutdown } from "./cli/shutdown" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -155,11 +155,6 @@ try { } process.exitCode = 1 } finally { - await Stdout.flush().catch((error) => { - Log.Default.error("stdout flush failed", { - error: error instanceof Error ? error.message : error, - }) - process.exitCode = 1 - }) + await Shutdown.flush() process.exit() } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4108d7f..e2d4b9c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -29,7 +29,7 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" -import { Stdout } from "./cli/stdout" +import { Shutdown } from "./cli/shutdown" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -213,11 +213,6 @@ try { // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. - await Stdout.flush().catch((error) => { - Log.Default.error("stdout flush failed", { - error: error instanceof Error ? error.message : error, - }) - process.exitCode = 1 - }) + await Shutdown.flush() process.exit() } diff --git a/packages/cli/test/cli/fixture/stdout.ts b/packages/cli/test/cli/fixture/stdout.ts index 0e6e0d9..9bc7760 100644 --- a/packages/cli/test/cli/fixture/stdout.ts +++ b/packages/cli/test/cli/fixture/stdout.ts @@ -1,6 +1,16 @@ import { Stdout } from "../../../src/cli/stdout" +import { Shutdown } from "../../../src/cli/shutdown" if (process.argv.includes("--idle")) process.exit(process.stdout.listenerCount("error")) +if (process.argv.includes("--shutdown-error")) { + Stdout.write("") + await Stdout.flush() + process.stdout.emit("error", Object.assign(new Error("broken stdout"), { code: "EIO" })) + await Shutdown.flush() + const status = process.argv.find((arg) => arg.startsWith("--status="))?.slice("--status=".length) + if (status) await Bun.write(status, String(process.exitCode)) + process.exit() +} if (process.argv.includes("--error")) { Stdout.write("") await Stdout.flush() diff --git a/packages/cli/test/cli/stdout.test.ts b/packages/cli/test/cli/stdout.test.ts index 335d8c5..afeffde 100644 --- a/packages/cli/test/cli/stdout.test.ts +++ b/packages/cli/test/cli/stdout.test.ts @@ -63,12 +63,20 @@ describe("stdout", () => { expect(await new Response(child.stderr).text()).toBe("") }) - test("entrypoints handle flush failures before forced exit", async () => { - for (const entry of ["index.ts", "headless.ts"]) { - const source = await Bun.file(path.join(import.meta.dir, "../../src", entry)).text() - const flush = source.lastIndexOf("await Stdout.flush().catch") - expect(flush).toBeGreaterThan(-1) - expect(source.indexOf("process.exit()", flush)).toBeGreaterThan(flush) - } + test("flush failure sets exit status without blocking forced exit", async () => { + await using tmp = await tmpdir() + const status = path.join(tmp.path, "status") + const child = Bun.spawn( + [process.execPath, path.join(import.meta.dir, "fixture", "stdout.ts"), "--shutdown-error", `--status=${status}`], + { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }, + ) + + expect(await child.exited).toBe(1) + expect(await Bun.file(status).text()).toBe("1") + expect(await new Response(child.stderr).text()).toBe("") }) })