From 7464d7e00531fc2eaf548d74e1e15a28602b0e3f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 15:07:23 +0900 Subject: [PATCH 1/4] fix(installer): timeout opencode --version probe to avoid Desktop binary hang When the binary resolved as 'opencode' on PATH is the OpenCode Desktop GUI (not the CLI), it does not respond to --version with prompt exit. proc.exited then waits forever, freezing the installer at 'Checking OpenCode installation'. Fix: race proc.exited against OPENCODE_VERSION_CHECK_TIMEOUT_MS=1500. On timeout, proc.kill() and treat the binary as failed so the next candidate is tried. Success requires both timedExitCode === 0 and proc.exitCode === 0. Fixes #3766 --- .../config-manager/opencode-binary.test.ts | 50 ++++++++++++++++++- src/cli/config-manager/opencode-binary.ts | 35 +++++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index b298c1027..fb64de7ed 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -9,17 +9,19 @@ type OpenCodeBinaryModule = typeof import("./opencode-binary") type CreateProcOptions = { exitCode?: number | null + exited?: Promise output?: { stdout?: string; stderr?: string } + kill?: (signal?: NodeJS.Signals) => void } function createProc(options: CreateProcOptions = {}): ReturnType { const exitCode = options.exitCode ?? 0 return { - exited: Promise.resolve(exitCode), + exited: options.exited ?? Promise.resolve(exitCode), exitCode, stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined, stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined, - kill: () => {}, + kill: options.kill ?? (() => {}), } satisfies ReturnType } @@ -71,6 +73,50 @@ describe("getOpenCodeVersion (installer)", () => { }) }) + describe("#given timeout path #when getOpenCodeVersion #then sends SIGTERM and SIGKILL and returns null without hanging", () => { + it("bounds process lifetime on hung --version", async () => { + const killCalls: Array = [] + spawnSpy.mockReturnValue( + createProc({ + exited: new Promise(() => {}), + output: { stdout: "" }, + kill: (signal?: NodeJS.Signals) => { + killCalls.push(signal) + }, + }), + ) + + const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { + if (typeof handler === "function") { + handler() + } + return 1 as unknown as ReturnType + }) + + const result = await getOpenCodeVersion() + + expect(result).toBe(null) + expect(killCalls).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]) + + 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" } })) + + const clearTimeoutSpy = spyOn(globalThis, "clearTimeout") + + const result = await getOpenCodeVersion() + + expect(result).toBe("1.14.33") + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + + clearTimeoutSpy.mockRestore() + }) + }) + describe("#given no opencode binary on PATH #when getOpenCodeVersion #then returns null", () => { it("all candidate spawns throw", async () => { spawnSpy.mockImplementation(() => { diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index d5256a0b0..4c64205cf 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -4,6 +4,8 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" 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 interface OpenCodeBinaryResult { binary: OpenCodeBinaryType @@ -17,9 +19,36 @@ async function findOpenCodeBinaryWithVersion(): Promise | null = null + const timedExitCode = await Promise.race([ + proc.exited, + new Promise((resolve) => { + killTimer = setTimeout(() => { + proc.kill("SIGTERM") + setTimeout(() => { + proc.kill("SIGKILL") + }, OPENCODE_VERSION_KILL_GRACE_MS) + resolve(1) + }, OPENCODE_VERSION_CHECK_TIMEOUT_MS) + }), + ]) + + if (killTimer) { + clearTimeout(killTimer) + } + + const output = await Promise.race([ + outputPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve("") + }, OPENCODE_VERSION_KILL_GRACE_MS) + }), + ]) + + if (timedExitCode === 0 && proc.exitCode === 0) { const version = extractSemverFromOutput(output) ?? output.trim() initConfigContext(binary, version) return { binary, version } From 84073897c6bccb3a8d4235bec96bc874b18861b7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:50:59 +0900 Subject: [PATCH 2/4] test(installer): stabilize timeout signal escalation assertion --- src/cli/config-manager/opencode-binary.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index fb64de7ed..ec7b05f35 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -86,17 +86,18 @@ describe("getOpenCodeVersion (installer)", () => { }), ) - const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { + 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) - expect(killCalls).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]) + expect(killCalls).toEqual(["SIGTERM", "SIGKILL"]) setTimeoutSpy.mockRestore() }) From 5d1c8718d7e6daef76fd52e04e6eca0f9513f880 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:24:42 +0900 Subject: [PATCH 3/4] 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() From f983b6b19e4b378d8fa720fbcd65ac314c3b3235 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 09:30:33 +0900 Subject: [PATCH 4/4] fix(installer): avoid empty-version success on delayed stdout --- .../config-manager/opencode-binary.test.ts | 6 +-- src/cli/config-manager/opencode-binary.ts | 48 ++++++++++++++----- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts index 99113da9a..27171db5b 100644 --- a/src/cli/config-manager/opencode-binary.test.ts +++ b/src/cli/config-manager/opencode-binary.test.ts @@ -140,8 +140,8 @@ describe("getOpenCodeVersion (installer)", () => { }) }) - describe("#given quick successful exit #when getOpenCodeVersion #then clears the watchdog timer", () => { - it("avoids timer leak after success", async () => { + describe("#given quick successful exit #when getOpenCodeVersion #then clears active timers", () => { + it("avoids timer leaks after success", async () => { spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } })) const clearTimeoutSpy = spyOn(globalThis, "clearTimeout") @@ -149,7 +149,7 @@ describe("getOpenCodeVersion (installer)", () => { const result = await getOpenCodeVersion() expect(result).toBe("1.14.33") - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + expect(clearTimeoutSpy).toHaveBeenCalledTimes(2) clearTimeoutSpy.mockRestore() }) diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts index ff650da3c..79e4ee542 100644 --- a/src/cli/config-manager/opencode-binary.ts +++ b/src/cli/config-manager/opencode-binary.ts @@ -23,15 +23,16 @@ async function findOpenCodeBinaryWithVersion(): Promise | null = null - const timedExitCode = await Promise.race([ - proc.exited, - new Promise((resolve) => { + let killGraceTimer: ReturnType | null = null + const timedExitResult = await Promise.race([ + proc.exited.then((exitCode) => ({ type: "exit" as const, exitCode })), + new Promise<{ type: "timeout" }>((resolve) => { killTimer = setTimeout(() => { proc.kill("SIGTERM") - setTimeout(() => { + killGraceTimer = setTimeout(() => { proc.kill("SIGKILL") }, OPENCODE_VERSION_KILL_GRACE_MS) - resolve(1) + resolve({ type: "timeout" }) }, OPENCODE_VERSION_CHECK_TIMEOUT_MS) }), ]) @@ -40,17 +41,40 @@ async function findOpenCodeBinaryWithVersion(): Promise((resolve) => { - setTimeout(() => { - resolve("") + if (timedExitResult.type === "timeout") { + void outputPromise.catch(() => {}) + continue + } + + if (killGraceTimer) { + clearTimeout(killGraceTimer) + } + + let outputTimer: ReturnType | null = null + const outputResult = await Promise.race([ + outputPromise.then((output) => ({ type: "output" as const, output })), + new Promise<{ type: "timeout" }>((resolve) => { + outputTimer = setTimeout(() => { + resolve({ type: "timeout" }) }, OPENCODE_OUTPUT_WAIT_TIMEOUT_MS) }), - ]).catch(() => "") + ]).catch(() => ({ type: "timeout" as const })) - if (timedExitCode === 0 && proc.exitCode === 0) { + if (outputTimer) { + clearTimeout(outputTimer) + } + + if (outputResult.type !== "output") { + continue + } + + if (timedExitResult.exitCode === 0 && proc.exitCode === 0) { + const output = outputResult.output const version = extractSemverFromOutput(output) ?? output.trim() + if (version.length === 0) { + continue + } + initConfigContext(binary, version) return { binary, version } }