Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions plugins/opencode/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P
}
chunks.push(chunk)
})
// The background host's stderr may have no reader. Forwarding to it can
// pause this stream forever, preventing the child's close event.
// The background host's stderr may have no reader, so drain it here rather
// than forwarding it there.
child.stderr.on("data", (chunk: Buffer) => {
tail = Buffer.concat([tail, chunk]).subarray(-8 * 1024)
})
Expand All @@ -76,8 +76,9 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P
}
child.on("error", (error) => finish(() => reject(error)))
child.stdin.on("error", (error) => finish(() => reject(error)))
child.on("close", (code) => {
const complete = (code: number | null, killed: NodeJS.Signals | null) => {
if (settled) return
if (killed) return finish(() => reject(new Error(describe(`Rift was terminated by ${killed}`))))
if (code !== 0) return finish(() => reject(new Error(describe(`Rift exited with status ${code}`))))
const invalid = () => finish(() => reject(new Error(describe("Rift returned an invalid RPC response"))))
let response: unknown
Expand All @@ -93,7 +94,24 @@ export function rpc(executable: string, request: object, signal: AbortSignal): P
return finish(() => reject(new RpcError({ ...failure, message: describe(failure.message) })))
}
invalid()
}
// Processes started by hooks inherit stderr and can hold it open long after
// Rift exits, so close may never fire. Once Rift has exited and stdout has
// ended, give stderr a moment to flush and settle without waiting for close.
let exited: [number | null, NodeJS.Signals | null] | undefined
let ended = false
const drained = () => {
if (exited && ended) setTimeout(complete, 100, ...exited).unref()
}
child.on("exit", (code, killed) => {
exited = [code, killed]
drained()
})
child.stdout.on("end", () => {
ended = true
drained()
})
child.on("close", complete)
child.stdin.end(JSON.stringify(request))
})
}
46 changes: 45 additions & 1 deletion plugins/opencode/test/command.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { spawn } from "node:child_process"
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
Expand Down Expand Up @@ -110,3 +110,47 @@ process.stdout.write(${JSON.stringify(JSON.stringify({ status: "error", error: f
await rm(temp, { recursive: true, force: true })
}
})

test("RPCs finish when hook processes keep stderr open", async () => {
const temp = await mkdtemp(join(tmpdir(), "opencode-rift-rpc-"))
const executable = join(temp, "rift")
const pidfile = join(temp, "daemon.pid")
await writeFile(
executable,
`#!/usr/bin/env node
const { spawn } = require("node:child_process")
const daemon = spawn("sleep", ["30"], { stdio: ["ignore", "ignore", "inherit"] })
require("node:fs").writeFileSync(${JSON.stringify(pidfile)}, String(daemon.pid))
daemon.unref()
process.stderr.write("compose: port 5432 in use\\n", () => {
process.stdout.write(${JSON.stringify(JSON.stringify({ status: "error", error: { code: "hook_failed", message: "postcreate hook failed" } }))})
})
`,
)
await chmod(executable, 0o755)
try {
await expect(rpc(executable, {}, AbortSignal.timeout(3000))).rejects.toThrow(
"postcreate hook failed: compose: port 5432 in use",
)
} finally {
process.kill(Number(await readFile(pidfile, "utf8")))
await rm(temp, { recursive: true, force: true })
}
})

test("signal deaths name the signal", async () => {
const temp = await mkdtemp(join(tmpdir(), "opencode-rift-rpc-"))
const executable = join(temp, "rift")
await writeFile(
executable,
'#!/usr/bin/env node\nprocess.stderr.write("fatal runtime error\\n", () => process.kill(process.pid, "SIGKILL"))\n',
)
await chmod(executable, 0o755)
try {
await expect(rpc(executable, {}, AbortSignal.timeout(3000))).rejects.toThrow(
"Rift was terminated by SIGKILL: fatal runtime error",
)
} finally {
await rm(temp, { recursive: true, force: true })
}
})
Loading