Skip to content

Commit 7b8dc80

Browse files
authored
fix(sdk): handle Windows opencode spawn and shutdown (#20772)
1 parent e89527c commit 7b8dc80

File tree

8 files changed

+103
-35
lines changed

8 files changed

+103
-35
lines changed

bun.lock

Lines changed: 8 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"catalog": {
2828
"@effect/platform-node": "4.0.0-beta.43",
2929
"@types/bun": "1.3.11",
30+
"@types/cross-spawn": "6.0.6",
3031
"@octokit/rest": "22.0.0",
3132
"@hono/zod-validator": "0.4.2",
3233
"ulid": "3.0.1",
@@ -47,6 +48,7 @@
4748
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
4849
"effect": "4.0.0-beta.43",
4950
"ai": "6.0.138",
51+
"cross-spawn": "7.0.6",
5052
"hono": "4.10.7",
5153
"hono-openapi": "1.1.2",
5254
"fuzzysort": "3.1.0",

packages/opencode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"@tsconfig/bun": "catalog:",
5252
"@types/babel__core": "7.20.5",
5353
"@types/bun": "catalog:",
54-
"@types/cross-spawn": "6.0.6",
54+
"@types/cross-spawn": "catalog:",
5555
"@types/mime-types": "3.0.1",
5656
"@types/npmcli__arborist": "6.3.3",
5757
"@types/semver": "^7.5.8",
@@ -118,7 +118,7 @@
118118
"bun-pty": "0.4.8",
119119
"chokidar": "4.0.3",
120120
"clipboardy": "4.0.0",
121-
"cross-spawn": "^7.0.6",
121+
"cross-spawn": "catalog:",
122122
"decimal.js": "10.5.0",
123123
"diff": "catalog:",
124124
"drizzle-orm": "catalog:",

packages/opencode/src/util/process.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,11 @@ export namespace Process {
144144
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
145145
}
146146

147+
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
148+
// `opencode` without creating a cycle. Keep both copies in sync.
147149
export async function stop(proc: ChildProcess) {
150+
if (proc.exitCode !== null || proc.signalCode !== null) return
151+
148152
if (process.platform !== "win32" || !proc.pid) {
149153
proc.kill()
150154
return

packages/sdk/js/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@
2323
"devDependencies": {
2424
"@hey-api/openapi-ts": "0.90.10",
2525
"@tsconfig/node22": "catalog:",
26+
"@types/cross-spawn": "catalog:",
2627
"@types/node": "catalog:",
27-
"typescript": "catalog:",
28-
"@typescript/native-preview": "catalog:"
28+
"@typescript/native-preview": "catalog:",
29+
"typescript": "catalog:"
2930
},
30-
"dependencies": {}
31+
"dependencies": {
32+
"cross-spawn": "catalog:"
33+
}
3134
}

packages/sdk/js/src/process.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type ChildProcess, spawnSync } from "node:child_process"
2+
3+
// Duplicated from `packages/opencode/src/util/process.ts` because the SDK cannot
4+
// import `opencode` without creating a cycle (`opencode` depends on `@opencode-ai/sdk`).
5+
export function stop(proc: ChildProcess) {
6+
if (proc.exitCode !== null || proc.signalCode !== null) return
7+
if (process.platform === "win32" && proc.pid) {
8+
const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
9+
if (!out.error && out.status === 0) return
10+
}
11+
proc.kill()
12+
}
13+
14+
export function bindAbort(proc: ChildProcess, signal?: AbortSignal, onAbort?: () => void) {
15+
if (!signal) return () => {}
16+
const abort = () => {
17+
clear()
18+
stop(proc)
19+
onAbort?.()
20+
}
21+
const clear = () => {
22+
signal.removeEventListener("abort", abort)
23+
proc.off("exit", clear)
24+
proc.off("error", clear)
25+
}
26+
signal.addEventListener("abort", abort, { once: true })
27+
proc.on("exit", clear)
28+
proc.on("error", clear)
29+
if (signal.aborted) abort()
30+
return clear
31+
}

packages/sdk/js/src/server.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { spawn } from "node:child_process"
1+
import launch from "cross-spawn"
22
import { type Config } from "./gen/types.gen.js"
3+
import { stop, bindAbort } from "./process.js"
34

45
export type ServerOptions = {
56
hostname?: string
@@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
3132
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
3233
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
3334

34-
const proc = spawn(`opencode`, args, {
35-
signal: options.signal,
35+
const proc = launch(`opencode`, args, {
3636
env: {
3737
...process.env,
3838
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
3939
},
4040
})
41+
let clear = () => {}
4142

4243
const url = await new Promise<string>((resolve, reject) => {
4344
const id = setTimeout(() => {
45+
clear()
46+
stop(proc)
4447
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
4548
}, options.timeout)
4649
let output = ""
50+
let resolved = false
4751
proc.stdout?.on("data", (chunk) => {
52+
if (resolved) return
4853
output += chunk.toString()
4954
const lines = output.split("\n")
5055
for (const line of lines) {
5156
if (line.startsWith("opencode server listening")) {
5257
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
5358
if (!match) {
54-
throw new Error(`Failed to parse server url from output: ${line}`)
59+
clear()
60+
stop(proc)
61+
clearTimeout(id)
62+
reject(new Error(`Failed to parse server url from output: ${line}`))
63+
return
5564
}
5665
clearTimeout(id)
66+
resolved = true
5767
resolve(match[1]!)
5868
return
5969
}
@@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
7484
clearTimeout(id)
7585
reject(error)
7686
})
77-
if (options.signal) {
78-
options.signal.addEventListener("abort", () => {
79-
clearTimeout(id)
80-
reject(new Error("Aborted"))
81-
})
82-
}
87+
clear = bindAbort(proc, options.signal, () => {
88+
clearTimeout(id)
89+
reject(options.signal?.reason)
90+
})
8391
})
8492

8593
return {
8694
url,
8795
close() {
88-
proc.kill()
96+
clear()
97+
stop(proc)
8998
},
9099
}
91100
}
@@ -106,18 +115,20 @@ export function createOpencodeTui(options?: TuiOptions) {
106115
args.push(`--agent=${options.agent}`)
107116
}
108117

109-
const proc = spawn(`opencode`, args, {
110-
signal: options?.signal,
118+
const proc = launch(`opencode`, args, {
111119
stdio: "inherit",
112120
env: {
113121
...process.env,
114122
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
115123
},
116124
})
117125

126+
const clear = bindAbort(proc, options?.signal)
127+
118128
return {
119129
close() {
120-
proc.kill()
130+
clear()
131+
stop(proc)
121132
},
122133
}
123134
}

packages/sdk/js/src/v2/server.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { spawn } from "node:child_process"
1+
import launch from "cross-spawn"
22
import { type Config } from "./gen/types.gen.js"
3+
import { stop, bindAbort } from "../process.js"
34

45
export type ServerOptions = {
56
hostname?: string
@@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
3132
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
3233
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
3334

34-
const proc = spawn(`opencode`, args, {
35-
signal: options.signal,
35+
const proc = launch(`opencode`, args, {
3636
env: {
3737
...process.env,
3838
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
3939
},
4040
})
41+
let clear = () => {}
4142

4243
const url = await new Promise<string>((resolve, reject) => {
4344
const id = setTimeout(() => {
45+
clear()
46+
stop(proc)
4447
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
4548
}, options.timeout)
4649
let output = ""
50+
let resolved = false
4751
proc.stdout?.on("data", (chunk) => {
52+
if (resolved) return
4853
output += chunk.toString()
4954
const lines = output.split("\n")
5055
for (const line of lines) {
5156
if (line.startsWith("opencode server listening")) {
5257
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
5358
if (!match) {
54-
throw new Error(`Failed to parse server url from output: ${line}`)
59+
clear()
60+
stop(proc)
61+
clearTimeout(id)
62+
reject(new Error(`Failed to parse server url from output: ${line}`))
63+
return
5564
}
5665
clearTimeout(id)
66+
resolved = true
5767
resolve(match[1]!)
5868
return
5969
}
@@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
7484
clearTimeout(id)
7585
reject(error)
7686
})
77-
if (options.signal) {
78-
options.signal.addEventListener("abort", () => {
79-
clearTimeout(id)
80-
reject(new Error("Aborted"))
81-
})
82-
}
87+
clear = bindAbort(proc, options.signal, () => {
88+
clearTimeout(id)
89+
reject(options.signal?.reason)
90+
})
8391
})
8492

8593
return {
8694
url,
8795
close() {
88-
proc.kill()
96+
clear()
97+
stop(proc)
8998
},
9099
}
91100
}
@@ -106,18 +115,20 @@ export function createOpencodeTui(options?: TuiOptions) {
106115
args.push(`--agent=${options.agent}`)
107116
}
108117

109-
const proc = spawn(`opencode`, args, {
110-
signal: options?.signal,
118+
const proc = launch(`opencode`, args, {
111119
stdio: "inherit",
112120
env: {
113121
...process.env,
114122
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
115123
},
116124
})
117125

126+
const clear = bindAbort(proc, options?.signal)
127+
118128
return {
119129
close() {
120-
proc.kill()
130+
clear()
131+
stop(proc)
121132
},
122133
}
123134
}

0 commit comments

Comments
 (0)