diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index b3d1db7eb0cb..b2ebb9a3a708 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -31,6 +31,7 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import path from "path" +import { Instance } from "./project/instance" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" @@ -165,6 +166,19 @@ cli = cli }) .strict() +// Dispose instances on termination to prevent orphan MCP/LSP processes +let cleanupDone = false +async function cleanup(signal: string) { + if (cleanupDone) return + cleanupDone = true + Log.Default.info("cleanup triggered", { signal }) + try { + await Instance.disposeAll() + } catch {} +} +process.on("SIGINT", () => cleanup("SIGINT").then(() => process.exit(130))) +process.on("SIGTERM", () => cleanup("SIGTERM").then(() => process.exit(143))) + try { await cli.parse() } catch (e) { @@ -205,6 +219,7 @@ try { } process.exitCode = 1 } finally { + await cleanup("exit") // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index bf5a0d3ce7fa..ec1fa3d641ae 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -152,6 +152,28 @@ export namespace MCP { type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() + // Helper function to close a transport properly + async function closeTransport(transport: TransportWithAuth | undefined): Promise { + if (!transport) return + try { + // The transport should have a close method or similar cleanup + if (typeof (transport as any).close === "function") { + await (transport as any).close() + } + } catch (error) { + log.error("Failed to close transport", { error }) + } + } + + // Helper function to set a transport while properly cleaning up the old one + async function setPendingOAuthTransport(key: string, transport: TransportWithAuth): Promise { + const existing = pendingOAuthTransports.get(key) + if (existing) { + await closeTransport(existing) + } + pendingOAuthTransports.set(key, transport) + } + // Prompt cache types type PromptInfo = Awaited>["prompts"][number] @@ -418,7 +440,7 @@ export namespace MCP { }).catch((e) => log.debug("failed to show toast", { error: e })) } else { // Store transport for later finishAuth call - pendingOAuthTransports.set(key, transport) + await setPendingOAuthTransport(key, transport) status = { status: "needs_auth" as const } // Show toast for needs_auth Bus.publish(TuiEvent.ToastShow, { @@ -812,7 +834,7 @@ export namespace MCP { } catch (error) { if (error instanceof UnauthorizedError && capturedUrl) { // Store transport for finishAuth - pendingOAuthTransports.set(mcpName, transport) + await setPendingOAuthTransport(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } throw error @@ -939,6 +961,9 @@ export namespace MCP { export async function removeAuth(mcpName: string): Promise { await McpAuth.remove(mcpName) McpOAuthCallback.cancelPending(mcpName) + // Properly close the transport before removing it + const transport = pendingOAuthTransports.get(mcpName) + await closeTransport(transport) pendingOAuthTransports.delete(mcpName) await McpAuth.clearOAuthState(mcpName) log.info("removed oauth credentials", { mcpName })