Skip to content

Commit 3ce7c7b

Browse files
thdxrpraxstack
authored andcommitted
fix(process): prevent orphaned opencode subprocesses on shutdown (anomalyco#15924)
1 parent 8c2bf72 commit 3ce7c7b

3 files changed

Lines changed: 98 additions & 72 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020

2121
Prefer single word names for variables and functions. Only use multiple words if necessary.
2222

23+
### Naming Enforcement (Read This)
24+
25+
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
26+
27+
- Use single word names by default for new locals, params, and helper functions.
28+
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
29+
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
30+
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
31+
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
32+
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
33+
2334
```ts
2435
// Good
2536
const foo = 1

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 86 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { type rpc } from "./worker"
55
import path from "path"
66
import { fileURLToPath } from "url"
77
import { UI } from "@/cli/ui"
8-
import { iife } from "@/util/iife"
98
import { Log } from "@/util/log"
9+
import { withTimeout } from "@/util/timeout"
1010
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
1111
import { Filesystem } from "@/util/filesystem"
1212
import type { Event } from "@opencode-ai/sdk/v2"
@@ -45,6 +45,20 @@ function createEventSource(client: RpcClient): EventSource {
4545
}
4646
}
4747

48+
async function target() {
49+
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
50+
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
51+
if (await Filesystem.exists(fileURLToPath(dist))) return dist
52+
return new URL("./worker.ts", import.meta.url)
53+
}
54+
55+
async function input(value?: string) {
56+
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
57+
if (!value) return piped
58+
if (!piped) return value
59+
return piped + "\n" + value
60+
}
61+
4862
export const TuiThreadCommand = cmd({
4963
command: "$0 [project]",
5064
describe: "start opencode tui",
@@ -97,100 +111,106 @@ export const TuiThreadCommand = cmd({
97111
}
98112

99113
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
100-
const baseCwd = process.env.PWD ?? process.cwd()
101-
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
102-
const localWorker = new URL("./worker.ts", import.meta.url)
103-
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
104-
const workerPath = await iife(async () => {
105-
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
106-
if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
107-
return localWorker
108-
})
114+
const root = process.env.PWD ?? process.cwd()
115+
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
116+
const file = await target()
109117
try {
110118
process.chdir(cwd)
111-
} catch (e) {
119+
} catch {
112120
UI.error("Failed to change directory to " + cwd)
113121
return
114122
}
115123

116-
const worker = new Worker(workerPath, {
124+
const worker = new Worker(file, {
117125
env: Object.fromEntries(
118126
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
119127
),
120128
})
121129
worker.onerror = (e) => {
122130
Log.Default.error(e)
123131
}
132+
124133
const client = Rpc.client<typeof rpc>(worker)
125-
process.on("uncaughtException", (e) => {
126-
Log.Default.error(e)
127-
})
128-
process.on("unhandledRejection", (e) => {
134+
const error = (e: unknown) => {
129135
Log.Default.error(e)
130-
})
131-
process.on("SIGUSR2", async () => {
132-
await client.call("reload", undefined)
133-
})
136+
}
137+
const reload = () => {
138+
client.call("reload", undefined).catch((err) => {
139+
Log.Default.warn("worker reload failed", {
140+
error: err instanceof Error ? err.message : String(err),
141+
})
142+
})
143+
}
144+
process.on("uncaughtException", error)
145+
process.on("unhandledRejection", error)
146+
process.on("SIGUSR2", reload)
134147

135-
const prompt = await iife(async () => {
136-
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
137-
if (!args.prompt) return piped
138-
return piped ? piped + "\n" + args.prompt : args.prompt
139-
})
148+
let stopped = false
149+
const stop = async () => {
150+
if (stopped) return
151+
stopped = true
152+
process.off("uncaughtException", error)
153+
process.off("unhandledRejection", error)
154+
process.off("SIGUSR2", reload)
155+
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
156+
Log.Default.warn("worker shutdown failed", {
157+
error: error instanceof Error ? error.message : String(error),
158+
})
159+
})
160+
worker.terminate()
161+
}
162+
163+
const prompt = await input(args.prompt)
140164
const config = await Instance.provide({
141165
directory: cwd,
142166
fn: () => TuiConfig.get(),
143167
})
144168

145-
// Check if server should be started (port or hostname explicitly set in CLI or config)
146-
const networkOpts = await resolveNetworkOptions(args)
147-
const shouldStartServer =
169+
const network = await resolveNetworkOptions(args)
170+
const external =
148171
process.argv.includes("--port") ||
149172
process.argv.includes("--hostname") ||
150173
process.argv.includes("--mdns") ||
151-
networkOpts.mdns ||
152-
networkOpts.port !== 0 ||
153-
networkOpts.hostname !== "127.0.0.1"
154-
155-
let url: string
156-
let customFetch: typeof fetch | undefined
157-
let events: EventSource | undefined
158-
159-
if (shouldStartServer) {
160-
// Start HTTP server for external access
161-
const server = await client.call("server", networkOpts)
162-
url = server.url
163-
} else {
164-
// Use direct RPC communication (no HTTP)
165-
url = "http://opencode.internal"
166-
customFetch = createWorkerFetch(client)
167-
events = createEventSource(client)
168-
}
174+
network.mdns ||
175+
network.port !== 0 ||
176+
network.hostname !== "127.0.0.1"
169177

170-
const tuiPromise = tui({
171-
url,
172-
config,
173-
directory: cwd,
174-
fetch: customFetch,
175-
events,
176-
args: {
177-
continue: args.continue,
178-
sessionID: args.session,
179-
agent: args.agent,
180-
model: args.model,
181-
prompt,
182-
fork: args.fork,
183-
},
184-
onExit: async () => {
185-
await client.call("shutdown", undefined)
186-
},
187-
})
178+
const transport = external
179+
? {
180+
url: (await client.call("server", network)).url,
181+
fetch: undefined,
182+
events: undefined,
183+
}
184+
: {
185+
url: "http://opencode.internal",
186+
fetch: createWorkerFetch(client),
187+
events: createEventSource(client),
188+
}
188189

189190
setTimeout(() => {
190191
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
191-
}, 1000)
192+
}, 1000).unref?.()
192193

193-
await tuiPromise
194+
try {
195+
await tui({
196+
url: transport.url,
197+
config,
198+
directory: cwd,
199+
fetch: transport.fetch,
200+
events: transport.events,
201+
args: {
202+
continue: args.continue,
203+
sessionID: args.session,
204+
agent: args.agent,
205+
model: args.model,
206+
prompt,
207+
fork: args.fork,
208+
},
209+
onExit: stop,
210+
})
211+
} finally {
212+
await stop()
213+
}
194214
} finally {
195215
unguard?.()
196216
}

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,7 @@ export const rpc = {
137137
async shutdown() {
138138
Log.Default.info("worker shutting down")
139139
if (eventStream.abort) eventStream.abort.abort()
140-
await Promise.race([
141-
Instance.disposeAll(),
142-
new Promise((resolve) => {
143-
setTimeout(resolve, 5000)
144-
}),
145-
])
140+
await Instance.disposeAll()
146141
if (server) server.stop(true)
147142
},
148143
}

0 commit comments

Comments
 (0)