diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a8d45c1af..fd82f4b1f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed native Windows terminal panes freezing their host during forced closure by skipping the impossible stdout-drain wait after ConPTY disconnects ([#6917](https://github.com/can1357/oh-my-pi/issues/6917)). + ## [17.1.8] - 2026-07-28 ### Fixed diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index c250aab1f..62d669917 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1512,7 +1512,7 @@ export class ProcessTerminal implements Terminal { } if (process.platform === "win32") { - void postmortem.quit(129); + void postmortem.quit(129, { drainStdout: false }); return; } try { diff --git a/packages/tui/test/process-terminal-render.test.ts b/packages/tui/test/process-terminal-render.test.ts index 87ad0e48e..dd8c3af25 100644 --- a/packages/tui/test/process-terminal-render.test.ts +++ b/packages/tui/test/process-terminal-render.test.ts @@ -1,9 +1,12 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { postmortem } from "@oh-my-pi/pi-utils"; import { createProcessTerminalRenderHarness, type ProcessTerminalRenderHarness, } from "./process-terminal-render-harness"; +const PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, "platform"); + // Geometry-reflow contract for the *real* terminal driven through the *real* // renderer. These exercise the seam VirtualTerminal cannot model: the OS channel // (SIGWINCH) and the DEC 2048 in-band channel disagreeing. The observable @@ -14,6 +17,8 @@ describe("ProcessTerminal geometry reflow through the renderer", () => { afterEach(() => { harness?.dispose(); harness = undefined; + if (PLATFORM_DESCRIPTOR) Object.defineProperty(process, "platform", PLATFORM_DESCRIPTOR); + vi.restoreAllMocks(); }); it("reflows to the OS width on resize when in-band resize is inactive", async () => { @@ -102,6 +107,17 @@ describe("ProcessTerminal geometry reflow through the renderer", () => { expect(harness.signals.at(-1)).toEqual({ pid: process.pid, signal: "SIGHUP" }); }); + it("does not wait for terminal output to drain after input ends on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + const quit = vi.spyOn(postmortem, "quit").mockResolvedValue(undefined); + harness = createProcessTerminalRenderHarness(100, 30); + + await harness.endInput(); + + expect(quit).toHaveBeenCalledWith(129, { drainStdout: false }); + expect(harness.signals).toHaveLength(0); + }); + it("stops rendering and raises SIGHUP when terminal output fails", async () => { harness = createProcessTerminalRenderHarness(100, 30); await harness.settle(); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index f70371acf..3f78b2c08 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a `postmortem.quit` option for shutdown paths where terminal output has already disconnected and cannot drain. + ## [17.1.8] - 2026-07-28 ### Added diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 85b89482d..3a1463b6a 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -319,14 +319,20 @@ export function cleanup(): Promise { return runCleanup(Reason.MANUAL); } -async function runQuit(code: number, exitMode: "guarded" | "native"): Promise { +/** Controls how manual process shutdown handles terminal output. */ +export interface QuitOptions { + /** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */ + drainStdout?: boolean; +} + +async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise { await runCleanup(Reason.MANUAL); if (!isMainThread) { return; // Workers: cleanup done, let worker exit naturally } - if (process.stdout.writableLength > 0) { + if (options.drainStdout !== false && process.stdout.writableLength > 0) { const { promise, resolve } = Promise.withResolvers(); process.stdout.once("drain", resolve); await Promise.race([promise, Bun.sleep(5000)]); @@ -343,9 +349,9 @@ async function runQuit(code: number, exitMode: "guarded" | "native"): Promise { - return runQuit(code, "guarded"); +export function quit(code: number = 0, options: QuitOptions = {}): Promise { + return runQuit(code, "guarded", options); }