diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index baaa53a4f..4ef7d31d1 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 3e0b6dd77..0973739ac 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1587,7 +1587,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 8c1d5608c..52aa7cfa0 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - Replaced the central logger's Winston dispatch with a byte-compatible local dispatcher while retaining the existing `winston-daily-rotate-file` rotation and retention behavior. +### Added + +- Added a `postmortem.quit` option for shutdown paths where terminal output has already disconnected and cannot drain. ## [17.1.8] - 2026-07-28 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); }