Bladeren bron

fix(sdk): handle Windows opencode spawn and shutdown (#20772)

Luke Parker 2 weken geleden
bovenliggende
commit
7b8dc8065e

+ 8 - 2
bun.lock

@@ -355,7 +355,7 @@
         "bun-pty": "0.4.8",
         "chokidar": "4.0.3",
         "clipboardy": "4.0.0",
-        "cross-spawn": "^7.0.6",
+        "cross-spawn": "catalog:",
         "decimal.js": "10.5.0",
         "diff": "catalog:",
         "drizzle-orm": "catalog:",
@@ -410,7 +410,7 @@
         "@tsconfig/bun": "catalog:",
         "@types/babel__core": "7.20.5",
         "@types/bun": "catalog:",
-        "@types/cross-spawn": "6.0.6",
+        "@types/cross-spawn": "catalog:",
         "@types/mime-types": "3.0.1",
         "@types/npmcli__arborist": "6.3.3",
         "@types/semver": "^7.5.8",
@@ -463,9 +463,13 @@
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "version": "1.3.13",
+      "dependencies": {
+        "cross-spawn": "catalog:",
+      },
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
+        "@types/cross-spawn": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
         "typescript": "catalog:",
@@ -634,11 +638,13 @@
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/node22": "22.0.2",
     "@types/bun": "1.3.11",
+    "@types/cross-spawn": "6.0.6",
     "@types/luxon": "3.7.1",
     "@types/node": "22.13.9",
     "@types/semver": "7.7.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
     "ai": "6.0.138",
+    "cross-spawn": "7.0.6",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
     "drizzle-kit": "1.0.0-beta.19-d95b7a4",

+ 2 - 0
package.json

@@ -27,6 +27,7 @@
     "catalog": {
       "@effect/platform-node": "4.0.0-beta.43",
       "@types/bun": "1.3.11",
+      "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
@@ -47,6 +48,7 @@
       "drizzle-orm": "1.0.0-beta.19-d95b7a4",
       "effect": "4.0.0-beta.43",
       "ai": "6.0.138",
+      "cross-spawn": "7.0.6",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",

+ 2 - 2
packages/opencode/package.json

@@ -51,7 +51,7 @@
     "@tsconfig/bun": "catalog:",
     "@types/babel__core": "7.20.5",
     "@types/bun": "catalog:",
-    "@types/cross-spawn": "6.0.6",
+    "@types/cross-spawn": "catalog:",
     "@types/mime-types": "3.0.1",
     "@types/npmcli__arborist": "6.3.3",
     "@types/semver": "^7.5.8",
@@ -118,7 +118,7 @@
     "bun-pty": "0.4.8",
     "chokidar": "4.0.3",
     "clipboardy": "4.0.0",
-    "cross-spawn": "^7.0.6",
+    "cross-spawn": "catalog:",
     "decimal.js": "10.5.0",
     "diff": "catalog:",
     "drizzle-orm": "catalog:",

+ 4 - 0
packages/opencode/src/util/process.ts

@@ -144,7 +144,11 @@ export namespace Process {
     throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
   }
 
+  // Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
+  // `opencode` without creating a cycle. Keep both copies in sync.
   export async function stop(proc: ChildProcess) {
+    if (proc.exitCode !== null || proc.signalCode !== null) return
+
     if (process.platform !== "win32" || !proc.pid) {
       proc.kill()
       return

+ 6 - 3
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:"
+  }
 }

+ 31 - 0
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
+}

+ 25 - 14
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)
     },
   }
 }

+ 25 - 14
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)
     },
   }
 }