summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorHaris Gušić <[email protected]>2025-10-31 05:57:58 +0100
committerGitHub <[email protected]>2025-10-30 23:57:58 -0500
commitc1ada302f983f150ec42c473d316b3488318163b (patch)
treed1807ba8f0ae8b75c11bd98c8f42ca8c384b4ba7
parent51e4c9fc4cb4ef01f7d337127b463729c2967594 (diff)
downloadopencode-c1ada302f983f150ec42c473d316b3488318163b.tar.gz
opencode-c1ada302f983f150ec42c473d316b3488318163b.zip
fix: Opencode hangs after exit (#3481)
Co-authored-by: Aiden Cline <[email protected]>
-rw-r--r--packages/opencode/src/acp/session.ts1
-rw-r--r--packages/opencode/src/cli/bootstrap.ts9
-rw-r--r--packages/opencode/src/cli/cmd/acp.ts6
-rw-r--r--packages/opencode/src/cli/cmd/serve.ts2
-rw-r--r--packages/opencode/src/config/config.ts4
-rw-r--r--packages/opencode/src/file/watcher.ts3
-rw-r--r--packages/opencode/src/index.ts10
-rw-r--r--packages/opencode/src/lsp/index.ts4
-rw-r--r--packages/opencode/src/mcp/index.ts4
-rw-r--r--packages/opencode/src/project/bootstrap.ts2
-rw-r--r--packages/opencode/src/project/state.ts50
-rw-r--r--packages/opencode/src/session/prompt.ts27
12 files changed, 86 insertions, 36 deletions
diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts
index 652e8cfdd..5d45ee283 100644
--- a/packages/opencode/src/acp/session.ts
+++ b/packages/opencode/src/acp/session.ts
@@ -1,5 +1,4 @@
import type { McpServer } from "@agentclientprotocol/sdk"
-import { Identifier } from "../id/id"
import { Session } from "../session"
import { Provider } from "../provider/provider"
import type { ACPSessionState } from "./types"
diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts
index 2114cbc56..984d5723d 100644
--- a/packages/opencode/src/cli/bootstrap.ts
+++ b/packages/opencode/src/cli/bootstrap.ts
@@ -6,9 +6,12 @@ export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
directory,
init: InstanceBootstrap,
fn: async () => {
- const result = await cb()
- await Instance.dispose()
- return result
+ try {
+ const result = await cb()
+ return result
+ } finally {
+ await Instance.dispose()
+ }
},
})
}
diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts
index 4f119d012..de461e170 100644
--- a/packages/opencode/src/cli/cmd/acp.ts
+++ b/packages/opencode/src/cli/cmd/acp.ts
@@ -56,7 +56,11 @@ export const AcpCommand = cmd({
}, stream)
log.info("setup connection")
+ process.stdin.resume()
+ await new Promise((resolve, reject) => {
+ process.stdin.on("end", resolve)
+ process.stdin.on("error", reject)
+ })
})
- process.stdin.resume()
},
})
diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts
index 850dbc83d..100b6a019 100644
--- a/packages/opencode/src/cli/cmd/serve.ts
+++ b/packages/opencode/src/cli/cmd/serve.ts
@@ -27,6 +27,6 @@ export const ServeCommand = cmd({
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
- server.stop()
+ await server.stop()
},
})
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 031c4d82a..bc7b8119b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -77,14 +77,16 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
+ const promises: Promise<void>[] = []
for (const dir of directories) {
await assertValid(dir)
- installDependencies(dir)
+ promises.push(installDependencies(dir))
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
}
+ await Promise.all(promises)
// Migrate deprecated mode field to agent field
for (const [name, mode] of Object.entries(result.mode)) {
diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts
index 7d190c60b..d5985b582 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
+ await state.sub?.unsubscribe()
},
)
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index a0cce76ad..45ccd3cad 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -22,8 +22,6 @@ import { AttachCommand } from "./cli/cmd/attach"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
-const cancel = new AbortController()
-
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
@@ -135,6 +133,10 @@ try {
console.error(e)
}
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 71e3b62f2..cccc8e774 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()
- }
+ await Promise.all(state.clients.map((client) => client.shutdown()))
},
)
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 0df6a5a72..d492a9365 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -45,9 +45,7 @@ export namespace MCP {
}
},
async (state) => {
- for (const client of Object.values(state.clients)) {
- client.close()
- }
+ await Promise.all(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 c7805aa7a..45e85fd24 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/state.ts b/packages/opencode/src/project/state.ts
index 2ffef3b39..6377833eb 100644
--- a/packages/opencode/src/project/state.ts
+++ b/packages/opencode/src/project/state.ts
@@ -1,23 +1,26 @@
+import { Log } from "@/util/log"
+
export namespace State {
interface Entry {
state: any
dispose?: (state: any) => Promise<void>
}
- const entries = new Map<string, Map<any, Entry>>()
+ const log = Log.create({ service: "state" })
+ const recordsByKey = new Map<string, Map<any, Entry>>()
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return () => {
const key = root()
- let collection = entries.get(key)
- if (!collection) {
- collection = new Map<string, Entry>()
- entries.set(key, collection)
+ let entries = recordsByKey.get(key)
+ if (!entries) {
+ entries = new Map<string, Entry>()
+ recordsByKey.set(key, entries)
}
- const exists = collection.get(init)
+ const exists = entries.get(init)
if (exists) return exists.state as S
const state = init()
- collection.set(init, {
+ entries.set(init, {
state,
dispose,
})
@@ -26,9 +29,38 @@ export namespace State {
}
export async function dispose(key: string) {
- for (const [_, entry] of entries.get(key)?.entries() ?? []) {
+ const entries = recordsByKey.get(key)
+ if (!entries) return
+
+ log.info("waiting for state disposal to complete", { key })
+
+ let disposalFinished = false
+
+ setTimeout(() => {
+ if (!disposalFinished) {
+ log.warn(
+ "state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug",
+ { key },
+ )
+ }
+ }, 10000).unref()
+
+ const tasks: Promise<void>[] = []
+ for (const entry of entries.values()) {
if (!entry.dispose) continue
- await entry.dispose(await entry.state)
+
+ const task = Promise.resolve(entry.state)
+ .then((state) => entry.dispose!(state))
+ .catch((error) => {
+ log.error("Error while disposing state:", { error, key })
+ })
+
+ tasks.push(task)
}
+
+ await Promise.all(tasks)
+
+ disposalFinished = true
+ log.info("state disposal completed", { key })
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index c6e0ad589..b9208f550 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -76,13 +76,22 @@ export namespace SessionPrompt {
callback: (input: MessageV2.WithParts) => void
}[]
>()
+ const pending = new Set<Promise<void>>()
+
+ const track = (promise: Promise<void>) => {
+ pending.add(promise)
+ promise.finally(() => pending.delete(promise))
+ }
return {
queued,
+ pending,
+ track,
}
},
async (current) => {
current.queued.clear()
+ await Promise.allSettled([...current.pending])
},
)
@@ -227,13 +236,15 @@ export namespace SessionPrompt {
step++
await processor.next(msgs.findLast((m) => m.info.role === "user")?.info.id!)
if (step === 1) {
- ensureTitle({
- session,
- history: msgs,
- message: userMsg,
- providerID: model.providerID,
- modelID: model.info.id,
- })
+ state().track(
+ ensureTitle({
+ session,
+ history: msgs,
+ message: userMsg,
+ providerID: model.providerID,
+ modelID: model.info.id,
+ }),
+ )
SessionSummary.summarize({
sessionID: input.sessionID,
messageID: userMsg.info.id,
@@ -1794,7 +1805,7 @@ export namespace SessionPrompt {
thinkingBudget: 0,
}
}
- generateText({
+ await generateText({
maxOutputTokens: small.info.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
messages: [