Skip to content

Commit 1feaf81

Browse files
joshmeadstyulyukov
authored andcommitted
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
1 parent 7f04a4a commit 1feaf81

2 files changed

Lines changed: 44 additions & 4 deletions

File tree

apps/server/src/provider/Layers/OpenCodeProvider.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const runtimeMock = {
2323
runVersionError: null as Error | null,
2424
versionStdout: DEFAULT_VERSION_STDOUT,
2525
inventoryError: null as Error | null,
26+
closeCalls: 0,
2627
inventory: {
2728
providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
2829
agents: [] as unknown[],
@@ -32,6 +33,7 @@ const runtimeMock = {
3233
this.state.runVersionError = null;
3334
this.state.versionStdout = DEFAULT_VERSION_STDOUT;
3435
this.state.inventoryError = null;
36+
this.state.closeCalls = 0;
3537
this.state.inventory = {
3638
providerList: { connected: [], all: [] as unknown[], default: {} },
3739
agents: [] as unknown[],
@@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
4648
exitCode: Effect.never,
4749
}),
4850
connectToOpenCodeServer: ({ serverUrl }) =>
49-
Effect.succeed({
50-
url: serverUrl ?? "http://127.0.0.1:4301",
51-
exitCode: null,
52-
external: Boolean(serverUrl),
51+
Effect.gen(function* () {
52+
if (!serverUrl) {
53+
yield* Effect.addFinalizer(() =>
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+
};
5364
}),
5465
runOpenCodeCommand: () =>
5566
runtimeMock.state.runVersionError
@@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
188199
assert.equal(agentDescriptor.options.find((option) => option.isDefault)?.id, "build");
189200
}),
190201
);
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+
);
191211
});
192212

193213
it.layer(

apps/server/src/provider/opencodeRuntime.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
330330
const child = yield* spawner
331331
.spawn(
332332
ChildProcess.make(input.binaryPath, args, {
333+
detached: process.platform !== "win32",
333334
env: {
334335
...process.env,
335336
OPENCODE_CONFIG_CONTENT: JSON.stringify({}),
@@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
348349
),
349350
);
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+
351371
const stdoutRef = yield* Ref.make("");
352372
const stderrRef = yield* Ref.make("");
353373
const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();

0 commit comments

Comments
 (0)