From 0ed6011d8ac281081999036bbecd85b483f25d4e Mon Sep 17 00:00:00 2001 From: Nathan Ollerenshaw Date: Fri, 14 Aug 2026 10:31:32 -0700 Subject: [PATCH] fix(opencode): defer config reload until sessions are idle SIGUSR2 asks the TUI worker to reload config, which disposes every instance. Instance disposal cancels the session runners that instance owns, so a signal that lands while the model is streaming interrupts the run. Desktop environments send this signal on theme changes - Omarchy's omarchy-theme-set runs `killall -SIGUSR2 opencode` - so switching themes mid-run aborts the in-flight request. Theme refresh does not depend on the worker reload: the TUI re-detects the terminal palette and re-scans theme files from its own SIGUSR2 handler. Wait for every instance to have no busy session before invalidating config and disposing, and coalesce signals that arrive while waiting, so the reload is deferred rather than dropped. --- packages/opencode/src/cli/tui/worker.ts | 27 +++++++--- .../opencode/src/project/instance-store.ts | 9 ++++ .../opencode/src/server/global-lifecycle.ts | 22 ++++++++ .../opencode/test/project/instance.test.ts | 13 +++++ .../test/server/global-lifecycle.test.ts | 54 +++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/server/global-lifecycle.test.ts diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446b3..ba0541c0be94 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -9,7 +9,7 @@ import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" -import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { awaitSessionsIdle, disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" Heap.start() @@ -26,6 +26,7 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +let reloading: Promise | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { @@ -61,13 +62,23 @@ export const rpc = { await upgrade().catch(() => {}) }, async reload() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - yield* cfg.invalidate() - yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) - }), - ) + // SIGUSR2 arrives from desktop environments on theme changes, so a reload + // can land mid-run. Swapping config in disposes every instance, which + // cancels the session that is currently working — wait for it to finish + // instead. Signals that arrive while waiting join the pending reload. + if (!reloading) { + reloading = AppRuntime.runPromise( + Effect.gen(function* () { + yield* awaitSessionsIdle() + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ).finally(() => { + reloading = undefined + }) + } + await reloading }, async shutdown() { await InstanceRuntime.disposeAllInstances() diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaff7..c9188d9b0e0b 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -19,6 +19,7 @@ export interface LoadInput { export interface Interface { readonly load: (input: LoadInput) => Effect.Effect + readonly list: () => Effect.Effect readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeDirectory: (directory: string) => Effect.Effect @@ -123,6 +124,13 @@ const layer: Layer.Layer + Deferred.await(entry.deferred).pipe(Effect.exit), + ) + return exits.filter(Exit.isSuccess).map((exit) => exit.value) + }) + const reload = (input: LoadInput): Effect.Effect => { const directory = FSUtil.resolve(input.directory) return Effect.uninterruptibleMask((restore) => @@ -193,6 +201,7 @@ const layer: Layer.Layer status.list().pipe(Effect.provideService(InstanceRef, ctx))) + return active.some((sessions) => sessions.size > 0) +}) + export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f78b99ef7d9b..90335908e325 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -50,6 +50,19 @@ describe("InstanceStore", () => { }), ) + it.live("lists loaded instance contexts", () => + Effect.gen(function* () { + const first = yield* tmpdirScoped({ git: true }) + const second = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + yield* store.load({ directory: first }) + yield* store.load({ directory: second }) + + expect((yield* store.list()).map((ctx) => ctx.directory).toSorted()).toEqual([first, second].toSorted()) + }), + ) + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts new file mode 100644 index 000000000000..fd146f8b74d3 --- /dev/null +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceStore } from "../../src/project/instance-store" +import { SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { awaitSessionsIdle } from "../../src/server/global-lifecycle" +import { tmpdirScoped } from "../fixture/fixture" +import { awaitWithTimeout, testEffect } from "../lib/effect" + +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) + +const it = testEffect( + LayerNode.compile(LayerNode.group([InstanceStore.node, SessionStatus.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, noopBootstrap], + ]), +) + +const sessionID = SessionID.make("ses_global_lifecycle") + +describe("awaitSessionsIdle", () => { + it.live("resolves when no instance has a busy session", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + yield* store.load({ directory: dir }) + + yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle blocked while idle") + }), + ) + + it.live("waits for a busy session to go idle", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + const status = yield* SessionStatus.Service + + yield* status.set(sessionID, { type: "busy" }).pipe(Effect.provideService(InstanceRef, ctx)) + + const blocked = yield* awaitSessionsIdle().pipe( + Effect.as(false), + Effect.timeoutOrElse({ duration: "500 millis", orElse: () => Effect.succeed(true) }), + ) + expect(blocked).toBe(true) + + yield* status.set(sessionID, { type: "idle" }).pipe(Effect.provideService(InstanceRef, ctx)) + yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle did not resolve after the session went idle") + }), + ) +})