| author | |
| committer | |
| log | 35822884d19ba64c6529d7736fb0182b361f3e4c |
| tree | a4b7ddb03702731a8560ef1a804164911ed28ce1 |
| parent | dbebc387dd458dd7062380ccb862a5cdac7aba66 |
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:desktop2 files changed, 44 insertions(+), 4 deletions(-)
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+24-4| ... | ... | @@ -23,6 +23,7 @@ const runtimeMock = { |
| 23 | 23 | runVersionError: null as Error | null, |
| 24 | 24 | versionStdout: DEFAULT_VERSION_STDOUT, |
| 25 | 25 | inventoryError: null as Error | null, |
| 26 | closeCalls: 0, | |
| 26 | 27 | inventory: { |
| 27 | 28 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, |
| 28 | 29 | agents: [] as unknown[], |
| ... | ... | @@ -32,6 +33,7 @@ const runtimeMock = { |
| 32 | 33 | this.state.runVersionError = null; |
| 33 | 34 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; |
| 34 | 35 | this.state.inventoryError = null; |
| 36 | this.state.closeCalls = 0; | |
| 35 | 37 | this.state.inventory = { |
| 36 | 38 | providerList: { connected: [], all: [] as unknown[], default: {} }, |
| 37 | 39 | agents: [] as unknown[], |
| ... | ... | @@ -46,10 +48,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 46 | 48 | exitCode: Effect.never, |
| 47 | 49 | }), |
| 48 | 50 | 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 | }; | |
| 53 | 64 | }), |
| 54 | 65 | runOpenCodeCommand: () => |
| 55 | 66 | runtimeMock.state.runVersionError |
| ... | ... | @@ -188,6 +199,15 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { |
| 188 | 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 | }); |
| 192 | 212 | |
| 193 | 213 | it.layer( |
apps/server/src/provider/opencodeRuntime.ts+20| ... | ... | @@ -330,6 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 330 | 330 | const child = yield* spawner |
| 331 | 331 | .spawn( |
| 332 | 332 | ChildProcess.make(input.binaryPath, args, { |
| 333 | detached: process.platform !== "win32", | |
| 333 | 334 | env: { |
| 334 | 335 | ...process.env, |
| 335 | 336 | OPENCODE_CONFIG_CONTENT: JSON.stringify({}), |
| ... | ... | @@ -348,6 +349,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 348 | 349 | ), |
| 349 | 350 | ); |
| 350 | 351 | |
| 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 | 371 | const stdoutRef = yield* Ref.make(""); |
| 352 | 372 | const stderrRef = yield* Ref.make(""); |
| 353 | 373 | const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>(); |