authorgravatar for 8870827+joshmeads@users.noreply.github.comJosh Meads <8870827+joshmeads@users.noreply.github.com> 2026-04-26 17:13:04-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-04-29 11:02:16-07:00
log35822884d19ba64c6529d7736fb0182b361f3e4c
treea4b7ddb03702731a8560ef1a804164911ed28ce1
parentdbebc387dd458dd7062380ccb862a5cdac7aba66

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

2 files changed, 44 insertions(+), 4 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts+24-4
...@@ -23,6 +23,7 @@ const runtimeMock = {...@@ -23,6 +23,7 @@ const runtimeMock = {
23 runVersionError: null as Error | null,23 runVersionError: null as Error | null,
24 versionStdout: DEFAULT_VERSION_STDOUT,24 versionStdout: DEFAULT_VERSION_STDOUT,
25 inventoryError: null as Error | null,25 inventoryError: null as Error | null,
26 closeCalls: 0,
26 inventory: {27 inventory: {
27 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },28 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
28 agents: [] as unknown[],29 agents: [] as unknown[],
...@@ -32,6 +33,7 @@ const runtimeMock = {...@@ -32,6 +33,7 @@ const runtimeMock = {
32 this.state.runVersionError = null;33 this.state.runVersionError = null;
33 this.state.versionStdout = DEFAULT_VERSION_STDOUT;34 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
34 this.state.inventoryError = null;35 this.state.inventoryError = null;
36 this.state.closeCalls = 0;
35 this.state.inventory = {37 this.state.inventory = {
36 providerList: { connected: [], all: [] as unknown[], default: {} },38 providerList: { connected: [], all: [] as unknown[], default: {} },
37 agents: [] as unknown[],39 agents: [] as unknown[],
...@@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
46 exitCode: Effect.never,48 exitCode: Effect.never,
47 }),49 }),
48 connectToOpenCodeServer: ({ serverUrl }) =>50 connectToOpenCodeServer: ({ serverUrl }) =>
49 Effect.succeed({51 Effect.gen(function* () {
50 url: serverUrl ?? "http://127.0.0.1:4301",52 if (!serverUrl) {
51 exitCode: null,53 yield* Effect.addFinalizer(() =>
52 external: Boolean(serverUrl),54 Effect.sync(() => {
55 runtimeMock.state.closeCalls += 1;
56 }),
57 );
58 }
59 return {
60 url: serverUrl ?? "http://127.0.0.1:4301",
61 exitCode: null,
62 external: Boolean(serverUrl),
63 };
53 }),64 }),
54 runOpenCodeCommand: () =>65 runOpenCodeCommand: () =>
55 runtimeMock.state.runVersionError66 runtimeMock.state.runVersionError
...@@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {...@@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
188 assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build");199 assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build");
189 }),200 }),
190 );201 );
202
203 it.effect("closes the local OpenCode server scope after provider refresh", () =>
204 Effect.gen(function* () {
205 const provider = yield* OpenCodeProvider;
206 yield* provider.refresh;
207
208 assert.equal(runtimeMock.state.closeCalls, 1);
209 }),
210 );
191});211});
192212
193it.layer(213it.layer(
apps/server/src/provider/opencodeRuntime.ts+20
...@@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
330 const child = yield* spawner330 const child = yield* spawner
331 .spawn(331 .spawn(
332 ChildProcess.make(input.binaryPath, args, {332 ChildProcess.make(input.binaryPath, args, {
333 detached: process.platform !== "win32",
333 env: {334 env: {
334 ...process.env,335 ...process.env,
335 OPENCODE_CONFIG_CONTENT: JSON.stringify({}),336 OPENCODE_CONFIG_CONTENT: JSON.stringify({}),
...@@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
348 ),349 ),
349 );350 );
350351
352 const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>
353 process.platform === "win32"
354 ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)
355 : Effect.sync(() => {
356 try {
357 process.kill(-Number(child.pid), signal);
358 } catch {
359 // The direct child may already have exited after starting the
360 // server; the process group kill is best-effort cleanup for
361 // any serve process left in that group.
362 }
363 });
364 const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(
365 Effect.andThen(Effect.sleep("1 second")),
366 Effect.andThen(killOpenCodeProcessGroup("SIGKILL")),
367 Effect.ignore,
368 );
369 yield* Scope.addFinalizer(runtimeScope, terminateChild);
370
351 const stdoutRef = yield* Ref.make("");371 const stdoutRef = yield* Ref.make("");
352 const stderrRef = yield* Ref.make("");372 const stderrRef = yield* Ref.make("");
353 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();373 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();