From 570038ac3c4ae02378e5de38793d3014587a33e1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 3 Mar 2026 19:24:00 -0500 Subject: [PATCH] fix(tui): ensure thread worker cleanup on exit --- packages/opencode/src/cli/cmd/tui/thread.ts | 150 +++++++++++--------- 1 file changed, 85 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 750347d9d6..1d8821110b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -5,8 +5,8 @@ import { type rpc } from "./worker" import path from "path" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" -import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { Filesystem } from "@/util/filesystem" import type { Event } from "@opencode-ai/sdk/v2" @@ -45,6 +45,20 @@ function createEventSource(client: RpcClient): EventSource { } } +async function workerURL() { + if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH + const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url) + if (await Filesystem.exists(fileURLToPath(dist))) return dist + return new URL("./worker.ts", import.meta.url) +} + +async function promptText(value?: string) { + const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + if (!value) return piped + if (!piped) return value + return piped + "\n" + value +} + export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", @@ -99,16 +113,10 @@ export const TuiThreadCommand = cmd({ // Resolve relative paths against PWD to preserve behavior when using --cwd flag const baseCwd = process.env.PWD ?? process.cwd() const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() - const localWorker = new URL("./worker.ts", import.meta.url) - const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url) - const workerPath = await iife(async () => { - if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH - if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker - return localWorker - }) + const workerPath = await workerURL() try { process.chdir(cwd) - } catch (e) { + } catch { UI.error("Failed to change directory to " + cwd) return } @@ -121,76 +129,88 @@ export const TuiThreadCommand = cmd({ worker.onerror = (e) => { Log.Default.error(e) } - const client = Rpc.client(worker) - process.on("uncaughtException", (e) => { - Log.Default.error(e) - }) - process.on("unhandledRejection", (e) => { - Log.Default.error(e) - }) - process.on("SIGUSR2", async () => { - await client.call("reload", undefined) - }) - const prompt = await iife(async () => { - const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined - if (!args.prompt) return piped - return piped ? piped + "\n" + args.prompt : args.prompt - }) + const client = Rpc.client(worker) + const onError = (e: unknown) => { + Log.Default.error(e) + } + const onReload = () => { + client.call("reload", undefined).catch((error) => { + Log.Default.warn("worker reload failed", { + error: error instanceof Error ? error.message : String(error), + }) + }) + } + process.on("uncaughtException", onError) + process.on("unhandledRejection", onError) + process.on("SIGUSR2", onReload) + + let stopped = false + const stop = async () => { + if (stopped) return + stopped = true + process.off("uncaughtException", onError) + process.off("unhandledRejection", onError) + process.off("SIGUSR2", onReload) + await withTimeout(client.call("shutdown", undefined), 6000).catch((error) => { + Log.Default.warn("worker shutdown failed", { + error: error instanceof Error ? error.message : String(error), + }) + }) + worker.terminate() + } + + const prompt = await promptText(args.prompt) const config = await Instance.provide({ directory: cwd, fn: () => TuiConfig.get(), }) - // Check if server should be started (port or hostname explicitly set in CLI or config) - const networkOpts = await resolveNetworkOptions(args) - const shouldStartServer = + const network = await resolveNetworkOptions(args) + const external = process.argv.includes("--port") || process.argv.includes("--hostname") || process.argv.includes("--mdns") || - networkOpts.mdns || - networkOpts.port !== 0 || - networkOpts.hostname !== "127.0.0.1" + network.mdns || + network.port !== 0 || + network.hostname !== "127.0.0.1" - let url: string - let customFetch: typeof fetch | undefined - let events: EventSource | undefined - - if (shouldStartServer) { - // Start HTTP server for external access - const server = await client.call("server", networkOpts) - url = server.url - } else { - // Use direct RPC communication (no HTTP) - url = "http://opencode.internal" - customFetch = createWorkerFetch(client) - events = createEventSource(client) - } - - const tuiPromise = tui({ - url, - config, - directory: cwd, - fetch: customFetch, - events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, - }, - onExit: async () => { - await client.call("shutdown", undefined) - }, - }) + const transport = external + ? { + url: (await client.call("server", network)).url, + fetch: undefined, + events: undefined, + } + : { + url: "http://opencode.internal", + fetch: createWorkerFetch(client), + events: createEventSource(client), + } setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) - }, 1000) + }, 1000).unref?.() - await tuiPromise + try { + await tui({ + url: transport.url, + config, + directory: cwd, + fetch: transport.fetch, + events: transport.events, + args: { + continue: args.continue, + sessionID: args.session, + agent: args.agent, + model: args.model, + prompt, + fork: args.fork, + }, + onExit: stop, + }) + } finally { + await stop() + } } finally { unguard?.() }