feat(shared): add Node-safe process stream reader and search output collector

Introduces:
- src/shared/process-stream-reader.ts: Buffer-concat stream reader compatible with both Bun and Node ChildProcess stdout (replaces Web Response API usage)
- src/tools/shared/search-process-output.ts: structured subprocess output collector with timeout, kill, and rejection cleanup
- bun-spawn-shim hardened: Node path forces windowsHide: true; spawn errors no longer escape as unhandledRejection

Foundation for #3919 fix.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-22 20:39:52 +09:00
parent b31ad3c892
commit 4ea7562f50
4 changed files with 227 additions and 24 deletions
+40 -4
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Readable } from "node:stream"
import { spawn, spawnSync } from "./bun-spawn-shim"
import { createNodeSpawnOptions, createNodeSpawnSyncOptions, spawn, spawnSync } from "./bun-spawn-shim"
import { readProcessStream } from "./process-stream-reader"
describe("bun-spawn-shim", () => {
test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => {
@@ -17,7 +19,7 @@ describe("bun-spawn-shim", () => {
const [exitCode, stdout] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
readProcessStream(proc.stdout),
])
expect(exitCode).toBe(0)
@@ -48,7 +50,7 @@ describe("bun-spawn-shim", () => {
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stdout = await readProcessStream(proc.stdout)
expect(exitCode).toBe(0)
expect(stdout).toBe("")
@@ -60,7 +62,7 @@ describe("bun-spawn-shim", () => {
expect(result.exitCode).toBe(0)
expect(result.success).toBe(true)
expect(result.stdout).toBeDefined()
expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok")
expect(result.stdout?.toString().trim()).toBe("sync-ok")
})
test("#given spawnSync command #when it completes #then result.pid is a positive number", () => {
@@ -88,4 +90,38 @@ describe("bun-spawn-shim", () => {
expect(observedError).toBeDefined()
})
test("#given Windows platform #when building Node spawn options #then windowsHide is enabled", () => {
const options = createNodeSpawnOptions({ stdout: "pipe", stderr: "pipe" }, "win32")
expect(options.windowsHide).toBe(true)
expect(options.shell).toBe(false)
})
test("#given Windows platform #when building Node spawnSync options #then windowsHide is enabled", () => {
const options = createNodeSpawnSyncOptions({ stdout: "pipe", stderr: "pipe" }, "win32")
expect(options.windowsHide).toBe(true)
expect(options.shell).toBe(false)
})
test("#given Node readable output #when reading in a non-Bun host shape #then Buffer-concat returns text", async () => {
const stream = Readable.from([Buffer.from("node-stream-ok\n")])
const output = await readProcessStream(stream)
expect(output).toBe("node-stream-ok\n")
})
test("#given empty process stream #when reading process output #then returns an empty string", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.close()
},
})
const output = await readProcessStream(stream)
expect(output).toBe("")
})
})