authorgravatar for 153842990+yashranaway@users.noreply.github.comAditya Garud <153842990+yashranaway@users.noreply.github.com> 2026-09-02 13:12:12+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 00:42:12-07:00
log4116db9807cb37b49d3db35c7a45301f5e851b01
tree1159ba891539e43a604ce3927f388696b56940b7
parentd2042d288eaf7aaf7feda85eb7d1a6f546302c12
signature Signed by PGP key B5690EEEBB952194

fix(server): bound OpenCode version probes (#8750)


3 files changed, 56 insertions(+), 10 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts+33-9
...@@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict";...@@ -3,8 +3,10 @@ import * as NodeAssert from "node:assert/strict";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
8import { beforeEach } from "vite-plus/test";10import { beforeEach } from "vite-plus/test";
911
10import { OpenCodeSettings } from "@t3tools/contracts";12import { OpenCodeSettings } from "@t3tools/contracts";
...@@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";...@@ -34,6 +36,7 @@ const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
34const runtimeMock = {36const runtimeMock = {
35 state: {37 state: {
36 runVersionError: null as Error | null,38 runVersionError: null as Error | null,
39 runVersionPending: false,
37 versionStdout: DEFAULT_VERSION_STDOUT,40 versionStdout: DEFAULT_VERSION_STDOUT,
38 inventoryError: null as Error | null,41 inventoryError: null as Error | null,
39 connectionError: null as Error | null,42 connectionError: null as Error | null,
...@@ -52,6 +55,7 @@ const runtimeMock = {...@@ -52,6 +55,7 @@ const runtimeMock = {
52 },55 },
53 reset() {56 reset() {
54 this.state.runVersionError = null;57 this.state.runVersionError = null;
58 this.state.runVersionPending = false;
55 this.state.versionStdout = DEFAULT_VERSION_STDOUT;59 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
56 this.state.inventoryError = null;60 this.state.inventoryError = null;
57 this.state.connectionError = null;61 this.state.connectionError = null;
...@@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -114,15 +118,17 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
114 };118 };
115 }),119 }),
116 runOpenCodeCommand: () =>120 runOpenCodeCommand: () =>
117 runtimeMock.state.runVersionError121 runtimeMock.state.runVersionPending
118 ? Effect.fail(122 ? Effect.never
119 new OpenCodeRuntimeError({123 : runtimeMock.state.runVersionError
120 operation: "runOpenCodeCommand",124 ? Effect.fail(
121 detail: runtimeMock.state.runVersionError.message,125 new OpenCodeRuntimeError({
122 cause: runtimeMock.state.runVersionError,126 operation: "runOpenCodeCommand",
123 }),127 detail: runtimeMock.state.runVersionError.message,
124 )128 cause: runtimeMock.state.runVersionError,
125 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),129 }),
130 )
131 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
126 createOpenCodeSdkClient: (input) => {132 createOpenCodeSdkClient: (input) => {
127 runtimeMock.state.sdkClientInputs.push(input);133 runtimeMock.state.sdkClientInputs.push(input);
128 return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>;134 return {} as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>;
...@@ -218,6 +224,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {...@@ -218,6 +224,24 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
218 }),224 }),
219 );225 );
220226
227 it.effect("times out a hanging local CLI version probe", () =>
228 Effect.gen(function* () {
229 runtimeMock.state.runVersionPending = true;
230 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
231
232 yield* Effect.yieldNow;
233 yield* TestClock.adjust("4 seconds");
234 const snapshot = yield* Fiber.join(probeFiber);
235
236 NodeAssert.equal(snapshot.status, "error");
237 NodeAssert.equal(snapshot.installed, true);
238 NodeAssert.equal(
239 snapshot.message,
240 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
241 );
242 }).pipe(Effect.provide(TestClock.layer())),
243 );
244
221 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>245 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
222 Effect.gen(function* () {246 Effect.gen(function* () {
223 runtimeMock.state.inventory = {247 runtimeMock.state.inventory = {
apps/server/src/provider/Layers/OpenCodeProvider.ts+11-1
...@@ -31,9 +31,10 @@ const OPENCODE_PRESENTATION = {...@@ -31,9 +31,10 @@ const OPENCODE_PRESENTATION = {
31 displayName: "OpenCode",31 displayName: "OpenCode",
32 showInteractionModeToggle: false,32 showInteractionModeToggle: false,
33} as const;33} as const;
34const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds";
3435
35class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{36class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
36 readonly cause: unknown;37 readonly cause?: unknown;
37 readonly detail: string;38 readonly detail: string;
38}> {}39}> {}
3940
...@@ -401,6 +402,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -401,6 +402,15 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
401 Effect.mapError(402 Effect.mapError(
402 (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),403 (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
403 ),404 ),
405 Effect.timeoutOrElse({
406 duration: OPENCODE_VERSION_PROBE_TIMEOUT,
407 orElse: () =>
408 Effect.fail(
409 new OpenCodeProbeError({
410 detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`,
411 }),
412 ),
413 }),
404 ),414 ),
405 );415 );
406 if (versionExit._tag === "Failure") {416 if (versionExit._tag === "Failure") {
apps/server/src/provider/opencodeRuntime.ts+12
...@@ -563,11 +563,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -563,11 +563,23 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
563 const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);563 const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);
564 const child = yield* spawner.spawn(564 const child = yield* spawner.spawn(
565 ChildProcess.make(spawnCommand.command, spawnCommand.args, {565 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
566 detached: hostPlatform !== "win32",
566 shell: spawnCommand.shell,567 shell: spawnCommand.shell,
567 ...(input.cwd ? { cwd: input.cwd } : {}),568 ...(input.cwd ? { cwd: input.cwd } : {}),
568 ...(input.environment ? { env: input.environment } : { extendEnv: true }),569 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
569 }),570 }),
570 );571 );
572 const terminateCommandGroup =
573 hostPlatform === "win32"
574 ? child.kill({ killSignal: "SIGKILL" }).pipe(Effect.asVoid)
575 : Effect.sync(() => {
576 try {
577 process.kill(-Number(child.pid), "SIGKILL");
578 } catch {
579 // The command and its process group may already have exited.
580 }
581 });
582 yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore));
571 const collectOptions =583 const collectOptions =
572 input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };584 input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };
573 const [stdout, stderr, code] = yield* Effect.all(585 const [stdout, stderr, code] = yield* Effect.all(