From 9803eded6cfc2a4d1d80ec189680c276659534b2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 5 Oct 2026 12:33:08 +0530 Subject: [PATCH 1/2] test(opencode): cover lingering hook processes and signal deaths --- plugins/opencode/test/command.test.ts | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/plugins/opencode/test/command.test.ts b/plugins/opencode/test/command.test.ts index d643ba1..67c3313 100644 --- a/plugins/opencode/test/command.test.ts +++ b/plugins/opencode/test/command.test.ts @@ -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" @@ -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 }) + } +}) From fe7ae437929f35bcb37471bbe5be1b17d2c54bc1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 5 Oct 2026 12:34:15 +0530 Subject: [PATCH 2/2] fix(opencode): settle RPCs when Rift exits, not when stderr closes --- plugins/opencode/src/command.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/plugins/opencode/src/command.ts b/plugins/opencode/src/command.ts index a77d9dc..e9668fb 100644 --- a/plugins/opencode/src/command.ts +++ b/plugins/opencode/src/command.ts @@ -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) }) @@ -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 @@ -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)) }) }