Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,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)
Expand Down Expand Up @@ -236,6 +244,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)
},
})
8 changes: 7 additions & 1 deletion packages/opencode/src/cli/cmd/tui/win32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,7 +118,6 @@ export function win32InstallCtrlCGuard() {
const interval = setInterval(enforce, 100)
interval.unref()

let done = false
unhook = () => {
if (done) return
done = true
Expand Down
54 changes: 54 additions & 0 deletions packages/opencode/test/cli/tui/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof spyOn>).mockImplementationOnce(async () => {})

const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[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
}
})
})
Loading