Skip to content

fix(opencode): stop per-instance background services on dispose - #217

Merged
ualtinok merged 4 commits into
cortexkit:mainfrom
iceteaSA:fix/dispose-stops-background-services
Sep 14, 2026
Merged

ualtinok merged 4 commits into
cortexkit:mainfrom
iceteaSA:fix/dispose-stops-background-services

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #216 — its dispose hook is the mechanism this uses. Review/merge that first; the diff here is the last commit only (353d158).

Symptom

Nothing user-visible. A long-lived opencode process that has loaded the plugin for several projects, or reloaded one, accumulates background workers that nobody stopped: each keeps polling quota, attempting token refreshes, and contending for the account-store file lock for the life of the process.

Mechanism

The plugin factory starts three background services. Until #216 there was no dispose hook at all, so none of them could be stopped — this is pre-existing, not a regression from that PR. #216 adds the hook and stops the RPC server; these three were still running:

Service Constructed Started Stopped
FallbackAccountManager per instance (index.ts:1856) startBackgroundRefresh() (:2200) never — stopBackgroundRefresh() had no production caller
CacheKeepManager per instance (:2404) on demand never — stop() had no production caller
PrimeManager shared via adoptPrimeManager (:2856) start() only by /claude-prime off

The timers are unref'd, so they do not hold the process open — they just keep doing work on behalf of a project that is gone.

Ownership is the whole risk

The first two are per-instance: the disposing instance owns them and stops them. PrimeManager is not — it is adopted from a process-wide registry keyed by account-storage path, shared by every project using that path. Calling primeManager.stop() from dispose would stop priming for live sibling projects.

So the registry gains releasePrimeManager(storagePath, slot): it drops the slot, and stops and evicts the manager only when that was the last slot. Releasing an unknown slot, or releasing twice, is a no-op.

A late release must not clobber a successor. slotFingerprints maps slot → the fingerprint that slot currently belongs to, and adoptPrimeManager reads it to detach a slot when its storage path changes. A release arriving after the slot was re-adopted elsewhere would otherwise delete the successor's mapping, so the next adopt skips the detach and leaves a stale slot behind — which keeps that manager's slot set non-empty and prevents it from ever being torn down. The deletion is therefore guarded on the mapping still pointing at the fingerprint being released. This is the same hazard the RPC teardown in #216 guards against, one map over.

dispose keeps the isolation discipline already in that function: each step is independent, so a failure in one cannot skip the RPC shutdown, and the additions run regardless of whether this instance still owns the registry entry.

Verification

Each behaviour is pinned by a mutation of the implemented logic, not by the absence of a symbol — a missing-export failure proves only that the function was new.

  • Per-instance stops. Reverting each call: (fail) dispose clears the fallback background refresh interval — Received: 0; (fail) dispose calls cacheKeepManager.stop — Received: 0.
  • Sibling protection. Removing the slots.size === 0 guard so release stops unconditionally reddens releasing one of two slots keeps the shared manager alive for the sibling and releasing an unknown slot is a no-op, while the last-slot and per-instance tests stay green.
  • Late-release ordering. Restoring the unconditional slotFingerprints.delete reddens exactly one test — a late release does not clobber a successor slot mapping (managerY.isStopped() received false, expected true) — and nothing else. Adopt slot D under path X, re-adopt D under path Y, release (pathX, D) late, then adopt under path Z and assert Y detached D.
  • Assertions are per-slot and per-key throughout; no aggregate registry.size === 0, which would depend on every other test in the process and on ordering.
  • core 169 pass / 0 fail · opencode 1573 / 0 · typecheck 0 · format and biome clean.

Not included

The 'default' slot fallback when ctx.directory is undefined means two directory-less instances share one slot. That is pre-existing in adoptPrimeManager and unchanged here; it is only reachable when the host provides no directory, and both such instances would share a storage path anyway.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes dispose so per-instance background services stop when a project is unloaded; previously only the RPC server was torn down, leaving quota polling and token refreshes running for the life of the process. The shared PrimeManager is now released per slot so sibling projects on the same storage path stay untouched.

Key details

  • Dispose now stops the fallback background refresh and the cache keep manager.
  • New releasePrimeManager drops a slot and stops the shared manager only when the last slot is released; unknown or double releases are no-ops.
  • A late release is guarded on the slot fingerprint so it cannot evict a successor's mapping.
  • Dispose steps are isolated, so a failure in one cannot skip the RPC shutdown.
  • Adds mutation-tested coverage for per-instance stops, sibling protection, and late-release ordering.

Written for commit 353d158. Summary will update on new commits.

Review in cubic

…rocess

The plugin kept one RPC server handle per process on
`globalThis.__anthropicAuthRpcServer`. Every plugin instantiation stopped the
existing handle first, and `stop()` unlinks `port-<pid>.json` from the directory
that handle was started with. So once a process instantiated the plugin for a
second project directory, the first project's port file was deleted and its
server stopped: that project's TUI found nothing to poll and `/claude-*` opened
no modal for the life of the process, with no error anywhere.

One process legitimately serves several directories -- request routing accepts
`?directory=`/`x-opencode-directory` and the plugin factory is scoped per
directory (opencode `plugin/index.ts:134-179`, `workspace-routing.ts:86-88`).

Key the servers by resolved RPC directory instead. Re-instantiating the same
directory stops and replaces as before; a different directory starts an
additional server and leaves the others alone.

Bound the registry with `Hooks.dispose` (declared in `@opencode-ai/plugin`
1.18.21 and invoked by opencode's instance finalizer, `plugin/index.ts:265-278`;
disposers also run on per-project reload, `project/instance-store.ts:126-145`).
Teardown acts only when the registry entry is still its own handle: the port
filename is identical for every server a process starts in one directory, so a
dispose arriving after a same-directory replace would otherwise unlink the live
successor's port file and put that project back in the dark. Cleanup steps are
isolated from the RPC shutdown so a failing one cannot skip it.

`stop()` unlinks the port file only when it still names its own port and pid, so
a stale handle cannot remove a successor's file even if a future path forgets
the identity check.

The two defences protect the same observable and would mask each other, so each
has its own test and each was mutated alone: removing the identity check reddens
only the dispose-behaviour test, removing the port/pid match reddens only the
stale-server test. A third test drives real HTTP against both directories'
servers and asserts each answers through its own instance's closure -- a
regression collapsing them onto one shared closure passes every other test here.
One notification queue serves every RPC server in the process, so anything in it
that is not keyed by session is keyed by nothing once a process holds more than
one project.

Require a session id on queued notices. A notice without one was delivered to
every draining TUI and survived one session's ack, which across servers means
crossing project boundaries.

Require a session id on the connectivity probe and delete the process-wide
`lastDrainAtAny` it fell back on. That timestamp was written by every project's
drain, so an unscoped call could report a TUI connected for project A because
project B's TUI polled -- and the caller skips the desktop fallback when it
believes a TUI is present, so the notice would go nowhere. Both call sites
already passed an id; this removes the possibility rather than relying on it.

Stop an unscoped drain from deleting other sessions' notices. It pruned every
acknowledged notice regardless of owner, so one client both swallowed and
destroyed other sessions' pending dialogs. This predates the per-directory
registry -- with a single server it was cross-session inside one project -- and
is fixed as deliver-but-never-prune: the TUI has sent a session id since polling
was introduced, so the clients that can omit it are malformed or third-party
ones, and rejecting them would fail by silently not delivering, which is the
symptom this change exists to fix.

With no producer able to queue a session-less notice, the matching branch in the
drain filter is unreachable and removed. The wire field stays optional so an
older TUI still parses what it is sent.
The per-directory registry __anthropicAuthRpcServers supersedes the
process-wide __anthropicAuthRpcServer handle. Production never read the
singular identifier for a decision; the conditional clear was a clear-if-mine
that no reader depended on, and the dispose never cleared it on teardown,
leaving a dangling reference to a stopped server for the lifetime of the
process.

The test helper gains a per-directory cleanup proof that, for every rpc
dir this file started a server in, the registry holds no entry for it and
its port-<pid>.json is gone.

A post-build gate (packages/opencode/scripts/check-bundle-globals.ts)
verifies the bundle still contains the registry identifier and has zero
matches for the singular form, wired into the build script and runnable
via bun run check:bundle.
The dispose hook previously stopped only the RPC server (plus the quota-header feed registry and Claustrum credential cache). Two per-instance services were never stopped:

- fallbackManager (FallbackAccountManager.stopBackgroundRefresh) — kept polling quota and attempting token refreshes after dispose.

- cacheKeepManager (CacheKeepManager.stop) — never called.

The third service is shared via prime-manager-registry: dispose now calls releasePrimeManager(accountStoragePath, ctx.directory ?? 'default'), which removes this instance's slot and stops the shared PrimeManager only when the last slot is gone — preserving it for sibling projects on the same storage path.

This is pre-existing; the per-instance background timers were already running before the RPC teardown was added. The RPC guard (early return when this instance no longer owns the registry entry) does not apply to per-instance cleanup, so the new steps run before that guard.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 10 files

Confidence score: 2/5

  • packages/opencode/src/prime-manager-registry.ts can remove a successor slot and stop its live PrimeManager when an older instance disposes after re-adoption, potentially breaking active manager behavior; track an adoption generation or lease before releasing the slot.
  • packages/opencode/src/index.ts leaves mainBackgroundRefreshTimer running after plugin disposal, so reloads can accumulate background refresh work and retain resources; clear it with runtimeTimers.clearInterval and set it to null during disposal.
  • packages/opencode/src/rpc/notifications.ts drops legacy TUI modal notifications when polling without sessionId, causing command.execute.before to ignore messages; preserve an unspecific connection timestamp for this path.
  • packages/opencode/src/tests/dispose-cleanup.test.ts does not actually exercise tracked timer cleanup because its timer overrides are no-ops, and packages/opencode/src/tests/rpc-multi-project.test.ts leaves created plugins undisposed; wire the real tracked timer and dispose each test plugin to make leak coverage meaningful.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/prime-manager-registry.ts">

<violation number="1" location="packages/opencode/src/prime-manager-registry.ts:63">
P1: When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live `PrimeManager`. Track an adoption generation or lease and release only the matching adoption.</violation>
</file>

<file name="packages/opencode/src/rpc/notifications.ts">

<violation number="1" location="packages/opencode/src/rpc/notifications.ts:53">
P2: When a legacy TUI polls without `sessionId`, this function leaves no connection timestamp, so `command.execute.before` falls back to `sendIgnoredMessage` instead of enqueueing the modal notification. Preserve an unscoped liveness fallback for legacy clients or explicitly remove that compatibility path.</violation>
</file>

<file name="packages/opencode/src/index.ts">

<violation number="1" location="packages/opencode/src/index.ts:2859">
P1: After the auth loader starts `mainBackgroundRefreshTimer`, disposing this plugin never clears it. Clear `mainBackgroundRefreshTimer` with `runtimeTimers.clearInterval` and set it to `null` during disposal so reloads do not leave stale auth refresh workers running.</violation>
</file>

<file name="packages/opencode/src/tests/dispose-cleanup.test.ts">

<violation number="1" location="packages/opencode/src/tests/dispose-cleanup.test.ts:80">
P2: The leak-detection assertion in afterEach is vacuous: this file wires the plugin with disabledPluginTimerOverrides (no-op setInterval/clearInterval mocks) and never installs the trackedSetInterval that would populate activeIntervals, so `expect(activeIntervals.size).toBe(0)` can never fail. Because the PR's whole purpose is preventing real background timers from keeping the process alive, the test gives false assurance — a real timer leak on dispose would still pass. Wire the plugin with trackedSetInterval/trackedClearInterval (as the createTimerTracking helper intends) so an actual leaked interval is caught, or drop the vacuous assertion.</violation>
</file>

<file name="packages/opencode/src/tests/rpc-multi-project.test.ts">

<violation number="1" location="packages/opencode/src/tests/rpc-multi-project.test.ts:203">
P3: Several tests create plugins via getPlugin() but never call dispose(), so the per-instance background services this PR adds teardown for (fallback background refresh, cacheKeepManager, prime manager) are left running and are only masked by the setInterval mock in disabledPluginRuntimeOverrides. afterEach only stops RPC servers. Either call dispose() at the end of these tests (matching the 'dispose stops...' tests) or assert that the teardown happens, so the tests actually cover the lifecycle this PR implements and don't silently depend on the timer mock to avoid leaks.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Host as OpenCode Host
    participant Plugin as Plugin Factory
    participant Services as Background Services
    participant Registry as Prime Manager Registry
    participant RPC as RPC Server Map
    participant DB as Account Storage

    Note over Host,DB: Plugin Lifecycle & Resource Cleanup

    Host->>Plugin: load plugin (per project)
    Plugin->>Services: create FallbackAccountManager
    Plugin->>Services: create CacheKeepManager
    Plugin->>Registry: adoptPrimeManager(storagePath, slot)
    Registry->>DB: get shared manager for storagePath
    Registry-->>Plugin: return shared PrimeManager
    Plugin->>RPC: startRpcServer for project directory
    RPC-->>Plugin: RpcServerHandle

    Note over Host,DB: Runtime Operation

    Services->>Services: startBackgroundRefresh() (quota polling)
    Services->>Services: cache keepalive (on demand)
    Registry->>DB: prime polling (shared, multi-slot)

    Note over Host,DB: Dispose (project unload)

    Host->>Plugin: dispose()
    
    Plugin->>Services: stopBackgroundRefresh()
    Plugin->>Services: stop()
    
    Plugin->>Registry: releasePrimeManager(storagePath, slot)
    alt Last slot for storage path
        Registry->>DB: stop() + evict manager
    else Sibling slots still active
        Registry->>DB: keep manager running
    end
    
    Plugin->>RPC: stop server for this directory
    alt RPC handle is still current
        RPC->>RPC: close server, remove port file
    else Stale handle (replaced by successor)
        RPC->>RPC: leave successor untouched
    end
    
    Note over Host,DB: Late release edge case
    
    Host->>Plugin: dispose() (delayed/out-of-order)
    Plugin->>Registry: releasePrimeManager(pathX, slotD)
    alt Slot D now adopted under pathY
        Registry->>DB: preserve successor mapping
    else Slot D still original
        Registry->>DB: clean slot mapping
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const fingerprint = primeStorageFingerprint(storagePath)
const entry = primeManagers.get(fingerprint)
if (entry) {
entry.slots.delete(slot)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live PrimeManager. Track an adoption generation or lease and release only the matching adoption.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/prime-manager-registry.ts, line 63:

<comment>When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live `PrimeManager`. Track an adoption generation or lease and release only the matching adoption.</comment>

<file context>
@@ -52,3 +52,24 @@ export function adoptPrimeManager(
+  const fingerprint = primeStorageFingerprint(storagePath)
+  const entry = primeManagers.get(fingerprint)
+  if (entry) {
+    entry.slots.delete(slot)
+    if (entry.slots.size === 0) {
+      entry.manager.stop()
</file context>

// rest of the process. Each step is isolated: one failure cannot skip
// the others.
try {
fallbackManager.stopBackgroundRefresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After the auth loader starts mainBackgroundRefreshTimer, disposing this plugin never clears it. Clear mainBackgroundRefreshTimer with runtimeTimers.clearInterval and set it to null during disposal so reloads do not leave stale auth refresh workers running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 2859:

<comment>After the auth loader starts `mainBackgroundRefreshTimer`, disposing this plugin never clears it. Clear `mainBackgroundRefreshTimer` with `runtimeTimers.clearInterval` and set it to `null` during disposal so reloads do not leave stale auth refresh workers running.</comment>

<file context>
@@ -2806,27 +2809,88 @@ const anthropicAuthPlugin = async (
+    // rest of the process. Each step is isolated: one failure cannot skip
+    // the others.
+    try {
+      fallbackManager.stopBackgroundRefresh()
+    } catch (error) {
+      logger.warn('fallback-background', 'failed to stop', {
</file context>
Suggested change
fallbackManager.stopBackgroundRefresh()
fallbackManager.stopBackgroundRefresh()
if (mainBackgroundRefreshTimer) {
runtimeTimers.clearInterval(mainBackgroundRefreshTimer)
mainBackgroundRefreshTimer = null
}

}

export function isTuiConnected(sessionId?: string): boolean {
export function isTuiConnected(sessionId: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a legacy TUI polls without sessionId, this function leaves no connection timestamp, so command.execute.before falls back to sendIgnoredMessage instead of enqueueing the modal notification. Preserve an unscoped liveness fallback for legacy clients or explicitly remove that compatibility path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/rpc/notifications.ts, line 53:

<comment>When a legacy TUI polls without `sessionId`, this function leaves no connection timestamp, so `command.execute.before` falls back to `sendIgnoredMessage` instead of enqueueing the modal notification. Preserve an unscoped liveness fallback for legacy clients or explicitly remove that compatibility path.</comment>

<file context>
@@ -21,34 +30,35 @@ export function drainNotifications(
 }
 
-export function isTuiConnected(sessionId?: string): boolean {
+export function isTuiConnected(sessionId: string): boolean {
   const now = Date.now()
-  if (sessionId !== undefined) {
</file context>

await rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
} finally {
expect(activeIntervals.size).toBe(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The leak-detection assertion in afterEach is vacuous: this file wires the plugin with disabledPluginTimerOverrides (no-op setInterval/clearInterval mocks) and never installs the trackedSetInterval that would populate activeIntervals, so expect(activeIntervals.size).toBe(0) can never fail. Because the PR's whole purpose is preventing real background timers from keeping the process alive, the test gives false assurance — a real timer leak on dispose would still pass. Wire the plugin with trackedSetInterval/trackedClearInterval (as the createTimerTracking helper intends) so an actual leaked interval is caught, or drop the vacuous assertion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/dispose-cleanup.test.ts, line 80:

<comment>The leak-detection assertion in afterEach is vacuous: this file wires the plugin with disabledPluginTimerOverrides (no-op setInterval/clearInterval mocks) and never installs the trackedSetInterval that would populate activeIntervals, so `expect(activeIntervals.size).toBe(0)` can never fail. Because the PR's whole purpose is preventing real background timers from keeping the process alive, the test gives false assurance — a real timer leak on dispose would still pass. Wire the plugin with trackedSetInterval/trackedClearInterval (as the createTimerTracking helper intends) so an actual leaked interval is caught, or drop the vacuous assertion.</comment>

<file context>
@@ -0,0 +1,358 @@
+      await rm(tempDir, { recursive: true, force: true }).catch(() => {})
+    }
+  } finally {
+    expect(activeIntervals.size).toBe(0)
+  }
+})
</file context>

const directoryA = join(testRoot, 'project-a')
const directoryB = join(testRoot, 'project-b')

await getPlugin(directoryA)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Several tests create plugins via getPlugin() but never call dispose(), so the per-instance background services this PR adds teardown for (fallback background refresh, cacheKeepManager, prime manager) are left running and are only masked by the setInterval mock in disabledPluginRuntimeOverrides. afterEach only stops RPC servers. Either call dispose() at the end of these tests (matching the 'dispose stops...' tests) or assert that the teardown happens, so the tests actually cover the lifecycle this PR implements and don't silently depend on the timer mock to avoid leaks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/rpc-multi-project.test.ts, line 203:

<comment>Several tests create plugins via getPlugin() but never call dispose(), so the per-instance background services this PR adds teardown for (fallback background refresh, cacheKeepManager, prime manager) are left running and are only masked by the setInterval mock in disabledPluginRuntimeOverrides. afterEach only stops RPC servers. Either call dispose() at the end of these tests (matching the 'dispose stops...' tests) or assert that the teardown happens, so the tests actually cover the lifecycle this PR implements and don't silently depend on the timer mock to avoid leaks.</comment>

<file context>
@@ -0,0 +1,340 @@
+    const directoryA = join(testRoot, 'project-a')
+    const directoryB = join(testRoot, 'project-b')
+
+    await getPlugin(directoryA)
+    await getPlugin(directoryB)
+
</file context>

@ualtinok
ualtinok merged commit 13f3e13 into cortexkit:main Sep 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants