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

fix(rpc): scope loopback servers by project#145
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 and cachekeep managers 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, or drops its warm cache targets.

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

Separately, bootQuotaSeedStarted looks like the same class of bug: a per-process latch guarding a per-instance quota seed, so later projects start with no seeded fallback quota. Not fixed here.

Written for commit af6ea21. 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.

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.

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

4 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:1056">
P2: When an OAuth loader has fallback accounts, disposing the plugin leaves its `FallbackAccountManager` interval running. Track each fallback manager and call `stopBackgroundRefresh()` during disposal.</violation>

<violation number="2" 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>

if (rpcGlobal.__openaiAuthRpcServer === activeRpcServer) {
rpcGlobal.__openaiAuthRpcServer = undefined
}
ownedCacheKeepManagers.clear()

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 an OAuth loader has fallback accounts, disposing the plugin leaves its FallbackAccountManager interval running. Track each fallback manager and call stopBackgroundRefresh() during disposal.

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

<comment>When an OAuth loader has fallback accounts, disposing the plugin leaves its `FallbackAccountManager` interval running. Track each fallback manager and call `stopBackgroundRefresh()` during disposal.</comment>

<file context>
@@ -1041,16 +1042,29 @@ export async function CodexAuthPlugin(
-        if (rpcGlobal.__openaiAuthRpcServer === activeRpcServer) {
-          rpcGlobal.__openaiAuthRpcServer = undefined
+      }
+      ownedCacheKeepManagers.clear()
+
+      const rpcGlobal = globalThis as {
</file context>

// 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>

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