From 4116db9807cb37b49d3db35c7a45301f5e851b01 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:12:12 +0530 Subject: [PATCH] fix(server): bound OpenCode version probes (#8750) --- .../provider/Layers/OpenCodeProvider.test.ts | 42 +++++++++++++++---- .../src/provider/Layers/OpenCodeProvider.ts | 12 +++++- apps/server/src/provider/opencodeRuntime.ts | 12 ++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index d81d66574db56d354fcc2d6770664972f670ac90..0c0bf0c28801b63f26c2cd31d2039d4b0ed92fc9 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; @@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; const runtimeMock = { state: { runVersionError: null as Error | null, + runVersionPending: false, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, connectionError: null as Error | null, @@ -52,6 +55,7 @@ const runtimeMock = { }, reset() { this.state.runVersionError = null; + this.state.runVersionPending = false; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.connectionError = null; @@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }; }), runOpenCodeCommand: () => - runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + runtimeMock.state.runVersionPending + ? Effect.never + : runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), createOpenCodeSdkClient: (input) => { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; @@ -218,6 +224,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("times out a hanging local CLI version probe", () => + Effect.gen(function* () { + runtimeMock.state.runVersionPending = true; + const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("4 seconds"); + const snapshot = yield* Fiber.join(probeFiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index ed935fb314022b2ce568be435ae93cfce2491fc0..a094fe8601a2a899f73417bccb47fad65990eaa4 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -31,9 +31,10 @@ const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; +const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; + readonly cause?: unknown; readonly detail: string; }> {} @@ -401,6 +402,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu Effect.mapError( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), + Effect.timeoutOrElse({ + duration: OPENCODE_VERSION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new OpenCodeProbeError({ + detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, + }), + ), + }), ), ); if (versionExit._tag === "Failure") { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index f34e28504b3a6d560a49f47f3db8db52767a7b42..afd806e5666e96131cce8b44dbf8dc0aa110f9a3 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -563,11 +563,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", shell: spawnCommand.shell, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const terminateCommandGroup = + hostPlatform === "win32" + ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), "SIGKILL"); + } catch { + // The command and its process group may already have exited. + } + }); + yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); const collectOptions = input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( -- 2.54.0