Skip to content

Commit e22afb0

Browse files
Apply PR #14448: refactor: migrate Bun.spawn to Process utility with timeout and cleanup
2 parents b3d0421 + b9d96ce commit e22afb0

13 files changed

Lines changed: 199 additions & 110 deletions

File tree

packages/opencode/src/bun/index.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@ import { Log } from "../util/log"
44
import path from "path"
55
import { Filesystem } from "../util/filesystem"
66
import { NamedError } from "@opencode-ai/util/error"
7-
import { readableStreamToText } from "bun"
7+
import { text } from "node:stream/consumers"
88
import { Lock } from "../util/lock"
99
import { PackageRegistry } from "./registry"
1010
import { proxied } from "@/util/proxied"
11+
import { Process } from "../util/process"
1112

1213
export namespace BunProc {
1314
const log = Log.create({ service: "bun" })
1415

15-
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
16+
export async function run(cmd: string[], options?: Process.Options) {
1617
log.info("running", {
1718
cmd: [which(), ...cmd],
1819
...options,
1920
})
20-
const result = Bun.spawn([which(), ...cmd], {
21+
const result = Process.spawn([which(), ...cmd], {
2122
...options,
2223
stdout: "pipe",
2324
stderr: "pipe",
@@ -28,23 +29,15 @@ export namespace BunProc {
2829
},
2930
})
3031
const code = await result.exited
31-
const stdout = result.stdout
32-
? typeof result.stdout === "number"
33-
? result.stdout
34-
: await readableStreamToText(result.stdout)
35-
: undefined
36-
const stderr = result.stderr
37-
? typeof result.stderr === "number"
38-
? result.stderr
39-
: await readableStreamToText(result.stderr)
40-
: undefined
32+
const stdout = result.stdout ? await text(result.stdout) : undefined
33+
const stderr = result.stderr ? await text(result.stderr) : undefined
4134
log.info("done", {
4235
code,
4336
stdout,
4437
stderr,
4538
})
4639
if (code !== 0) {
47-
throw new Error(`Command failed with exit code ${result.exitCode}`)
40+
throw new Error(`Command failed with exit code ${code}`)
4841
}
4942
return result
5043
}

packages/opencode/src/bun/registry.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { readableStreamToText, semver } from "bun"
1+
import { semver } from "bun"
2+
import { text } from "node:stream/consumers"
23
import { Log } from "../util/log"
4+
import { Process } from "../util/process"
35

46
export namespace PackageRegistry {
57
const log = Log.create({ service: "bun" })
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
911
}
1012

1113
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
12-
const result = Bun.spawn([which(), "info", pkg, field], {
14+
const result = Process.spawn([which(), "info", pkg, field], {
1315
cwd,
1416
stdout: "pipe",
1517
stderr: "pipe",
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
2022
})
2123

2224
const code = await result.exited
23-
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
24-
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
25+
const stdout = result.stdout ? await text(result.stdout) : ""
26+
const stderr = result.stderr ? await text(result.stderr) : ""
2527

2628
if (code !== 0) {
2729
log.warn("bun info failed", { pkg, field, code, stderr })

packages/opencode/src/cli/cmd/auth.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { Global } from "../../global"
1111
import { Plugin } from "../../plugin"
1212
import { Instance } from "../../project/instance"
1313
import type { Hooks } from "@opencode-ai/plugin"
14+
import { Process } from "../../util/process"
15+
import { text } from "node:stream/consumers"
1416

1517
type PluginAuth = NonNullable<Hooks["auth"]>
1618

@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
263265
if (args.url) {
264266
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
265267
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
266-
const proc = Bun.spawn({
267-
cmd: wellknown.auth.command,
268+
const proc = Process.spawn(wellknown.auth.command, {
268269
stdout: "pipe",
269270
})
270271
const exit = await proc.exited
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
273274
prompts.outro("Done")
274275
return
275276
}
276-
const token = await new Response(proc.stdout).text()
277+
if (!proc.stdout) {
278+
prompts.log.error("Failed")
279+
prompts.outro("Done")
280+
return
281+
}
282+
const token = await text(proc.stdout)
277283
await Auth.set(args.url, {
278284
type: "wellknown",
279285
key: wellknown.auth.env,

packages/opencode/src/cli/cmd/session.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { UI } from "../ui"
66
import { Locale } from "../../util/locale"
77
import { Flag } from "../../flag/flag"
88
import { Filesystem } from "../../util/filesystem"
9+
import { Process } from "../../util/process"
910
import { EOL } from "os"
1011
import path from "path"
1112

@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
102103
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
103104

104105
if (shouldPaginate) {
105-
const proc = Bun.spawn({
106-
cmd: pagerCmd(),
106+
const proc = Process.spawn(pagerCmd(), {
107107
stdin: "pipe",
108108
stdout: "inherit",
109109
stderr: "inherit",
110110
})
111111

112+
if (!proc.stdin) {
113+
console.log(output)
114+
return
115+
}
116+
112117
proc.stdin.write(output)
113118
proc.stdin.end()
114119
await proc.exited

packages/opencode/src/cli/cmd/tui/util/clipboard.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
55
import { tmpdir } from "os"
66
import path from "path"
77
import { Filesystem } from "../../../../util/filesystem"
8+
import { Process } from "../../../../util/process"
89

910
/**
1011
* Writes text to clipboard via OSC 52 escape sequence.
@@ -87,7 +88,8 @@ export namespace Clipboard {
8788
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
8889
console.log("clipboard: using wl-copy")
8990
return async (text: string) => {
90-
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
91+
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
92+
if (!proc.stdin) return
9193
proc.stdin.write(text)
9294
proc.stdin.end()
9395
await proc.exited.catch(() => {})
@@ -96,11 +98,12 @@ export namespace Clipboard {
9698
if (Bun.which("xclip")) {
9799
console.log("clipboard: using xclip")
98100
return async (text: string) => {
99-
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
101+
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
100102
stdin: "pipe",
101103
stdout: "ignore",
102104
stderr: "ignore",
103105
})
106+
if (!proc.stdin) return
104107
proc.stdin.write(text)
105108
proc.stdin.end()
106109
await proc.exited.catch(() => {})
@@ -109,11 +112,12 @@ export namespace Clipboard {
109112
if (Bun.which("xsel")) {
110113
console.log("clipboard: using xsel")
111114
return async (text: string) => {
112-
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
115+
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
113116
stdin: "pipe",
114117
stdout: "ignore",
115118
stderr: "ignore",
116119
})
120+
if (!proc.stdin) return
117121
proc.stdin.write(text)
118122
proc.stdin.end()
119123
await proc.exited.catch(() => {})
@@ -125,7 +129,7 @@ export namespace Clipboard {
125129
console.log("clipboard: using powershell")
126130
return async (text: string) => {
127131
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
128-
const proc = Bun.spawn(
132+
const proc = Process.spawn(
129133
[
130134
"powershell.exe",
131135
"-NonInteractive",
@@ -140,6 +144,7 @@ export namespace Clipboard {
140144
},
141145
)
142146

147+
if (!proc.stdin) return
143148
proc.stdin.write(text)
144149
proc.stdin.end()
145150
await proc.exited.catch(() => {})

packages/opencode/src/cli/cmd/tui/util/editor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { CliRenderer } from "@opentui/core"
66
import { Filesystem } from "@/util/filesystem"
7+
import { Process } from "@/util/process"
78

89
export namespace Editor {
910
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -17,8 +18,7 @@ export namespace Editor {
1718
opts.renderer.suspend()
1819
opts.renderer.currentRenderBuffer.clear()
1920
const parts = editor.split(" ")
20-
const proc = Bun.spawn({
21-
cmd: [...parts, filepath],
21+
const proc = Process.spawn([...parts, filepath], {
2222
stdin: "inherit",
2323
stdout: "inherit",
2424
stderr: "inherit",

packages/opencode/src/file/ripgrep.ts

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error"
77
import { lazy } from "../util/lazy"
88
import { $ } from "bun"
99
import { Filesystem } from "../util/filesystem"
10+
import { Process } from "../util/process"
11+
import { text } from "node:stream/consumers"
1012

1113
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
1214
import { Log } from "@/util/log"
@@ -153,17 +155,19 @@ export namespace Ripgrep {
153155
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
154156
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
155157

156-
const proc = Bun.spawn(args, {
158+
const proc = Process.spawn(args, {
157159
cwd: Global.Path.bin,
158160
stderr: "pipe",
159161
stdout: "pipe",
160162
})
161-
await proc.exited
162-
if (proc.exitCode !== 0)
163+
const exit = await proc.exited
164+
if (exit !== 0) {
165+
const stderr = proc.stderr ? await text(proc.stderr) : ""
163166
throw new ExtractionFailedError({
164167
filepath,
165-
stderr: await Bun.readableStreamToText(proc.stderr),
168+
stderr,
166169
})
170+
}
167171
}
168172
if (config.extension === "zip") {
169173
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
@@ -227,8 +231,7 @@ export namespace Ripgrep {
227231
}
228232
}
229233

230-
// Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
231-
// See https://github.com/oven-sh/bun/issues/24012
234+
// Guard against invalid cwd to provide a consistent ENOENT error.
232235
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
233236
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
234237
code: "ENOENT",
@@ -237,41 +240,35 @@ export namespace Ripgrep {
237240
})
238241
}
239242

240-
const proc = Bun.spawn(args, {
243+
const proc = Process.spawn(args, {
241244
cwd: input.cwd,
242245
stdout: "pipe",
243246
stderr: "ignore",
244-
maxBuffer: 1024 * 1024 * 20,
245-
signal: input.signal,
247+
abort: input.signal,
246248
})
247249

248-
const reader = proc.stdout.getReader()
249-
const decoder = new TextDecoder()
250-
let buffer = ""
251-
252-
try {
253-
while (true) {
254-
input.signal?.throwIfAborted()
250+
if (!proc.stdout) {
251+
throw new Error("Process output not available")
252+
}
255253

256-
const { done, value } = await reader.read()
257-
if (done) break
254+
let buffer = ""
255+
const stream = proc.stdout as AsyncIterable<Buffer | string>
256+
for await (const chunk of stream) {
257+
input.signal?.throwIfAborted()
258258

259-
buffer += decoder.decode(value, { stream: true })
260-
// Handle both Unix (\n) and Windows (\r\n) line endings
261-
const lines = buffer.split(/\r?\n/)
262-
buffer = lines.pop() || ""
259+
buffer += typeof chunk === "string" ? chunk : chunk.toString()
260+
// Handle both Unix (\n) and Windows (\r\n) line endings
261+
const lines = buffer.split(/\r?\n/)
262+
buffer = lines.pop() || ""
263263

264-
for (const line of lines) {
265-
if (line) yield line
266-
}
264+
for (const line of lines) {
265+
if (line) yield line
267266
}
268-
269-
if (buffer) yield buffer
270-
} finally {
271-
reader.releaseLock()
272-
await proc.exited
273267
}
274268

269+
if (buffer) yield buffer
270+
await proc.exited
271+
275272
input.signal?.throwIfAborted()
276273
}
277274

packages/opencode/src/format/formatter.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { readableStreamToText } from "bun"
1+
import { text } from "node:stream/consumers"
22
import { BunProc } from "../bun"
33
import { Instance } from "../project/instance"
44
import { Filesystem } from "../util/filesystem"
5+
import { Process } from "../util/process"
56
import { Flag } from "@/flag/flag"
67

78
export interface Info {
@@ -213,12 +214,13 @@ export const rlang: Info = {
213214
if (airPath == null) return false
214215

215216
try {
216-
const proc = Bun.spawn(["air", "--help"], {
217+
const proc = Process.spawn(["air", "--help"], {
217218
stdout: "pipe",
218219
stderr: "pipe",
219220
})
220221
await proc.exited
221-
const output = await readableStreamToText(proc.stdout)
222+
if (!proc.stdout) return false
223+
const output = await text(proc.stdout)
222224

223225
// Check for "Air: An R language server and formatter"
224226
const firstLine = output.split("\n")[0]
@@ -238,7 +240,7 @@ export const uvformat: Info = {
238240
async enabled() {
239241
if (await ruff.enabled()) return false
240242
if (Bun.which("uv") !== null) {
241-
const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
243+
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
242244
const code = await proc.exited
243245
return code === 0
244246
}

packages/opencode/src/format/index.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
88
import { Config } from "../config/config"
99
import { mergeDeep } from "remeda"
1010
import { Instance } from "../project/instance"
11+
import { Process } from "../util/process"
1112

1213
export namespace Format {
1314
const log = Log.create({ service: "format" })
@@ -110,13 +111,15 @@ export namespace Format {
110111
for (const item of await getFormatter(ext)) {
111112
log.info("running", { command: item.command })
112113
try {
113-
const proc = Bun.spawn({
114-
cmd: item.command.map((x) => x.replace("$FILE", file)),
115-
cwd: Instance.directory,
116-
env: { ...process.env, ...item.environment },
117-
stdout: "ignore",
118-
stderr: "ignore",
119-
})
114+
const proc = Process.spawn(
115+
item.command.map((x) => x.replace("$FILE", file)),
116+
{
117+
cwd: Instance.directory,
118+
env: { ...process.env, ...item.environment },
119+
stdout: "ignore",
120+
stderr: "ignore",
121+
},
122+
)
120123
const exit = await proc.exited
121124
if (exit !== 0)
122125
log.error("failed", {

0 commit comments

Comments
 (0)