From 5d1c8718d7e6daef76fd52e04e6eca0f9513f880 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:24:42 +0900 Subject: [PATCH] fix(installer): bound outputPromise wait after kill to prevent indirect hang After SIGTERM/SIGKILL escalation, the stdout stream may not close immediately on all platforms. The unconditional await on outputPromise could then hang indefinitely, defeating the bounded process lifetime guarantee. Race outputPromise against a short follow-up timeout to ensure getOpenCodeVersion always returns within a bounded time. Refs #3766 --- .../config-manager/opencode-binary.test.ts | 41 ++++++++++++++++++- src/cli/config-manager/opencode-binary.ts | 5 ++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index ec7b05f35..99113da9a 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -10,7 +10,11 @@ type OpenCodeBinaryModule = typeof import("./opencode-binary") type CreateProcOptions = { exitCode?: number | null exited?: Promise - output?: { stdout?: string; stderr?: string } + output?: { + stdout?: string + stdoutStream?: ReadableStream + stderr?: string + } kill?: (signal?: NodeJS.Signals) => void } @@ -19,7 +23,9 @@ function createProc(options: CreateProcOptions = {}): ReturnType {}), } satisfies ReturnType @@ -103,6 +109,37 @@ describe("getOpenCodeVersion (installer)", () => { }) }) + describe("#given never-closing stdout after kill #when getOpenCodeVersion #then returns within bounded time", () => { + it("bounds outputPromise wait and returns null", async () => { + const neverClosingStdout = new ReadableStream({ + start() { + // Intentionally never closing to simulate a hung stdout stream. + }, + }) + spawnSpy.mockReturnValue( + createProc({ + exited: new Promise(() => {}), + output: { stdoutStream: neverClosingStdout }, + kill: () => {}, + }), + ) + + const immediateSetTimeout = ((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 1 as unknown as ReturnType + }) as unknown as typeof globalThis.setTimeout + const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout) + + const result = await getOpenCodeVersion() + + expect(result).toBe(null) + + setTimeoutSpy.mockRestore() + }) + }) + describe("#given quick successful exit #when getOpenCodeVersion #then clears the watchdog timer", () => { it("avoids timer leak after success", async () => { spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } })) diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index 4c64205cf..ff650da3c 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -6,6 +6,7 @@ import { initConfigContext } from "./config-context" const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const const OPENCODE_VERSION_CHECK_TIMEOUT_MS = 1500 const OPENCODE_VERSION_KILL_GRACE_MS = 200 +const OPENCODE_OUTPUT_WAIT_TIMEOUT_MS = 200 interface OpenCodeBinaryResult { binary: OpenCodeBinaryType @@ -44,9 +45,9 @@ async function findOpenCodeBinaryWithVersion(): Promise((resolve) => { setTimeout(() => { resolve("") - }, OPENCODE_VERSION_KILL_GRACE_MS) + }, OPENCODE_OUTPUT_WAIT_TIMEOUT_MS) }), - ]) + ]).catch(() => "") if (timedExitCode === 0 && proc.exitCode === 0) { const version = extractSemverFromOutput(output) ?? output.trim()