Merge PR #6920: fix(tui): prevent Windows pane-close deadlock (@roboomp)

This commit is contained in:
can1357
2026-07-29 23:08:31 +02:00
5 changed files with 36 additions and 7 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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 {
@@ -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();
+3
View File
@@ -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
+11 -5
View File
@@ -319,14 +319,20 @@ export function cleanup(): Promise<void> {
return runCleanup(Reason.MANUAL);
}
async function runQuit(code: number, exitMode: "guarded" | "native"): Promise<void> {
/** 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<void> {
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<void>();
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<vo
/**
* Runs all cleanup callbacks and exits through the current `process.exit`.
*
* In main thread: waits for stdout drain, then calls `process.exit()`.
* In main thread: waits for stdout drain unless disabled, then calls `process.exit()`.
* In workers: runs cleanup only (process.exit would kill entire process).
*/
export function quit(code: number = 0): Promise<void> {
return runQuit(code, "guarded");
export function quit(code: number = 0, options: QuitOptions = {}): Promise<void> {
return runQuit(code, "guarded", options);
}