| 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,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.runVersionError | 66 | 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 | }); |
| 192 | 212 | ||
| 193 | it.layer( | 213 | it.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* spawner | 330 | 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 | ); |
| 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 | 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>(); |