diff options
| author | Luke Parker <[email protected]> | 2026-04-03 13:18:50 +1000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-03 13:18:50 +1000 |
| commit | 7b8dc8065e63b3bfee9dd56dad5548f1706b5acc (patch) | |
| tree | 5d5f0aa4337471c089c3d70e012841cd57bbf50e /packages/sdk | |
| parent | e89527c9f0eba6c7aa866f3ba39ce9f6809023a7 (diff) | |
| download | opencode-7b8dc8065e63b3bfee9dd56dad5548f1706b5acc.tar.gz opencode-7b8dc8065e63b3bfee9dd56dad5548f1706b5acc.zip | |
fix(sdk): handle Windows opencode spawn and shutdown (#20772)
Diffstat (limited to 'packages/sdk')
| -rw-r--r-- | packages/sdk/js/package.json | 9 | ||||
| -rw-r--r-- | packages/sdk/js/src/process.ts | 31 | ||||
| -rw-r--r-- | packages/sdk/js/src/server.ts | 39 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/server.ts | 39 |
4 files changed, 87 insertions, 31 deletions
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 231e8d9da..ed4e47384 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -23,9 +23,12 @@ "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", + "@types/cross-spawn": "catalog:", "@types/node": "catalog:", - "typescript": "catalog:", - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" }, - "dependencies": {} + "dependencies": { + "cross-spawn": "catalog:" + } } diff --git a/packages/sdk/js/src/process.ts b/packages/sdk/js/src/process.ts new file mode 100644 index 000000000..3111b424a --- /dev/null +++ b/packages/sdk/js/src/process.ts @@ -0,0 +1,31 @@ +import { type ChildProcess, spawnSync } from "node:child_process" + +// Duplicated from `packages/opencode/src/util/process.ts` because the SDK cannot +// import `opencode` without creating a cycle (`opencode` depends on `@opencode-ai/sdk`). +export function stop(proc: ChildProcess) { + if (proc.exitCode !== null || proc.signalCode !== null) return + if (process.platform === "win32" && proc.pid) { + const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true }) + if (!out.error && out.status === 0) return + } + proc.kill() +} + +export function bindAbort(proc: ChildProcess, signal?: AbortSignal, onAbort?: () => void) { + if (!signal) return () => {} + const abort = () => { + clear() + stop(proc) + onAbort?.() + } + const clear = () => { + signal.removeEventListener("abort", abort) + proc.off("exit", clear) + proc.off("error", clear) + } + signal.addEventListener("abort", abort, { once: true }) + proc.on("exit", clear) + proc.on("error", clear) + if (signal.aborted) abort() + return clear +} diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index 174131ccf..2d1ab29fc 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -1,5 +1,6 @@ -import { spawn } from "node:child_process" +import launch from "cross-spawn" import { type Config } from "./gen/types.gen.js" +import { stop, bindAbort } from "./process.js" export type ServerOptions = { hostname?: string @@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) { const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) - const proc = spawn(`opencode`, args, { - signal: options.signal, + const proc = launch(`opencode`, args, { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), }, }) + let clear = () => {} const url = await new Promise<string>((resolve, reject) => { const id = setTimeout(() => { + clear() + stop(proc) reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) }, options.timeout) let output = "" + let resolved = false proc.stdout?.on("data", (chunk) => { + if (resolved) return output += chunk.toString() const lines = output.split("\n") for (const line of lines) { if (line.startsWith("opencode server listening")) { const match = line.match(/on\s+(https?:\/\/[^\s]+)/) if (!match) { - throw new Error(`Failed to parse server url from output: ${line}`) + clear() + stop(proc) + clearTimeout(id) + reject(new Error(`Failed to parse server url from output: ${line}`)) + return } clearTimeout(id) + resolved = true resolve(match[1]!) return } @@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) { clearTimeout(id) reject(error) }) - if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) - } + clear = bindAbort(proc, options.signal, () => { + clearTimeout(id) + reject(options.signal?.reason) + }) }) return { url, close() { - proc.kill() + clear() + stop(proc) }, } } @@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) { args.push(`--agent=${options.agent}`) } - const proc = spawn(`opencode`, args, { - signal: options?.signal, + const proc = launch(`opencode`, args, { stdio: "inherit", env: { ...process.env, @@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) { }, }) + const clear = bindAbort(proc, options?.signal) + return { close() { - proc.kill() + clear() + stop(proc) }, } } diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index 174131ccf..48f1a253d 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -1,5 +1,6 @@ -import { spawn } from "node:child_process" +import launch from "cross-spawn" import { type Config } from "./gen/types.gen.js" +import { stop, bindAbort } from "../process.js" export type ServerOptions = { hostname?: string @@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) { const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) - const proc = spawn(`opencode`, args, { - signal: options.signal, + const proc = launch(`opencode`, args, { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), }, }) + let clear = () => {} const url = await new Promise<string>((resolve, reject) => { const id = setTimeout(() => { + clear() + stop(proc) reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) }, options.timeout) let output = "" + let resolved = false proc.stdout?.on("data", (chunk) => { + if (resolved) return output += chunk.toString() const lines = output.split("\n") for (const line of lines) { if (line.startsWith("opencode server listening")) { const match = line.match(/on\s+(https?:\/\/[^\s]+)/) if (!match) { - throw new Error(`Failed to parse server url from output: ${line}`) + clear() + stop(proc) + clearTimeout(id) + reject(new Error(`Failed to parse server url from output: ${line}`)) + return } clearTimeout(id) + resolved = true resolve(match[1]!) return } @@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) { clearTimeout(id) reject(error) }) - if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) - } + clear = bindAbort(proc, options.signal, () => { + clearTimeout(id) + reject(options.signal?.reason) + }) }) return { url, close() { - proc.kill() + clear() + stop(proc) }, } } @@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) { args.push(`--agent=${options.agent}`) } - const proc = spawn(`opencode`, args, { - signal: options?.signal, + const proc = launch(`opencode`, args, { stdio: "inherit", env: { ...process.env, @@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) { }, }) + const clear = bindAbort(proc, options?.signal) + return { close() { - proc.kill() + clear() + stop(proc) }, } } |
