fix(sdk): handle Windows opencode spawn and shutdown (#20772)
This commit is contained in:
parent
e89527c9f0
commit
7b8dc8065e
8 changed files with 103 additions and 35 deletions
31
packages/sdk/js/src/process.ts
Normal file
31
packages/sdk/js/src/process.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue