Skip to content

fix(rpc): scope loopback servers by project - #145

Open
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/rpc-per-project
Open

iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:fix/rpc-per-project

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

One opencode server process can host several project directories — the plugin factory is scoped per directory (plugin/index.ts:134-179 in opencode). This plugin kept a single process-global RPC server handle, so each new instantiation stopped the previous server and started a new one, and stop() unlinked port-<pid>.json from the directory it had been started with. The first project's port file disappeared, its TUI discovered nothing, and /openai-* commands silently stopped opening a modal for the life of the process. No error anywhere.

Found on this machine: pid 2799321, cwd ~/projects/brandon, also serving homelab and cortexkit. Its port file was under sha256(homelab) and there was none under sha256(brandon). Last instantiation wins; the rest go dark.

@cortexkit/anthropic-auth has the same shape and is fixing it in parallel (#216). Several findings below came out of comparing the two, including two that only showed up because the other seat measured something I had assumed.

The fix

globalThis.__openaiAuthRpcServers, a map keyed by the resolved RPC directory. Same directory re-instantiated stops and replaces its entry as before; a different directory starts an additional server and touches nothing else. dispose stops and removes only this instance's entry, matched on directory and handle identity.

The same treatment for __openaiAuthCacheKeepManager: a second project's instantiation used to call .stop() on the first's manager and install a fresh one with an empty target map, so project A's tracked idle sessions were dropped and never warmed again — no error, just prewarms that stop happening.

Two things the registry could have broken, and what pins them

Teardown resurrection. port-<pid>.json is the same filename for every server the process starts in a directory. Instance 1 starts S1, instance 2 replaces it with S2 under that same name, then instance 1's dispose runs late and a blind stop() unlinks S2's live port file — the original outage, re-entered through the cleanup. Two defences: dispose only acts when the map entry is still this instance's handle, and stop() unlinks only if the file on disk still names its own port and token.

Both are pinned independently, which took two attempts. A test asserting "the successor's port file survives" is satisfied by the ownership check alone and says nothing about the identity guard — when two defences protect the same observable, no assertion on that observable pins either one. The isolating assertion is behavioural: a non-owning dispose must not call stop() at all. Reverting each hunk alone:

  • identity guard only → disposing a replaced plugin instance does not stop its stale RPC handle fails; the port-file test still passes
  • port+token check only → stopping a stale server leaves its successor port file and health endpoint live fails; the dispose test still passes

Unbounded growth. Replacing one handle with a map trades a bounded leak for an unbounded one if nothing ever tears entries down. dispose?: () => Promise<void> is in the published SDK we build against (@opencode-ai/plugin/dist/index.d.ts:174, resolved 1.18.25) and opencode invokes it — the hook finalizer at plugin/index.ts:265-278, with per-directory disposers at project/instance-store.ts:94-105 and :126-145. Cardinality is bounded by live directory instances.

One pre-existing bug fixed on the way

drainNotifications(lastReceivedId, sessionId?) with an undefined sessionId matched every notification and pruned it, so one drain without a session id swallowed and deleted other sessions' pending dialogs. This predates the registry — the queue is module-global and never referenced the server, so it was already cross-session with a single server; per-project servers only widen it to cross-project.

The change is one line: don't prune on an unscoped drain. Delivery is unchanged, including the ack cursor, so an unscoped drain(0) still returns everything above the cursor and an unscoped drain(2) still returns nothing at or below it.

I checked reachability rather than assuming it: the TUI's pending(lastReceivedId, sessionId) call and the drain's sessionId parameter were introduced in the same commit (7142ad0), so no shipped TUI build can produce an unscoped drain. What reaches it is a malformed or third-party client on the loopback socket (rpc-server.ts:112 coerces a non-string param to undefined) and any future caller. Rejecting the call instead would have been stricter and worse: its failure mode is dialogs that never appear, which is the outage this PR fixes.

Verification

1142 pass, 0 fail (baseline 1137 at fb1402e); typecheck, Biome, order scanner 14/14, clean tree. Red proof against pristine fb1402e: expect(portA).not.toBeNull()Received: null.

Reviewed adversarially on a different model family: approve, no blocking findings, all three hazards traced with live probes. Two review findings were test-discrimination defects rather than production bugs, both fixed in 61c02e2 — the context test passed on port-distinctness rather than on the context, and the late-dispose test needed both defences reverted before it failed.

Separate, not fixed here

bootQuotaSeedStarted (index.ts:185 on fb1402e) looks like the same class. It is a per-process latch, but it guards quotaManager.seedFallbacksFromAccounts() at :2904, which mutates a per-instance QuotaManager — so in a process serving two projects the first trips the latch and later projects start with no seeded fallback quota. The sidebar write and API refresh in that block are correctly once-per-process; the seed is not. Admission also reads the shared sidebar file, so the likely symptom is degraded sticky/admission decisions until the first live push rather than a visible failure. Happy to fold it in here or file it separately — say which.


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 RPC servers, cachekeep managers, and fallback quota seeding being process-global singletons, so a second project in the same opencode process no longer stops the first project's server, deletes its port file, drops its warm cache targets, or starts with no seeded fallback quota.

Bug Fixes

  • RPC servers and cachekeep managers are stored in globalThis maps keyed by resolved RPC directory; same-directory reloads still replace, different directories coexist.
  • dispose only stops a server or manager if the map entry still matches this instance's handle, so a late dispose from a replaced instance can't kill the successor.
  • stop() unlinks the port file only if it still names this server's port and token.
  • Teardown runs from the plugin Hooks dispose only; the loader result no longer exposes a dispose hook.
  • Fallback quota seeding moved out from under the process-global bootQuotaSeedStarted latch, so each loader run seeds its own quota manager; only the machine-global sidebar write and quota refresh stay latched.
  • Superseded fallback refresh managers are stopped on replacement and at Hooks dispose; previously the fallback refresher was never stopped.
  • drainNotifications no longer prunes when called without a sessionId, so an unscoped drain can't swallow other sessions' pending dialogs.
  • TUI connectivity now requires a sessionId; the unscoped connectivity state is removed.
  • The RPC server logs a one-time warning when a drain arrives without a session id.
  • A packaging test refuses builds that still contain the singular RPC global.

Written for commit ab04de3. Summary will update on new commits.

Review in cubic

@iceteaSA

Copy link
Copy Markdown
Contributor Author

A sharper way to state the class, worth more than the three instances — it came out of comparing this against the sibling plugin, which has the same RPC bug and is fixing it in parallel.

The test is not "is this module-scope state" but does the cardinality of the guard match the cardinality of the work it guards. A per-process latch over per-process work is correct; the bug is a per-process guard over per-directory work, in a host where one process legitimately serves several directories.

Applying that to every piece of process-global state in the plugin, so the sweep is on the record rather than just the two fixes:

State Guards Verdict
__openaiAuthRpcServer one RPC server per directory bug — fixed here
__openaiAuthCacheKeepManager warm targets per instance bug — fixed here
bootQuotaSeedStarted (index.ts:185) seedFallbacksFromAccounts() on a per-instance QuotaManager bug — not fixed, see the PR body
oauthServer / serverStarting (core/oauth.ts:305) the callback server on a fixed port correct — one per process is the only possible cardinality
sidebarWriteChain (sidebar-state.ts:754) writes to the machine-global sidebar file correct — the resource is shared, so the chain should be
cached settings (config.ts:161), logger level/buffer process-wide env and one log file correct
notification queue / nextId (rpc/notifications.ts) delivery keyed by session id correct — the key is the shared thing
loggedCostRestoration, warnedCostCatalogUnavailable log de-duplication correct
nextDumpId a counter guards nothing

One thing that row eight does not cover, and I would rather flag it than leave it implied: lastDrainAtAny in the same module is set by any project's drain, so an unscoped isTuiConnected() would return true for project A because project B's TUI polled. Not reachable today — the only caller is index.ts:3598, which passes input.sessionID — so there is nothing to fix, but it is the same shape and it would become real the moment someone adds an unscoped caller.

The sibling plugin ran the same sweep on its tree and came back clean on all four of its globals, which is the useful control: the framing finds real instances where they exist and does not manufacture them where they do not.

@iceteaSA

Copy link
Copy Markdown
Contributor Author

Two corrections to the comment above, both making it less flattering.

The control was weaker than I said. I wrote that the sibling plugin ran the same sweep and came back clean on all four of its globals. Its sweep covered index.ts only, not the tree — and its rpc/notifications.ts has the same lastDrainAtAny instance ours does, same shape, same lineage. So the honest comparison is three instances here against one bug plus one latent instance there, which is still a discriminating result but not the zero-finding control I leaned on. Their words for why it happened are worth more than the correction: a sweep bounded to the file where you already found the bug confirms what you believe instead of testing it, so the scope has to be justified independently of where the known instance lives.

The lastDrainAtAny row understated the consequence. I described it as misleading a status probe. It is not — the call at index.ts:3598 sits in if (isTuiConnected(input.sessionID)) { pushNotification(...) } else { await sendIgnoredMessage(...) }. A cross-project false positive would push the dialog to a TUI that is not there and skip the fallback message, so the user sees nothing at all. That is the same silent-no-dialog symptom this PR exists to fix, reached through the connectivity probe instead of the port file.

Still not reachable: the only production caller passes input.sessionID. But "unreachable because every current caller happens to pass an argument" is exactly the state the RPC bug was in before someone opened a second project, so I have closed it rather than documented it — the session id is now required, the unscoped branch is gone, and lastDrainAtAny is deleted rather than left as dead process-wide state for a future caller to rediscover. Pinned behaviourally (a drain by one session must not make another report connected) and by type, with the type pin proven non-vacuous.

@iceteaSA

Copy link
Copy Markdown
Contributor Author

One more category the table above does not distinguish, which I think is the more useful half of it. Some of those "correct" rows are correct by structure and some are correct only contingently — and the second group all fail together the day one assumption changes.

Correct by structure: oauthServer (a fixed port admits one server per process), sidebarWriteChain (serialising writes to a machine-global file is the point of it), the notification queue (keyed by session id), the log-dedup flags, the counter.

Correct only contingently: the memoized settings in config.ts:161 and the logger's runtimeLevel are module-global mirrors of sidecar config, written by each plugin instance at boot. Two project instances in one process share one set of knobs — /openai-dump, /openai-logging, and everything else getSettings() resolves. That is correct today only because the sidecar path is process-wide: OPENCODE_OPENAI_AUTH_FILE, OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, homedir(). Every instance reads the same file, so the last writer writes the same value.

It is not correct by design. If this plugin ever gains a per-project config path — a .opencode/openai-auth.json beside the project is an ordinary enough request — every one of those knobs becomes a cross-project bug at the same moment, silently, with no test that would notice. Worth having as a stated precondition on that feature rather than as a bug report after it.

I am not scoping them here: there is no reachable defect, and it would be a much larger change than this PR should carry. The same reasoning applied to the cachekeep manager, which is why it needed the registry — it was only ever defensible because the account store cannot differ per project inside one process, and the moment that held less firmly than the RPC directory did, it was already broken.

Also flagging rather than fixing: tui.tsx:51 rpcPollStarted is a per-process latch in the TUI process. Correct while one TUI process serves one project; same class the instant that stops being true.

The sibling plugin ran the same class-derived sweep over its tree — 24 module-scope bindings — and found the identical contingent group (its dump, fast-mode and 1h-cache knobs) resting on the identical assumption about its own storage layer. Two independently written plugins, same tripwire under the same feature.

@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.

2 issues found and verified against the latest diff

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/rpc/rpc-server.ts">

<violation number="1" location="packages/opencode/src/rpc/rpc-server.ts:180">
P1: When a stale handle stops concurrently with a same-directory successor, this ownership check is still vulnerable to a read/unlink race and can remove the successor’s port file. Serialize per-directory start/stop or use an atomic ownership mechanism instead of separate `readFile` and `unlink` calls.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/tests/rpc-server.test.ts:519">
P2: The test replaces `globalThis.fetch` with a mock that always returns `new Response('{}')` and never asserts the request URL or count. The RPC `apply` handler this PR exercises can make real backend calls for several commands (e.g. `openai-quota`/`openai-reset` reach the Codex backend); for any such path the canned `{}` is silently consumed and the test would still pass (200) while the response was fabricated, masking regressions in the very round-trip these tests aim to protect. Scope the mock to only the calls the loader actually makes (e.g. assert the URL) or use a spy that records/asserts requests, rather than a blanket success stub.</violation>
</file>

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

Re-trigger cubic

Comment thread packages/opencode/src/index.ts Outdated
const current = await readFile(portFile, 'utf8')
.then((raw) => JSON.parse(raw) as { port?: unknown; token?: unknown })
.catch(() => undefined)
if (current?.port === port && current.token === token)

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 stale handle stops concurrently with a same-directory successor, this ownership check is still vulnerable to a read/unlink race and can remove the successor’s port file. Serialize per-directory start/stop or use an atomic ownership mechanism instead of separate readFile and unlink calls.

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

<comment>When a stale handle stops concurrently with a same-directory successor, this ownership check is still vulnerable to a read/unlink race and can remove the successor’s port file. Serialize per-directory start/stop or use an atomic ownership mechanism instead of separate `readFile` and `unlink` calls.</comment>

<file context>
@@ -164,9 +173,12 @@ export async function startRpcServer(
+      const current = await readFile(portFile, 'utf8')
+        .then((raw) => JSON.parse(raw) as { port?: unknown; token?: unknown })
+        .catch(() => undefined)
+      if (current?.port === port && current.token === token)
+        await unlink(portFile).catch(() => {})
     },
</file context>

Comment thread packages/opencode/src/index.ts
root,
'auth-state.json',
)
globalThis.fetch = (async () =>

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 test replaces globalThis.fetch with a mock that always returns new Response('{}') and never asserts the request URL or count. The RPC apply handler this PR exercises can make real backend calls for several commands (e.g. openai-quota/openai-reset reach the Codex backend); for any such path the canned {} is silently consumed and the test would still pass (200) while the response was fabricated, masking regressions in the very round-trip these tests aim to protect. Scope the mock to only the calls the loader actually makes (e.g. assert the URL) or use a spy that records/asserts requests, rather than a blanket success stub.

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-server.test.ts, line 519:

<comment>The test replaces `globalThis.fetch` with a mock that always returns `new Response('{}')` and never asserts the request URL or count. The RPC `apply` handler this PR exercises can make real backend calls for several commands (e.g. `openai-quota`/`openai-reset` reach the Codex backend); for any such path the canned `{}` is silently consumed and the test would still pass (200) while the response was fabricated, masking regressions in the very round-trip these tests aim to protect. Scope the mock to only the calls the loader actually makes (e.g. assert the URL) or use a spy that records/asserts requests, rather than a blanket success stub.</comment>

<file context>
@@ -323,4 +502,210 @@ describe('rpc-server', () => {
+        root,
+        'auth-state.json',
+      )
+      globalThis.fetch = (async () =>
+        new Response('{}')) as unknown as typeof globalThis.fetch
+
</file context>

@iceteaSA

Copy link
Copy Markdown
Contributor Author

Squashed to one commit, fe187e1, and CI is green there. Two things resolved since the last push, one of which I cannot fully explain and would rather say so.

Cubic's P1 was real and is fixed. Disposal compared identity against activeRpcServer, which is plugin-instance state that a second auth.loader run overwrites — so the first loader's dispose read the successor's handle, matched, and stopped the successor's live server. Identity-matched teardown defeated by the identity variable being shared. It now compares the loader-local handle. Reverting just that comparison reddens disposing an earlier loader leaves its same-instance successor RPC live with Expected: <successor port>; Received: undefined.

Worth noting the cachekeep manager in the same dispose block did not have this — its binding was already loader-local. One block, correct for one handle and wrong for the other.

The CI failure was a separate problem, and I did not prove which. disposal removes every project RPC and cachekeep registry entry failed on two consecutive runs (61c02e2, 3ad8f58) and never reproduced locally — not alone, not over five repeats, not under CPU load, not with a temp HOME/XDG_STATE_HOME, not on a pre-fix full-suite run.

What was independently wrong is the assertion: it checked registries.__openaiAuthRpcServers?.size === 0 — a global-size check against a process-global registry, in a file that is not the only thing in the process touching it. Any entry surviving from elsewhere reddens it, and the result depends on every other test in the process. It now asserts that each of the three directories the test owns is absent and its port file gone, which is stricter about what the test is responsible for.

So: two CI reds, then green after the assertion fix, with the P1 fixed in the same push. That is consistent with the assertion shape being the cause, but I have not separated it from the P1 fix and one green run is not determinism. If it recurs, the order-sensitive explanation is at least out of the candidate set.

Verification on the squashed head: 1144 pass, 0 fail across three consecutive local runs; the same under HOME and XDG_STATE_HOME redirected to temp dirs; typecheck, Biome, order scanner 15/15. The three-way defence proof still isolates — reverting the disposal identity check reddens only its test, reverting the port+token check in stop() reddens only its own.

@iceteaSA
iceteaSA marked this pull request as draft September 11, 2026 20:38
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Hold this one - I found a defect in my own change while verifying the build, and the PR currently claims something that is not true.

There are two dispose functions in index.ts. The one I changed, at :3423, is returned from auth.loader. The one that actually runs is the plugin Hooks dispose at :1040, and I left it untouched.

OpenCode never calls the loader's return as a lifecycle object. provider/provider.ts:1614-1622 takes it as provider options and merges it into the provider info:

const options = yield* Effect.promise(() => plugin.auth!.loader!(...))
const opts = options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }

fetch is meaningful there; dispose is just an unused property. The only dispose OpenCode invokes is the hooks one, through the finalizer at plugin/index.ts:265-278 - which is the citation I gave for teardown being reachable. That citation was right about the hook and wrong about which function I had put the teardown in.

So, concretely, on this branch:

  • the registry teardown never runs, which makes the bound I claimed against unbounded map growth dead code
  • :1044-1053 still does await activeRpcServer.stop() with no identity check, and activeRpcServer is still assigned on every loader run at :1948 - that is the same P1 cubic filed, surviving in the function that executes
  • that path clears the old __openaiAuthRpcServer global, which nothing sets any more, and never deletes the new registry entry

The build is what caught it. I grepped the bundled dist/index.js for the old global expecting zero and got if(N.__openaiAuthRpcServer===H)N.__openaiAuthRpcServer=void 0, which only exists if a site still references it.

Fixing both disposes and re-pushing. The registry, the port-ownership check in stop(), and the notification changes are unaffected - this is entirely about which function the teardown lives in. Worth saying plainly that three separate reviews and my own verification all read the dispose that changed rather than the dispose that runs.

@iceteaSA
iceteaSA marked this pull request as ready for review September 11, 2026 21:18
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Fixed and re-squashed to one commit, af6ea21. Ready for review again.

The teardown now lives in the plugin Hooks dispose. A test asserts the loader result carries no dispose at all, so the wrong function cannot be chosen again by accident.

Reverting each hunk on its own:

  • dispose moved back to the loader → three tests fail, including expect(loaderResult?.dispose).toBeUndefined() receiving [AsyncFunction: dispose]
  • identity check alone → only disposing a replaced plugin instance does not stop its stale RPC handle
  • stop() port+token check alone → only stopping a stale server leaves its successor port file and health endpoint live

Why the earlier tests passed over a dead function. Two reasons, one per test, and neither of them was the teardown working. RPC server stops and unlinks port file on loader dispose called loaderResult?.dispose?.() directly, so it drove the unreachable function itself. ...on plugin dispose drove the right one but asserted only on the port file, which stop() unlinks regardless of which function called it. Both now assert on the registry entry as well, which is the part that distinguishes them.

Worth flagging separately: that first test predates this PR. It asserted that the loader dispose works, for a path opencode never invokes - which is likely why the teardown ended up there in the first place. A test over an unreachable path does not just fail to catch bugs; it signals the path matters and pulls later work into it. It is now inverted to assert the loader must not expose a dispose.

One more from the same thread: command-session-isolation.test.ts was cleaning up with loaderResult?.dispose?.(), which became a no-op once the loader stopped returning one, leaving its RPC server running past the test. It disposes the plugin now.

Bundle check. dist/index.js must exist, be over 1 KiB, contain __openaiAuthRpcServers, and contain zero occurrences of the singular global. The existence and positive-control assertions are there because a count check on a missing or empty bundle returns zero and reads as a pass. CI runs bun run build immediately before bun run test (ci.yml:21-22, release.yaml:50-54), so the bundle under test is the one just built; an in-test build step was removed as redundant.

This check is what caught the original defect - I grepped the built bundle for the old global expecting zero and got if(N.__openaiAuthRpcServer===H)N.__openaiAuthRpcServer=void 0. Source review had already declared it gone. A source grep misses a second call site in a file nobody opened; the bundle contains everything that ships.

1145 pass, 0 fail (1137 at fb1402e), two consecutive runs; typecheck, Biome, order scanner clean on all touched files; git status empty.

@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.

3 issues found across 9 files

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/index.ts">

<violation number="1" location="packages/opencode/src/index.ts:1962">
P1: When two loaders for one directory start concurrently, both can pass the empty-map check before either asynchronous `startRpcServer` call registers. Serialize startup or reserve a per-directory entry before awaiting, otherwise the first server and its port-file state can be orphaned.</violation>
</file>

<file name="packages/opencode/src/tests/tui-packaging.test.ts">

<violation number="1" location="packages/opencode/src/tests/tui-packaging.test.ts:187">
P2: This test reads the gitignored build artifact `dist/index.js` (root `.gitignore` lists `packages/*/dist/`), which `bun run test` does not produce. On any fresh checkout where the developer runs `bun test src/tests` (or `bun run test`) without first running `bun run build`, this test now throws and fails the whole suite — a regression from the other tests in this file, which are source-based and need no prior build. It also silently validates a stale bundle after editing `src/index.ts`: if a contributor reintroduces the singular global and runs `bun test` without rebuilding, the test passes even though the source regressed, so the guard gives misleading local results and only enforces correctly in CI. Consider running build as part of the test step, reading the relevant source modules instead of the bundle, or gating this check to CI so it does not break the plain local `bun test` flow.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/tests/rpc-server.test.ts:526">
P3: The four new per-project tests each duplicate the same env-var capture, globalThis.fetch override, `{ __openaiAuthRpcServers?... }` registry cast, and 6-line finally restore block. Factor the setup/teardown into the shared helpers (e.g. a `withProjectEnv(root, fn)` wrapper or a beforeEach/afterEach) so a future change to env or registry handling doesn't have to be made in four places.</violation>
</file>

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

Re-trigger cubic

})
rpcGlobal.__openaiAuthRpcServer = rpcServer
activeRpcServer = rpcServer
rpcServers.set(rpcDir.dir, rpcServer)

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 two loaders for one directory start concurrently, both can pass the empty-map check before either asynchronous startRpcServer call registers. Serialize startup or reserve a per-directory entry before awaiting, otherwise the first server and its port-file state can be orphaned.

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 1962:

<comment>When two loaders for one directory start concurrently, both can pass the empty-map check before either asynchronous `startRpcServer` call registers. Serialize startup or reserve a per-directory entry before awaiting, otherwise the first server and its port-file state can be orphaned.</comment>

<file context>
@@ -1934,8 +1959,8 @@ export async function CodexAuthPlugin(
             })
-            rpcGlobal.__openaiAuthRpcServer = rpcServer
-            activeRpcServer = rpcServer
+            rpcServers.set(rpcDir.dir, rpcServer)
+            ownedRpcServers.set(rpcDir.dir, rpcServer)
           } catch {
</file context>

Comment thread packages/opencode/src/index.ts
// bundle directly here keeps the test dependent on the same freshness
// guarantee CI provides instead of rebuilding inside the test.
const bundle = join(PKG_DIR, 'dist', 'index.js')
if (!existsSync(bundle)) {

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: This test reads the gitignored build artifact dist/index.js (root .gitignore lists packages/*/dist/), which bun run test does not produce. On any fresh checkout where the developer runs bun test src/tests (or bun run test) without first running bun run build, this test now throws and fails the whole suite — a regression from the other tests in this file, which are source-based and need no prior build. It also silently validates a stale bundle after editing src/index.ts: if a contributor reintroduces the singular global and runs bun test without rebuilding, the test passes even though the source regressed, so the guard gives misleading local results and only enforces correctly in CI. Consider running build as part of the test step, reading the relevant source modules instead of the bundle, or gating this check to CI so it does not break the plain local bun test flow.

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

<comment>This test reads the gitignored build artifact `dist/index.js` (root `.gitignore` lists `packages/*/dist/`), which `bun run test` does not produce. On any fresh checkout where the developer runs `bun test src/tests` (or `bun run test`) without first running `bun run build`, this test now throws and fails the whole suite — a regression from the other tests in this file, which are source-based and need no prior build. It also silently validates a stale bundle after editing `src/index.ts`: if a contributor reintroduces the singular global and runs `bun test` without rebuilding, the test passes even though the source regressed, so the guard gives misleading local results and only enforces correctly in CI. Consider running build as part of the test step, reading the relevant source modules instead of the bundle, or gating this check to CI so it does not break the plain local `bun test` flow.</comment>

<file context>
@@ -177,4 +177,34 @@ describe('tui packaging (compiled ./tui entry shim)', () => {
+    // bundle directly here keeps the test dependent on the same freshness
+    // guarantee CI provides instead of rebuilding inside the test.
+    const bundle = join(PKG_DIR, 'dist', 'index.js')
+    if (!existsSync(bundle)) {
+      throw new Error(
+        'Built plugin bundle is missing: dist/index.js (run `bun run build` first)',
</file context>

root,
'auth-state.json',
)
globalThis.fetch = (async () =>

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: The four new per-project tests each duplicate the same env-var capture, globalThis.fetch override, { __openaiAuthRpcServers?... } registry cast, and 6-line finally restore block. Factor the setup/teardown into the shared helpers (e.g. a withProjectEnv(root, fn) wrapper or a beforeEach/afterEach) so a future change to env or registry handling doesn't have to be made in four places.

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-server.test.ts, line 526:

<comment>The four new per-project tests each duplicate the same env-var capture, globalThis.fetch override, `{ __openaiAuthRpcServers?... }` registry cast, and 6-line finally restore block. Factor the setup/teardown into the shared helpers (e.g. a `withProjectEnv(root, fn)` wrapper or a beforeEach/afterEach) so a future change to env or registry handling doesn't have to be made in four places.</comment>

<file context>
@@ -323,4 +509,282 @@ describe('rpc-server', () => {
+        root,
+        'auth-state.json',
+      )
+      globalThis.fetch = (async () =>
+        new Response('{}')) as unknown as typeof globalThis.fetch
+
</file context>

@oaiauth-alfonso

Copy link
Copy Markdown

Reviewed the mechanism and the teardown story rather than the whole diff. The bug is real and the registry is the right shape — one process hosting several project directories, last instantiation wins, and stop() unlinking the port file it was started with is what makes it silent rather than noisy.

Two things before merge.

stopBackgroundRefresh loses its only caller

fallbackManager.stopBackgroundRefresh() is called in exactly one place on main — inside the loader-result dispose() this PR deletes. After the PR there are zero callers in the tree; I checked pr145 directly rather than reading the diff.

The timer is unref'd (accounts.ts:1973) so it will not hold the process open, but it keeps calling refreshDueAccounts() forever, and that path takes the account-store file lock. In the exact scenario this PR exists for — one process, several projects — disposing project A leaves A's refresher competing for that lock with the live projects, and the set grows with each disposed instance. We have already had one incident from store-lock acquire timeouts under event-loop pressure, so an accumulating set of orphaned refreshers is not a theoretical cost.

What I could not settle from here: whether opencode ever called that loader-result dispose. The SDK types the loader as returning Promise<Record<string, any>> and the record is handed to the provider SDK, which suggests a dispose key on it was never a hook at all — in which case this is a pre-existing leak you are inheriting rather than introducing, and the PR is still the right place to fix it because it is the PR that makes disposal meaningful. You cited plugin/index.ts:134-179 and project/instance-store.ts:94-145, so you are better placed than I am to say which. Either way the remedy is the same: the Hooks-level dispose should stop the fallback manager alongside the RPC server and the cachekeep manager.

Fold in bootQuotaSeedStarted

Yes, fold it in — same class, same file, and the area is already open. I verified your diagnosis: the latch is a module-level let at :185, and quotaManager at :2904 is per-loader-instance, so a second project's QuotaManager never receives the persisted fallback quota. Your read of the blast radius matches mine: writeMachineSidebarState and refreshAllQuota in that block are machine-global and correctly once-per-process, so only the seed should come out from under the latch.

Rebase

main moved under you — 4f07e37 bumps the Pi trio to 0.85.1, Biome to 2.5.12, @opencode-ai/plugin to 1.18.29 and ai to ^7.0.93. Your branch carries the old bun.lock and package.json, so those three files will conflict.

On the review notes

The pinning problem you describe — two defences guarding one observable, so no assertion on that observable isolates either — is the same failure I shipped in this repo two weeks ago, where a fake socket closed inside the first-event grace and the test passed against broken code. Naming which hunk each test reddens when reverted alone is the right standard, and it is worth keeping in the PR body where the next reader will find it.

The drainNotifications reachability check is the right call too. Rejecting an unscoped drain would have converted a data-loss bug into the exact outage this PR fixes.

One opencode server process can host several project directories - the
plugin factory is scoped per directory (opencode `plugin/index.ts:134-179`).
This plugin kept a single process-global RPC server handle, so each new
instantiation stopped the previous server and started a new one, and
`stop()` unlinked `port-<pid>.json` from the directory it had been started
with. The first project's port file disappeared, its TUI discovered
nothing, and `/openai-*` commands silently stopped opening a modal for the
life of the process.

Found on a machine running one process across three projects: its port
file sat under the hash of a directory it was not serving, and there was
none under its own.

The RPC server and the cachekeep manager are now per-directory registries.
Re-instantiating the same directory stops and replaces its entry as
before; a different directory starts an additional server and touches
nothing else. Teardown removes only this instance's entries, matched on
directory and handle identity, and `stop()` unlinks a port file only when
it still names its own port and token - without that, a late dispose from
a superseded instance deletes a live successor's file and reproduces the
original outage through the cleanup path. Each defence is pinned by its
own test: reverting either one reddens that test and no other.

The cachekeep manager had the same shape with a quieter symptom. A second
project's instantiation called `.stop()` on the first's manager and
installed a fresh one with an empty target map, so project A's tracked
idle sessions were dropped and never warmed again - no error, prewarms
simply stopped happening.

Teardown runs from the plugin Hooks dispose, which is the only dispose
opencode invokes (`plugin/index.ts:265-278`, with per-directory disposers
at `project/instance-store.ts:94-105` and `:126-145`). The object returned
from `auth.loader` is provider options, not a lifecycle object
(`provider/provider.ts:1614-1622` merges it as `{ options }`), so a
dispose placed there never runs; a test now asserts the loader result
carries no dispose at all. A packaging check refuses a build whose bundle
still contains the singular global, after first asserting the bundle
exists and carries the registry global, so it cannot pass by matching
nothing.

That inherited dispose was also the only caller of
`stopBackgroundRefresh`, so the fallback refresher was never stopped by
anything. `auth.loader` can run more than once per plugin instance, and
each run built a new manager while the previous one kept polling
`refreshDueAccounts()` on its own timer, taking the account-store file
lock. A superseded manager is now stopped as its replacement is installed,
and the Hooks dispose stops the active one. The RPC and cachekeep paths
already stopped what they replaced; the fallback path was the exception.

Fallback quota seeding moves out from under `bootQuotaSeedStarted`. The
latch is process-global but `quotaManager` is per loader instance, so the
first project through the process left every later project's manager
without the persisted fallback quota and its routing started blind. The
machine-global work in that block - the sidebar state write and
`refreshAllQuota` - stays latched, because those are correctly
once-per-process.

Also fixed here, pre-existing and independent of the registry: a drain of
the notification queue without a session id matched every notification and
pruned it, so one drain swallowed and deleted other sessions' pending
dialogs. The queue is module-global and never referenced the server, so
this was already cross-session with a single server; per-project servers
only widen it to cross-project. Delivery is unchanged, including the ack
cursor - an unscoped drain still returns everything above it and nothing
at or below it - and pruning no longer happens. Rejecting the call instead
would have been stricter and worse: its failure mode is dialogs that never
appear. Connectivity is scoped the same way, so one project's TUI polling
can no longer make another project's session look connected and suppress
its fallback message.
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Both asks are in, rebased onto 7163652, squashed to one commit ab04de3. 1147 pass, 0 fail.

stopBackgroundRefresh - I can settle the question you could not

It never ran. The object returned from auth.loader is treated as provider options: provider/provider.ts:1614-1622 assigns it to options and merges { options: opts } into the provider Info, and no .dispose call on that object exists anywhere in provider/*.ts. fetch is meaningful there; dispose was an unused property. So this is a pre-existing leak the PR inherits, exactly as you guessed, and the PR is the right place for it because it is the PR that makes disposal real.

Chasing it turned up worse than a dispose-time leak. auth.loader can run more than once per plugin instance, and each run built a new FallbackAccountManager while the previous one kept polling refreshDueAccounts() on its own timer - taking the account-store file lock, with nothing routing through it. So the orphaned-refresher set you described was already accumulating within a single instance, between loader runs, not only after disposal.

The fix is therefore in two places: a superseded manager is stopped as its replacement is installed, and the Hooks dispose stops the active one. I checked the other two registries for the same shape and they were already correct - cachekeep stops the existing entry before replacing it, and the RPC path awaits stop() before startRpcServer. The fallback path was the only one that replaced without stopping.

Pinned by a live-timer count rather than a spy: reverting either half leaves a second fallback timer running after a double loader run.

bootQuotaSeedStarted

Folded in, scoped as you described. seedFallbacksFromAccounts moves out from under the latch; writeMachineSidebarState and refreshAllQuota stay under it. Test asserts the second loader instance's QuotaManager receives the persisted fallback quota; reverting the change reddens it.

Rebase

No conflict. The branch touches neither package.json nor bun.lock, so your dependency bumps replayed untouched - git diff --name-only upstream/main..HEAD lists neither. Frozen install clean against the new versions, Biome 2.5.12 reformatted nothing of ours.

One thing I looked at and am not fixing

Every stop-then-start sequence here has an await between check and registration, so two concurrent loader runs for one directory could both start a server and leave the loser orphaned. It is not reachable: InstanceState.make (effect/instance-state.ts:30) builds a ScopedCache keyed by directory, which single-flights concurrent lookups, and the plugin loop awaits each auth.loader serially. It also predates this PR - main's single global handle has the same shape - and the registry scopes it per directory without widening it. Worth knowing that the existing repeated-loader tests are sequential and would not catch it if that ever changed.

Gates

1147 pass / 0 fail twice (1137 at fb1402e), typecheck, Biome 95 files, order scanner 17/17 isolated, bundle gate 3/3, clean tree.

@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.

3 issues found across 5 files (changes from recent commits).

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/index.ts">

<violation number="1" location="packages/opencode/src/index.ts:1067">
P1: When a replacement loader installs a successor while this stop is awaiting, the unconditional delete removes the successor from `__openaiAuthRpcServers`. Re-check the registry identity after the await before deleting so the successor remains trackable and disposable.</violation>
</file>

<file name="packages/opencode/src/tests/cachekeep.test.ts">

<violation number="1" location="packages/opencode/src/tests/cachekeep.test.ts:2633">
P3: Asserting the entire process-global `__openaiAuthRpcServers` map reaches size 0 couples this test to every other server the process may hold. The PR's registry is keyed per-directory and the design explicitly allows several directories to coexist, so verifying this plugin's tempDir key was deleted (while leaving unrelated keys untouched) matches the intended teardown contract better and won't fail spuriously if another test/project owns a live server. Assert on the specific key instead of global size.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/tests/rpc-server.test.ts:946">
P3: The exact-length `toEqual(['Bearer access-token'])` assumes no other `/responses` request is issued by the async boot-quota-seed/refresh work between the two loader runs and the final fetchOverride call. Because refreshAllQuota runs un-awaited in the background during the first load, any additional `/responses` probe lands in `responseAuthorizations` and breaks this assertion nondeterministically. Assert against the last entry or filter instead of requiring the whole array to be length 1.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

for (const [key, rpcServer] of ownedRpcServers) {
if (rpcGlobal.__openaiAuthRpcServers?.get(key) === rpcServer) {
await rpcServer.stop().catch(() => {})
rpcGlobal.__openaiAuthRpcServers.delete(key)

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 replacement loader installs a successor while this stop is awaiting, the unconditional delete removes the successor from __openaiAuthRpcServers. Re-check the registry identity after the await before deleting so the successor remains trackable and disposable.

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 1067:

<comment>When a replacement loader installs a successor while this stop is awaiting, the unconditional delete removes the successor from `__openaiAuthRpcServers`. Re-check the registry identity after the await before deleting so the successor remains trackable and disposable.</comment>

<file context>
@@ -1039,18 +1041,33 @@ export async function CodexAuthPlugin(
+      for (const [key, rpcServer] of ownedRpcServers) {
+        if (rpcGlobal.__openaiAuthRpcServers?.get(key) === rpcServer) {
+          await rpcServer.stop().catch(() => {})
+          rpcGlobal.__openaiAuthRpcServers.delete(key)
         }
-        activeRpcServer = null
</file context>
Suggested change
rpcGlobal.__openaiAuthRpcServers.delete(key)
if (rpcGlobal.__openaiAuthRpcServers?.get(key) === rpcServer) {
rpcGlobal.__openaiAuthRpcServers.delete(key)
}

expect(
files.some((f) => f.startsWith('port-') && f.endsWith('.json')),
).toBe(false)
expect(rpcGlobal.__openaiAuthRpcServers?.size ?? 0).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.

P3: Asserting the entire process-global __openaiAuthRpcServers map reaches size 0 couples this test to every other server the process may hold. The PR's registry is keyed per-directory and the design explicitly allows several directories to coexist, so verifying this plugin's tempDir key was deleted (while leaving unrelated keys untouched) matches the intended teardown contract better and won't fail spuriously if another test/project owns a live server. Assert on the specific key instead of global size.

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

<comment>Asserting the entire process-global `__openaiAuthRpcServers` map reaches size 0 couples this test to every other server the process may hold. The PR's registry is keyed per-directory and the design explicitly allows several directories to coexist, so verifying this plugin's tempDir key was deleted (while leaving unrelated keys untouched) matches the intended teardown contract better and won't fail spuriously if another test/project owns a live server. Assert on the specific key instead of global size.</comment>

<file context>
@@ -2625,14 +2619,18 @@ describe('RPC server dispose', () => {
       expect(
         files.some((f) => f.startsWith('port-') && f.endsWith('.json')),
       ).toBe(false)
+      expect(rpcGlobal.__openaiAuthRpcServers?.size ?? 0).toBe(0)
     } finally {
       process.env.OPENCODE_OPENAI_AUTH_RPC_DIR = originalRpcDir
</file context>

},
)
expect(response.status).toBe(200)
expect(responseAuthorizations).toEqual(['Bearer access-token'])

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: The exact-length toEqual(['Bearer access-token']) assumes no other /responses request is issued by the async boot-quota-seed/refresh work between the two loader runs and the final fetchOverride call. Because refreshAllQuota runs un-awaited in the background during the first load, any additional /responses probe lands in responseAuthorizations and breaks this assertion nondeterministically. Assert against the last entry or filter instead of requiring the whole array to be length 1.

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-server.test.ts, line 946:

<comment>The exact-length `toEqual(['Bearer access-token'])` assumes no other `/responses` request is issued by the async boot-quota-seed/refresh work between the two loader runs and the final fetchOverride call. Because refreshAllQuota runs un-awaited in the background during the first load, any additional `/responses` probe lands in `responseAuthorizations` and breaks this assertion nondeterministically. Assert against the last entry or filter instead of requiring the whole array to be length 1.</comment>

<file context>
@@ -745,34 +746,211 @@ describe('rpc-server', () => {
+        },
+      )
+      expect(response.status).toBe(200)
+      expect(responseAuthorizations).toEqual(['Bearer access-token'])
+    } finally {
+      await plugin?.dispose?.()
</file context>

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.

1 participant