Skip to content

Commit 2e7aafd

Browse files
author
金双
committed
fix(win32): prevent TUI exit from closing terminal window
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.
1 parent a915fe7 commit 2e7aafd

3 files changed

Lines changed: 74 additions & 6 deletions

File tree

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,20 @@ export const TuiThreadCommand = cmd({
168168
process.off("uncaughtException", error)
169169
process.off("unhandledRejection", error)
170170
process.off("SIGUSR2", reload)
171-
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
172-
Log.Default.warn("worker shutdown failed", {
173-
error: errorMessage(error),
171+
if (process.platform === "win32") {
172+
// On Windows, both worker.terminate() and awaiting the worker's
173+
// graceful shutdown destroy the console window — the MCP subprocess
174+
// cleanup detaches the process from its console. Fire-and-forget
175+
// the shutdown signal and let process.exit() tear everything down.
176+
client.call("shutdown", undefined).catch(() => {})
177+
} else {
178+
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
179+
Log.Default.warn("worker shutdown failed", {
180+
error: errorMessage(error),
181+
})
174182
})
175-
})
176-
worker.terminate()
183+
worker.terminate()
184+
}
177185
}
178186

179187
const prompt = await input(args.prompt)

packages/opencode/src/cli/cmd/tui/win32.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ export function win32InstallCtrlCGuard() {
8080
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
8181
const initial = buf[0]!
8282

83+
// Moved before enforce() so the closure can check the guard state.
84+
let done = false
85+
8386
const enforce = () => {
87+
// After unhook(), stop touching the console mode — a pending
88+
// setImmediate(enforce) could otherwise re-clear
89+
// ENABLE_PROCESSED_INPUT after the mode was already restored.
90+
if (done) return
8491
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
8592
const mode = buf[0]!
8693
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
@@ -111,7 +118,6 @@ export function win32InstallCtrlCGuard() {
111118
const interval = setInterval(enforce, 100)
112119
interval.unref()
113120

114-
let done = false
115121
unhook = () => {
116122
if (done) return
117123
done = true

packages/opencode/test/cli/tui/thread.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,58 @@ describe("tui thread", () => {
125125
test("uses the real cwd after resolving a relative project from PWD", async () => {
126126
await check(".")
127127
})
128+
129+
test("does not force process.exit after tui exits cleanly", async () => {
130+
const exit = spyOn(process, "exit").mockImplementation((() => undefined) as typeof process.exit)
131+
setup()
132+
;(App.tui as ReturnType<typeof spyOn>).mockImplementationOnce(async () => {})
133+
134+
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
135+
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
136+
_: [],
137+
$0: "opencode",
138+
project: undefined,
139+
prompt: "hi",
140+
model: undefined,
141+
agent: undefined,
142+
session: undefined,
143+
continue: false,
144+
fork: false,
145+
port: 0,
146+
hostname: "127.0.0.1",
147+
mdns: false,
148+
"mdns-domain": "opencode.local",
149+
mdnsDomain: "opencode.local",
150+
cors: [],
151+
}
152+
const worker = globalThis.Worker
153+
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
154+
155+
Object.defineProperty(process.stdin, "isTTY", {
156+
configurable: true,
157+
value: true,
158+
})
159+
globalThis.Worker = class extends EventTarget {
160+
onerror = null
161+
onmessage = null
162+
onmessageerror = null
163+
postMessage() {}
164+
terminate() {}
165+
} as unknown as typeof Worker
166+
167+
try {
168+
await TuiThreadCommand.handler(args)
169+
if (process.platform === "win32") {
170+
// On Windows, process.exit(0) is required because awaiting the
171+
// worker shutdown destroys the console window.
172+
expect(exit).toHaveBeenCalledWith(0)
173+
} else {
174+
expect(exit).not.toHaveBeenCalled()
175+
}
176+
} finally {
177+
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
178+
else delete (process.stdin as { isTTY?: boolean }).isTTY
179+
globalThis.Worker = worker
180+
}
181+
})
128182
})

0 commit comments

Comments
 (0)