refactor: migrate Bun.spawn to Process utility with timeout and cleanup (#14448)

This commit is contained in:
Dax
2026-02-24 23:04:15 -05:00
committed by GitHub
parent da40ab7b3d
commit 814c1d398c
13 changed files with 203 additions and 114 deletions

View File

@@ -4,20 +4,21 @@ import { Log } from "../util/log"
import path from "path" import path from "path"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun" import { text } from "node:stream/consumers"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry" import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied" import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc { export namespace BunProc {
const log = Log.create({ service: "bun" }) const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) { export async function run(cmd: string[], options?: Process.Options) {
log.info("running", { log.info("running", {
cmd: [which(), ...cmd], cmd: [which(), ...cmd],
...options, ...options,
}) })
const result = Bun.spawn([which(), ...cmd], { const result = Process.spawn([which(), ...cmd], {
...options, ...options,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -28,23 +29,15 @@ export namespace BunProc {
}, },
}) })
const code = await result.exited const code = await result.exited
const stdout = result.stdout const stdout = result.stdout ? await text(result.stdout) : undefined
? typeof result.stdout === "number" const stderr = result.stderr ? await text(result.stderr) : undefined
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
log.info("done", { log.info("done", {
code, code,
stdout, stdout,
stderr, stderr,
}) })
if (code !== 0) { if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`) throw new Error(`Command failed with exit code ${code}`)
} }
return result return result
} }

View File

@@ -1,5 +1,7 @@
import { readableStreamToText, semver } from "bun" import { semver } from "bun"
import { text } from "node:stream/consumers"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process"
export namespace PackageRegistry { export namespace PackageRegistry {
const log = Log.create({ service: "bun" }) const log = Log.create({ service: "bun" })
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
} }
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> { export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], { const result = Process.spawn([which(), "info", pkg, field], {
cwd, cwd,
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
}) })
const code = await result.exited const code = await result.exited
const stdout = result.stdout ? await readableStreamToText(result.stdout) : "" const stdout = result.stdout ? await text(result.stdout) : ""
const stderr = result.stderr ? await readableStreamToText(result.stderr) : "" const stderr = result.stderr ? await text(result.stderr) : ""
if (code !== 0) { if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr }) log.warn("bun info failed", { pkg, field, code, stderr })

View File

@@ -11,6 +11,8 @@ import { Global } from "../../global"
import { Plugin } from "../../plugin" import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
type PluginAuth = NonNullable<Hooks["auth"]> type PluginAuth = NonNullable<Hooks["auth"]>
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
if (args.url) { if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({ const proc = Process.spawn(wellknown.auth.command, {
cmd: wellknown.auth.command,
stdout: "pipe", stdout: "pipe",
}) })
const exit = await proc.exited const exit = await proc.exited
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done") prompts.outro("Done")
return return
} }
const token = await new Response(proc.stdout).text() if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await text(proc.stdout)
await Auth.set(args.url, { await Auth.set(args.url, {
type: "wellknown", type: "wellknown",
key: wellknown.auth.env, key: wellknown.auth.env,

View File

@@ -6,6 +6,7 @@ import { UI } from "../ui"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os" import { EOL } from "os"
import path from "path" import path from "path"
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
if (shouldPaginate) { if (shouldPaginate) {
const proc = Bun.spawn({ const proc = Process.spawn(pagerCmd(), {
cmd: pagerCmd(),
stdin: "pipe", stdin: "pipe",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
}) })
if (!proc.stdin) {
console.log(output)
return
}
proc.stdin.write(output) proc.stdin.write(output)
proc.stdin.end() proc.stdin.end()
await proc.exited await proc.exited

View File

@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
import { Filesystem } from "../../../../util/filesystem" import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
/** /**
* Writes text to clipboard via OSC 52 escape sequence. * Writes text to clipboard via OSC 52 escape sequence.
@@ -87,7 +88,8 @@ export namespace Clipboard {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) { if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy") console.log("clipboard: using wl-copy")
return async (text: string) => { return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited.catch(() => {}) await proc.exited.catch(() => {})
@@ -96,11 +98,12 @@ export namespace Clipboard {
if (Bun.which("xclip")) { if (Bun.which("xclip")) {
console.log("clipboard: using xclip") console.log("clipboard: using xclip")
return async (text: string) => { return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], { const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe", stdin: "pipe",
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })
if (!proc.stdin) return
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited.catch(() => {}) await proc.exited.catch(() => {})
@@ -109,11 +112,12 @@ export namespace Clipboard {
if (Bun.which("xsel")) { if (Bun.which("xsel")) {
console.log("clipboard: using xsel") console.log("clipboard: using xsel")
return async (text: string) => { return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], { const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe", stdin: "pipe",
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) })
if (!proc.stdin) return
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited.catch(() => {}) await proc.exited.catch(() => {})
@@ -125,7 +129,7 @@ export namespace Clipboard {
console.log("clipboard: using powershell") console.log("clipboard: using powershell")
return async (text: string) => { return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn( const proc = Process.spawn(
[ [
"powershell.exe", "powershell.exe",
"-NonInteractive", "-NonInteractive",
@@ -140,6 +144,7 @@ export namespace Clipboard {
}, },
) )
if (!proc.stdin) return
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited.catch(() => {}) await proc.exited.catch(() => {})

View File

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { CliRenderer } from "@opentui/core" import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export namespace Editor { export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> { export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -17,8 +18,7 @@ export namespace Editor {
opts.renderer.suspend() opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear() opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ") const parts = editor.split(" ")
const proc = Bun.spawn({ const proc = Process.spawn([...parts, filepath], {
cmd: [...parts, filepath],
stdin: "inherit", stdin: "inherit",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",

View File

@@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { $ } from "bun" import { $ } from "bun"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
@@ -153,17 +155,19 @@ export namespace Ripgrep {
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg") if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg") if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, { const proc = Process.spawn(args, {
cwd: Global.Path.bin, cwd: Global.Path.bin,
stderr: "pipe", stderr: "pipe",
stdout: "pipe", stdout: "pipe",
}) })
await proc.exited const exit = await proc.exited
if (proc.exitCode !== 0) if (exit !== 0) {
const stderr = proc.stderr ? await text(proc.stderr) : ""
throw new ExtractionFailedError({ throw new ExtractionFailedError({
filepath, filepath,
stderr: await Bun.readableStreamToText(proc.stderr), stderr,
}) })
}
} }
if (config.extension === "zip") { if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer]))) const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
@@ -227,8 +231,7 @@ export namespace Ripgrep {
} }
} }
// Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. // Guard against invalid cwd to provide a consistent ENOENT error.
// See https://github.com/oven-sh/bun/issues/24012
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT", code: "ENOENT",
@@ -237,41 +240,35 @@ export namespace Ripgrep {
}) })
} }
const proc = Bun.spawn(args, { const proc = Process.spawn(args, {
cwd: input.cwd, cwd: input.cwd,
stdout: "pipe", stdout: "pipe",
stderr: "ignore", stderr: "ignore",
maxBuffer: 1024 * 1024 * 20, abort: input.signal,
signal: input.signal,
}) })
const reader = proc.stdout.getReader() if (!proc.stdout) {
const decoder = new TextDecoder() throw new Error("Process output not available")
let buffer = ""
try {
while (true) {
input.signal?.throwIfAborted()
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (line) yield line
}
}
if (buffer) yield buffer
} finally {
reader.releaseLock()
await proc.exited
} }
let buffer = ""
const stream = proc.stdout as AsyncIterable<Buffer | string>
for await (const chunk of stream) {
input.signal?.throwIfAborted()
buffer += typeof chunk === "string" ? chunk : chunk.toString()
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (line) yield line
}
}
if (buffer) yield buffer
await proc.exited
input.signal?.throwIfAborted() input.signal?.throwIfAborted()
} }

View File

@@ -1,7 +1,8 @@
import { readableStreamToText } from "bun" import { text } from "node:stream/consumers"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
export interface Info { export interface Info {
@@ -213,12 +214,13 @@ export const rlang: Info = {
if (airPath == null) return false if (airPath == null) return false
try { try {
const proc = Bun.spawn(["air", "--help"], { const proc = Process.spawn(["air", "--help"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })
await proc.exited await proc.exited
const output = await readableStreamToText(proc.stdout) if (!proc.stdout) return false
const output = await text(proc.stdout)
// Check for "Air: An R language server and formatter" // Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0] const firstLine = output.split("\n")[0]
@@ -238,7 +240,7 @@ export const uvformat: Info = {
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) { if (Bun.which("uv") !== null) {
const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited const code = await proc.exited
return code === 0 return code === 0
} }

View File

@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
import { Config } from "../config/config" import { Config } from "../config/config"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Process } from "../util/process"
export namespace Format { export namespace Format {
const log = Log.create({ service: "format" }) const log = Log.create({ service: "format" })
@@ -110,13 +111,15 @@ export namespace Format {
for (const item of await getFormatter(ext)) { for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command }) log.info("running", { command: item.command })
try { try {
const proc = Bun.spawn({ const proc = Process.spawn(
cmd: item.command.map((x) => x.replace("$FILE", file)), item.command.map((x) => x.replace("$FILE", file)),
cwd: Instance.directory, {
env: { ...process.env, ...item.environment }, cwd: Instance.directory,
stdout: "ignore", env: { ...process.env, ...item.environment },
stderr: "ignore", stdout: "ignore",
}) stderr: "ignore",
},
)
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0)
log.error("failed", { log.error("failed", {

View File

@@ -4,12 +4,14 @@ import os from "os"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { $, readableStreamToText } from "bun" import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Archive } from "../util/archive" import { Archive } from "../util/archive"
import { Process } from "../util/process"
export namespace LSPServer { export namespace LSPServer {
const log = Log.create({ service: "lsp.server" }) const log = Log.create({ service: "lsp.server" })
@@ -133,7 +135,7 @@ export namespace LSPServer {
) )
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], { await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -263,14 +265,16 @@ export namespace LSPServer {
} }
if (lintBin) { if (lintBin) {
const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" }) const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
await proc.exited await proc.exited
const help = await readableStreamToText(proc.stdout) if (proc.stdout) {
if (help.includes("--lsp")) { const help = await text(proc.stdout)
return { if (help.includes("--lsp")) {
process: spawn(lintBin, ["--lsp"], { return {
cwd: root, process: spawn(lintBin, ["--lsp"], {
}), cwd: root,
}),
}
} }
} }
} }
@@ -372,8 +376,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls") log.info("installing gopls")
const proc = Bun.spawn({ const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin }, env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -414,8 +417,7 @@ export namespace LSPServer {
} }
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing rubocop") log.info("installing rubocop")
const proc = Bun.spawn({ const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
stdin: "pipe", stdin: "pipe",
@@ -513,7 +515,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], { await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -746,8 +748,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing csharp-ls via dotnet tool") log.info("installing csharp-ls via dotnet tool")
const proc = Bun.spawn({ const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
stdin: "pipe", stdin: "pipe",
@@ -786,8 +787,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing fsautocomplete via dotnet tool") log.info("installing fsautocomplete via dotnet tool")
const proc = Bun.spawn({ const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
stdin: "pipe", stdin: "pipe",
@@ -1047,7 +1047,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -1094,7 +1094,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -1339,7 +1339,7 @@ export namespace LSPServer {
const exists = await Filesystem.exists(js) const exists = await Filesystem.exists(js)
if (!exists) { if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], { await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -1518,7 +1518,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], { await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -1615,7 +1615,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], { await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,
@@ -1827,7 +1827,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) { if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin, cwd: Global.Path.bin,
env: { env: {
...process.env, ...process.env,

View File

@@ -1,7 +1,9 @@
import z from "zod" import z from "zod"
import { text } from "node:stream/consumers"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", {
} }
args.push(searchPath) args.push(searchPath)
const proc = Bun.spawn([rgPath, ...args], { const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
signal: ctx.abort, abort: ctx.abort,
}) })
const output = await new Response(proc.stdout).text() if (!proc.stdout || !proc.stderr) {
const errorOutput = await new Response(proc.stderr).text() throw new Error("Process output not available")
}
const output = await text(proc.stdout)
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)

View File

@@ -1,5 +1,7 @@
import { $ } from "bun" import { $ } from "bun"
import { buffer } from "node:stream/consumers"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Process } from "./process"
export interface GitResult { export interface GitResult {
exitCode: number exitCode: number
@@ -14,12 +16,12 @@ export interface GitResult {
* Uses Bun's lightweight `$` shell by default. When the process is running * Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which * as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that * carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Bun.spawn` with `stdin: "ignore"`. * case we fall back to `Process.spawn` with `stdin: "ignore"`.
*/ */
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> { export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") { if (Flag.OPENCODE_CLIENT === "acp") {
try { try {
const proc = Bun.spawn(["git", ...args], { const proc = Process.spawn(["git", ...args], {
stdin: "ignore", stdin: "ignore",
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@@ -27,18 +29,15 @@ export async function git(args: string[], opts: { cwd: string; env?: Record<stri
env: opts.env ? { ...process.env, ...opts.env } : process.env, env: opts.env ? { ...process.env, ...opts.env } : process.env,
}) })
// Read output concurrently with exit to avoid pipe buffer deadlock // Read output concurrently with exit to avoid pipe buffer deadlock
const [exitCode, stdout, stderr] = await Promise.all([ if (!proc.stdout || !proc.stderr) {
proc.exited, throw new Error("Process output not available")
new Response(proc.stdout).arrayBuffer(), }
new Response(proc.stderr).arrayBuffer(), const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
])
const stdoutBuf = Buffer.from(stdout)
const stderrBuf = Buffer.from(stderr)
return { return {
exitCode, exitCode,
text: () => stdoutBuf.toString(), text: () => out.toString(),
stdout: stdoutBuf, stdout: out,
stderr: stderrBuf, stderr: err,
} }
} catch (error) { } catch (error) {
const stderr = Buffer.from(error instanceof Error ? error.message : String(error)) const stderr = Buffer.from(error instanceof Error ? error.message : String(error))

View File

@@ -0,0 +1,71 @@
import { spawn as launch, type ChildProcess } from "child_process"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
}
export type Child = ChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], options: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
options.abort?.throwIfAborted()
const proc = launch(cmd[0], cmd.slice(1), {
cwd: options.cwd,
env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined,
stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"],
})
let aborted = false
let timer: ReturnType<typeof setTimeout> | undefined
const abort = () => {
if (aborted) return
if (proc.exitCode !== null || proc.signalCode !== null) return
aborted = true
proc.kill(options.kill ?? "SIGTERM")
const timeout = options.timeout ?? 5_000
if (timeout <= 0) return
timer = setTimeout(() => {
proc.kill("SIGKILL")
}, timeout)
}
const exited = new Promise<number>((resolve, reject) => {
const done = () => {
options.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
}
proc.once("exit", (exitCode, signal) => {
done()
resolve(exitCode ?? (signal ? 1 : 0))
})
proc.once("error", (error) => {
done()
reject(error)
})
})
if (options.abort) {
options.abort.addEventListener("abort", abort, { once: true })
if (options.abort.aborted) abort()
}
const child = proc as Child
child.exited = exited
return child
}
}