diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 1bae71cfb9..4e0c64d3fa 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -63,7 +63,8 @@ export namespace FileWatcher { return { sub } }, async (state) => { - state.sub?.unsubscribe() + if (!state.sub) return + Instance.trackPromises([state.sub.unsubscribe()]) }, ) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 342034eedf..b5df5c8408 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -20,8 +20,6 @@ import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { AttachCommand } from "./cli/cmd/attach" -const cancel = new AbortController() - process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, @@ -129,6 +127,10 @@ try { if (formatted) UI.error(formatted) if (formatted === undefined) UI.error("Unexpected error, check log file at " + Log.file() + " for more details") process.exitCode = 1 +} finally { + // 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`. + // Explicitly exit to avoid any hanging subprocesses. + process.exit(); } - -cancel.abort() diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index d811b23780..66cb6791b6 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -101,9 +101,7 @@ export namespace LSP { } }, async (state) => { - for (const client of state.clients) { - await client.shutdown() - } + Instance.trackPromises(state.clients.map(client => client.shutdown())) }, ) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index dc5bb8b863..4cd4dee5ad 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -145,9 +145,7 @@ export namespace MCP { } }, async (state) => { - for (const client of Object.values(state.clients)) { - client.close() - } + Instance.trackPromises(Object.values(state.clients).map(client => client.close())) }, ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index c7805aa7a4..45e85fd240 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,7 +11,7 @@ export async function InstanceBootstrap() { await Plugin.init() Share.init() Format.init() - LSP.init() + await LSP.init() FileWatcher.init() File.init() } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 01ea87a3ca..3bb9051b5b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -41,6 +41,12 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + /** + * Track promises, making sure they have settled before Instance disposal is allowed to complete. + */ + trackPromises(promises: Promise[]) { + State.trackPromises(Instance.directory, promises) + }, async dispose() { await State.dispose(Instance.directory) }, diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 2ffef3b397..d16cbc4b16 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -1,23 +1,28 @@ +import { Log } from "@/util/log" + export namespace State { interface Entry { state: any dispose?: (state: any) => Promise } - const entries = new Map>() + const recordsByKey = new Map>, entries: Map }>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { return () => { const key = root() - let collection = entries.get(key) - if (!collection) { - collection = new Map() - entries.set(key, collection) + let record = recordsByKey.get(key) + if (!record) { + record = { + entries: new Map(), + trackedPromises: new Set>(), + } + recordsByKey.set(key, record) } - const exists = collection.get(init) + const exists = record.entries.get(init) if (exists) return exists.state as S const state = init() - collection.set(init, { + record.entries.set(init, { state, dispose, }) @@ -26,9 +31,51 @@ export namespace State { } export async function dispose(key: string) { - for (const [_, entry] of entries.get(key)?.entries() ?? []) { - if (!entry.dispose) continue - await entry.dispose(await entry.state) - } + const record = recordsByKey.get(key) + if (!record) return + + let disposalFinished = false + + setTimeout(() => { + if (!disposalFinished) { + Log.Default.warn("waiting for state disposal to complete... (this is usually a saving operation or subprocess shutdown)") + } + }, 1000).unref() + + setTimeout(() => { + if (!disposalFinished) { + Log.Default.warn("state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug") + } + }, 10000).unref() + + await Promise.allSettled( + [...record.entries.values()] + .map(async (entry) => await entry.dispose?.(await entry.state)), + ).then((results) => { + for (const result of results) { + if (result.status === "rejected") { + Log.Default.error("Error while disposing state:", result.reason) + } + } + }) + + await Promise.allSettled([...record.trackedPromises.values()]) + .then((results) => { + for (const result of results) { + if (result.status === "rejected") { + Log.Default.error("Error while disposing state:", result.reason) + } + } + }) + + disposalFinished = true + } + + /** + * Track promises, making sure they have settled before state disposal is allowed to complete. + */ + export function trackPromises(key: string, promises: Promise[]) { + for (const promise of promises) + recordsByKey.get(key)!.trackedPromises.add(promise) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 234f47b69d..b70d074dc7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -216,13 +216,15 @@ export namespace SessionPrompt { (messages) => insertReminders({ messages, agent }), ) if (step === 0) - ensureTitle({ - session, - history: msgs, - message: userMsg, - providerID: model.providerID, - modelID: model.info.id, - }) + Instance.trackPromises([ + ensureTitle({ + session, + history: msgs, + message: userMsg, + providerID: model.providerID, + modelID: model.info.id, + }) + ]) step++ await processor.next() await using _ = defer(async () => { @@ -1635,7 +1637,7 @@ export namespace SessionPrompt { thinkingBudget: 0, } } - generateText({ + await generateText({ maxOutputTokens: small.info.reasoning ? 1500 : 20, providerOptions: { [small.providerID]: options,