From 71199bbb77822d4a71d526907059adbf3828d175 Mon Sep 17 00:00:00 2001 From: Josh Meads <8870827+joshmeads@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:13:04 -0700 Subject: [PATCH] Stop OpenCode refresh from leaking serve processes OpenCode can leave the long-lived serve child in the spawned process group after the wrapper exits. Provider refresh owns only a scoped inventory probe, so cleanup targets that local process group when the refresh scope closes. Constraint: Keep active OpenCode thread sessions scoped to their own session lifecycle. Rejected: Disable provider refresh | would hide model and auth state changes. Confidence: high Scope-risk: narrow Tested: bun run test src/provider/Layers/OpenCodeProvider.test.ts src/provider/Layers/OpenCodeAdapter.test.ts Tested: bun fmt Tested: bun lint Tested: bun typecheck Tested: bun run build:desktop --- .../provider/Layers/OpenCodeProvider.test.ts | 28 ++++++++++++++++--- apps/server/src/provider/opencodeRuntime.ts | 20 +++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index f32fd6f49e2..75622281c59 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -23,6 +23,7 @@ const runtimeMock = { runVersionError: null as Error | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, + closeCalls: 0, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -32,6 +33,7 @@ const runtimeMock = { this.state.runVersionError = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; + this.state.closeCalls = 0; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }), connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), + Effect.gen(function* () { + if (!serverUrl) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + } + return { + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + }; }), runOpenCodeCommand: () => runtimeMock.state.runVersionError @@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build"); }), ); + + it.effect("closes the local OpenCode server scope after provider refresh", () => + Effect.gen(function* () { + const provider = yield* OpenCodeProvider; + yield* provider.refresh; + + assert.equal(runtimeMock.state.closeCalls, 1); + }), + ); }); it.layer( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 41ec5102c3c..086584893c1 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(input.binaryPath, args, { + detached: process.platform !== "win32", env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({}), @@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); + const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => + process.platform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), signal); + } catch { + // The direct child may already have exited after starting the + // server; the process group kill is best-effort cleanup for + // any serve process left in that group. + } + }); + const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), + Effect.ignore, + ); + yield* Scope.addFinalizer(runtimeScope, terminateChild); + const stdoutRef = yield* Ref.make(""); const stderrRef = yield* Ref.make(""); const readyDeferred = yield* Deferred.make();