From 2e7aafd504de7f4ab847181061aede5d2330f92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=87=91=E5=8F=8C?= Date: Sat, 11 Apr 2026 14:24:24 +0800 Subject: [PATCH 1/2] fix(win32): prevent TUI exit from closing terminal window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, exiting the TUI (Ctrl+D) causes the terminal window to close instead of returning to the shell prompt. This happens because the worker's graceful shutdown kills MCP server subprocesses, which detaches the main process from its console (GetConsoleWindow → 0x0). Two fixes: 1. **thread.ts**: On Windows, fire-and-forget the worker shutdown signal instead of awaiting it. Neither `worker.terminate()` nor awaiting the graceful shutdown is safe — both destroy the console window. Let `process.exit(0)` tear down all subprocesses instead. 2. **win32.ts**: Fix a race condition where a pending `setImmediate(enforce)` callback could re-clear `ENABLE_PROCESSED_INPUT` after `unguard()` had already restored the console mode. Move `done` flag before `enforce()` and check it on entry. --- packages/opencode/src/cli/cmd/tui/thread.ts | 18 +++++-- packages/opencode/src/cli/cmd/tui/win32.ts | 8 ++- packages/opencode/test/cli/tui/thread.test.ts | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 972e67d103fa..1b966ad91d78 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -168,12 +168,20 @@ export const TuiThreadCommand = cmd({ process.off("uncaughtException", error) process.off("unhandledRejection", error) process.off("SIGUSR2", reload) - await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { - Log.Default.warn("worker shutdown failed", { - error: errorMessage(error), + if (process.platform === "win32") { + // On Windows, both worker.terminate() and awaiting the worker's + // graceful shutdown destroy the console window — the MCP subprocess + // cleanup detaches the process from its console. Fire-and-forget + // the shutdown signal and let process.exit() tear everything down. + client.call("shutdown", undefined).catch(() => {}) + } else { + await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { + Log.Default.warn("worker shutdown failed", { + error: errorMessage(error), + }) }) - }) - worker.terminate() + worker.terminate() + } } const prompt = await input(args.prompt) diff --git a/packages/opencode/src/cli/cmd/tui/win32.ts b/packages/opencode/src/cli/cmd/tui/win32.ts index 23e9f448574f..5a41943e6136 100644 --- a/packages/opencode/src/cli/cmd/tui/win32.ts +++ b/packages/opencode/src/cli/cmd/tui/win32.ts @@ -80,7 +80,14 @@ export function win32InstallCtrlCGuard() { if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return const initial = buf[0]! + // Moved before enforce() so the closure can check the guard state. + let done = false + const enforce = () => { + // After unhook(), stop touching the console mode — a pending + // setImmediate(enforce) could otherwise re-clear + // ENABLE_PROCESSED_INPUT after the mode was already restored. + if (done) return if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return const mode = buf[0]! if ((mode & ENABLE_PROCESSED_INPUT) === 0) return @@ -111,7 +118,6 @@ export function win32InstallCtrlCGuard() { const interval = setInterval(enforce, 100) interval.unref() - let done = false unhook = () => { if (done) return done = true diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 176c2575a308..4a8542dcbc73 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -125,4 +125,58 @@ describe("tui thread", () => { test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) + + test("does not force process.exit after tui exits cleanly", async () => { + const exit = spyOn(process, "exit").mockImplementation((() => undefined) as typeof process.exit) + setup() + ;(App.tui as ReturnType).mockImplementationOnce(async () => {}) + + const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") + const args: Parameters>[0] = { + _: [], + $0: "opencode", + project: undefined, + prompt: "hi", + model: undefined, + agent: undefined, + session: undefined, + continue: false, + fork: false, + port: 0, + hostname: "127.0.0.1", + mdns: false, + "mdns-domain": "opencode.local", + mdnsDomain: "opencode.local", + cors: [], + } + const worker = globalThis.Worker + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + globalThis.Worker = class extends EventTarget { + onerror = null + onmessage = null + onmessageerror = null + postMessage() {} + terminate() {} + } as unknown as typeof Worker + + try { + await TuiThreadCommand.handler(args) + if (process.platform === "win32") { + // On Windows, process.exit(0) is required because awaiting the + // worker shutdown destroys the console window. + expect(exit).toHaveBeenCalledWith(0) + } else { + expect(exit).not.toHaveBeenCalled() + } + } finally { + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + globalThis.Worker = worker + } + }) }) From 5940e66f38447206699fc0bff7ff7e2c4b403d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=87=91=E5=8F=8C?= Date: Sat, 11 Apr 2026 14:44:54 +0800 Subject: [PATCH 2/2] fix: make process.exit(0) Windows-only in TUI handler The unconditional process.exit(0) at the end of the TUI handler causes the Linux CI test to fail. On non-Windows platforms, the index.ts finally{} safety-net handles process exit. Only Windows needs the explicit exit because the worker is not terminated there. --- packages/opencode/src/cli/cmd/tui/thread.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 1b966ad91d78..5130798e36ea 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -242,6 +242,10 @@ export const TuiThreadCommand = cmd({ } finally { unguard?.() } - process.exit(0) + // On Windows we cannot await the worker shutdown or call + // worker.terminate() — both destroy the console window. The worker + // is still alive so the event loop won't drain; force-exit here. + // On other platforms the index.ts finally{} safety-net handles exit. + if (process.platform === "win32") process.exit(0) }, })