diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f349b884..c0bc2ab2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -45,9 +45,10 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui", + "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui && bun run check:bundle", "build:tui": "bun scripts/build-tui.ts", "smoke:tui": "bun scripts/smoke-tui-pack-install.ts", + "check:bundle": "bun scripts/check-bundle-globals.ts", "build:dev": "rm -rf dist && tsc -p tsconfig.build.json", "dev": "bun ../../scripts/dev.ts", "dev:clean": "bun ../../scripts/dev-clean.ts", diff --git a/packages/opencode/scripts/check-bundle-globals.ts b/packages/opencode/scripts/check-bundle-globals.ts new file mode 100644 index 00000000..b1c966a6 --- /dev/null +++ b/packages/opencode/scripts/check-bundle-globals.ts @@ -0,0 +1,35 @@ +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const bundlePath = join(import.meta.dir, '..', 'dist', 'index.js') +const minBundleBytes = 1024 + +let size: number +try { + size = (await stat(bundlePath)).size +} catch { + throw new Error(`Bundle artifact check failed: ${bundlePath} is missing`) +} + +if (size <= minBundleBytes) { + throw new Error( + `Bundle artifact check failed: ${bundlePath} is not substantial (${size} bytes)`, + ) +} + +const bundle = await readFile(bundlePath, 'utf8') +const registryMatches = bundle.match(/__anthropicAuthRpcServers/g)?.length ?? 0 +if (registryMatches === 0) { + throw new Error( + 'Bundle positive-control check failed: __anthropicAuthRpcServers is absent', + ) +} + +// This catches one identifier; the positive control makes its zero assertion meaningful, not proof that no other stale global exists. +const singularMatches = + bundle.match(/__anthropicAuthRpcServer(?!s)/g)?.length ?? 0 +if (singularMatches !== 0) { + throw new Error( + `Bundle stale-global check failed: __anthropicAuthRpcServer appears ${singularMatches} time(s)`, + ) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5822c771..922f019b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -181,7 +181,7 @@ import { stickyRouteFamilyForModel, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' -import type { Plugin } from '@opencode-ai/plugin' +import type { Hooks, Plugin } from '@opencode-ai/plugin' import { applyCacheDiagnosticsOptIn, @@ -214,7 +214,10 @@ import { LANE_START_REQUEST_HEADER, LaneStartTracker, } from './lane-start.ts' -import { adoptPrimeManager } from './prime-manager-registry.ts' +import { + adoptPrimeManager, + releasePrimeManager, +} from './prime-manager-registry.ts' import { resolvePromptContext } from './prompt-context.ts' import { formatKillswitchBlockMessage, @@ -2806,27 +2809,88 @@ const anthropicAuthPlugin = async ( } let rpcServer: RpcServerHandle | null = null + let rpcDir: string | null = null if (ctx.directory) { const rpcGlobal = globalThis as { - __anthropicAuthRpcServer?: RpcServerHandle + __anthropicAuthRpcServers?: Map } - if (rpcGlobal.__anthropicAuthRpcServer) { - await rpcGlobal.__anthropicAuthRpcServer.stop().catch(() => {}) - rpcGlobal.__anthropicAuthRpcServer = undefined + rpcDir = getRpcDir(ctx.directory) + const rpcServers = + rpcGlobal.__anthropicAuthRpcServers ?? new Map() + rpcGlobal.__anthropicAuthRpcServers = rpcServers + const previousRpcServer = rpcServers.get(rpcDir) + if (previousRpcServer) { + await previousRpcServer.stop().catch(() => {}) + rpcServers.delete(rpcDir) } try { rpcServer = await startRpcServer({ - dir: getRpcDir(ctx.directory), + dir: rpcDir, drain: drainNotifications, apply: applyCommand, }) - rpcGlobal.__anthropicAuthRpcServer = rpcServer + rpcServers.set(rpcDir, rpcServer) } catch (error) { logger.warn('rpc', 'failed to start', { error: error instanceof Error ? error.message : String(error), }) } } + const dispose: NonNullable = async () => { + try { + await quotaHeaderFeedRegistry?.dispose() + } catch (error) { + logger.warn('quota-header-feed', 'failed to dispose', { + error: error instanceof Error ? error.message : String(error), + }) + } + try { + claustrumCredentialCache?.close() + } catch (error) { + logger.warn('claustrum', 'failed to close credential cache', { + error: error instanceof Error ? error.message : String(error), + }) + } + // Per-instance background services must be torn down before the RPC + // guard so a disposed instance never leaves its timer running for the + // rest of the process. Each step is isolated: one failure cannot skip + // the others. + try { + fallbackManager.stopBackgroundRefresh() + } catch (error) { + logger.warn('fallback-background', 'failed to stop', { + error: error instanceof Error ? error.message : String(error), + }) + } + try { + cacheKeepManager.stop() + } catch (error) { + logger.warn('cachekeep', 'failed to stop', { + error: error instanceof Error ? error.message : String(error), + }) + } + try { + releasePrimeManager(accountStoragePath, ctx.directory ?? 'default') + } catch (error) { + logger.warn('prime', 'failed to release slot', { + error: error instanceof Error ? error.message : String(error), + }) + } + const rpcServers = ( + globalThis as { + __anthropicAuthRpcServers?: Map + } + ).__anthropicAuthRpcServers + if (!rpcServer || !rpcDir || rpcServers?.get(rpcDir) !== rpcServer) return + try { + await rpcServer.stop() + if (rpcServers.get(rpcDir) === rpcServer) rpcServers.delete(rpcDir) + } catch (error) { + logger.warn('rpc', 'failed to stop', { + error: error instanceof Error ? error.message : String(error), + }) + } + } // Remembers the last explicit routing decision so quota-only sidebar refreshes // (background main/fallback quota landing) do not reset the active account. @@ -7600,10 +7664,6 @@ const anthropicAuthPlugin = async ( return {} }, - dispose: async () => { - await quotaHeaderFeedRegistry?.dispose() - claustrumCredentialCache?.close() - }, methods: [ { label: 'Claude Pro/Max', @@ -7664,6 +7724,7 @@ const anthropicAuthPlugin = async ( }, ], }, + dispose, __primeManager: primeManager, __quotaManager: quotaManager, __persistFallbackQuotaErrorForTest: persistFallbackQuotaError, diff --git a/packages/opencode/src/prime-manager-registry.ts b/packages/opencode/src/prime-manager-registry.ts index 37be9444..87e98e64 100644 --- a/packages/opencode/src/prime-manager-registry.ts +++ b/packages/opencode/src/prime-manager-registry.ts @@ -52,3 +52,24 @@ export function adoptPrimeManager( }) return manager } + +// Each adopting slot owns its own release; releasing the last slot stops the +// shared manager so a disposed instance cannot leave a timer alive for a +// sibling project that still depends on the same storage path. +export function releasePrimeManager(storagePath: string, slot: string): void { + const fingerprint = primeStorageFingerprint(storagePath) + const entry = primeManagers.get(fingerprint) + if (entry) { + entry.slots.delete(slot) + if (entry.slots.size === 0) { + entry.manager.stop() + primeManagers.delete(fingerprint) + } + } + // A late release must not clobber a successor's slot mapping; the slot may + // already have been re-adopted under a different storage path, and the next + // adopt relies on this map to detach it from the previous owner. + if (slotFingerprints.get(slot) === fingerprint) { + slotFingerprints.delete(slot) + } +} diff --git a/packages/opencode/src/rpc/notifications.ts b/packages/opencode/src/rpc/notifications.ts index 1464063b..2db233bc 100644 --- a/packages/opencode/src/rpc/notifications.ts +++ b/packages/opencode/src/rpc/notifications.ts @@ -1,16 +1,25 @@ +import { logger } from '@cortexkit/anthropic-auth-core' + import type { OpenDialogPayload, RpcNotification } from './protocol' const QUEUE_CAP = 100 const TUI_CONNECTED_WINDOW_MS = 3_000 +// One queue serves every RPC server in the process, and a process can hold one server per +// project directory. Session ids are globally unique, so a notice that carries one reaches +// only the TUI polling for that session. A notice WITHOUT one broadcasts instead: every +// draining TUI receives it and one session's ack does not prune it for the others — which, +// once a process serves more than one project, would carry it across project boundaries. +// The producer boundary therefore requires a session id; the wire field stays optional so +// an older TUI still parses what it is sent. let queue: RpcNotification[] = [] let nextId = 1 -let lastDrainAtAny = 0 const lastDrainAtBySession = new Map() +let warnedAboutUnscopedDrain = false export function pushNotification( payload: OpenDialogPayload, - sessionId?: string, + sessionId: string, ): void { queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId }) if (queue.length > QUEUE_CAP) queue = queue.slice(queue.length - QUEUE_CAP) @@ -21,34 +30,35 @@ export function drainNotifications( sessionId?: string, ): RpcNotification[] { const now = Date.now() - lastDrainAtAny = now if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now) const matches = (n: RpcNotification) => - sessionId === undefined || - n.sessionId === undefined || - n.sessionId === sessionId + sessionId === undefined || n.sessionId === sessionId + if (sessionId === undefined && !warnedAboutUnscopedDrain) { + warnedAboutUnscopedDrain = true + logger.warn( + 'rpc.notifications', + 'drain arrived without a session id; delivery is unscoped and the queue is left intact', + ) + } if (lastReceivedId > 0) { queue = queue.filter((n) => { if (n.id > lastReceivedId) return true - if (sessionId === undefined) return false + if (sessionId === undefined) return true return n.sessionId !== sessionId }) } return queue.filter((n) => n.id > lastReceivedId && matches(n)) } -export function isTuiConnected(sessionId?: string): boolean { +export function isTuiConnected(sessionId: string): boolean { const now = Date.now() - if (sessionId !== undefined) { - const at = lastDrainAtBySession.get(sessionId) ?? 0 - return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS - } - return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS + const at = lastDrainAtBySession.get(sessionId) ?? 0 + return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS } export function resetNotificationsForTest(): void { queue = [] nextId = 1 - lastDrainAtAny = 0 lastDrainAtBySession.clear() + warnedAboutUnscopedDrain = false } diff --git a/packages/opencode/src/rpc/rpc-server.ts b/packages/opencode/src/rpc/rpc-server.ts index b886e300..cfdbfcee 100644 --- a/packages/opencode/src/rpc/rpc-server.ts +++ b/packages/opencode/src/rpc/rpc-server.ts @@ -1,5 +1,5 @@ import { randomBytes, timingSafeEqual } from 'node:crypto' -import { unlink } from 'node:fs/promises' +import { readFile, unlink } from 'node:fs/promises' import { createServer, type IncomingMessage, @@ -115,9 +115,16 @@ export async function startRpcServer( token, async stop() { await new Promise((resolve) => server.close(() => resolve())) - await unlink(join(options.dir, `port-${process.pid}.json`)).catch( - () => {}, - ) + try { + const portFile = join(options.dir, `port-${process.pid}.json`) + const current = JSON.parse(await readFile(portFile, 'utf8')) as { + port?: unknown + pid?: unknown + } + if (current.port === port && current.pid === process.pid) { + await unlink(portFile) + } + } catch {} }, } } diff --git a/packages/opencode/src/tests/dispose-cleanup.test.ts b/packages/opencode/src/tests/dispose-cleanup.test.ts new file mode 100644 index 00000000..1cdb3b3c --- /dev/null +++ b/packages/opencode/src/tests/dispose-cleanup.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + CacheKeepManager, + FallbackAccountManager, + PrimeManager, + type PrimeManagerOptions, + saveAccounts, +} from '@cortexkit/anthropic-auth-core' +import { adoptPrimeManager } from '../prime-manager-registry.ts' +// releasePrimeManager is intentionally imported via a dynamic import inside +// each prime test so its pre-fix absence (a missing export) fails only those +// tests rather than crashing the whole file at module load. +import { + createTimerTracking, + type PluginTimerOverrides, +} from './timer-tracking' + +// Spies installed on shared prototypes before the plugin factory runs; +// restored in afterEach so unrelated tests are not affected. +const cacheKeepStopSpy = mock(() => {}) +const originalCacheKeepStop = CacheKeepManager.prototype.stop +const originalFallbackStop = + FallbackAccountManager.prototype.stopBackgroundRefresh + +const timerTracking = createTimerTracking() +const { activeIntervals, disabledPluginTimerOverrides } = timerTracking + +let tempDir: string +const originalFetch = globalThis.fetch + +beforeEach(async () => { + timerTracking.reset() + cacheKeepStopSpy.mockReset() + CacheKeepManager.prototype.stop = + cacheKeepStopSpy as unknown as typeof CacheKeepManager.prototype.stop + FallbackAccountManager.prototype.stopBackgroundRefresh = mock( + () => {}, + ) as unknown as typeof FallbackAccountManager.prototype.stopBackgroundRefresh + const { installDefaultFetchMock } = await import('./test-fetch') + installDefaultFetchMock() + tempDir = await mkdtemp(join(tmpdir(), 'anthropic-dispose-test-')) + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( + tempDir, + 'anthropic-auth.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + tempDir, + 'sidebar-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + tempDir, + 'cachekeep-registry', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( + tempDir, + 'quota-header-feed', + ) + await saveAccounts(baseStorage()) +}) + +afterEach(async () => { + try { + CacheKeepManager.prototype.stop = originalCacheKeepStop + FallbackAccountManager.prototype.stopBackgroundRefresh = + originalFallbackStop + const currentFetch = globalThis.fetch as typeof fetch | undefined + if (currentFetch) globalThis.fetch = originalFetch + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + delete process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR + delete process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } + } finally { + expect(activeIntervals.size).toBe(0) + } +}) + +function baseStorage(): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + fallbackOn: [401, 403, 429], + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + } +} + +function createMockClient() { + return { + auth: { set: mock(() => Promise.resolve()) }, + session: { + promptAsync: mock((_input: unknown) => Promise.resolve()), + }, + } +} + +async function getPlugin( + timerOverrides?: PluginTimerOverrides, + directory?: string, +) { + const { AnthropicAuthPlugin } = await import('../index') + const defaultTimerOverrides = disabledPluginTimerOverrides() + return (await ( + AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + timers?: PluginTimerOverrides, + ) => ReturnType + )( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(), + ...(directory && { directory }), + }, + { ...defaultTimerOverrides, ...timerOverrides }, + )) as Promise +} + +describe('dispose stops per-instance background services', () => { + test('dispose clears the fallback background refresh interval', async () => { + const plugin = await getPlugin() + // startBackgroundRefresh sets a real interval via runtimeTimers.setInterval; + // disabledPluginTimerOverrides replaced those with no-op mocks, so the + // call count is what proves the manager wired up its timer. + expect(timerTracking.disabledIntervalCalls).toBeGreaterThanOrEqual(1) + const intervalCallsBeforeDispose = timerTracking.disabledIntervalCalls + + await plugin.dispose?.() + + // The fallbackManager stop path uses its own clearIntervalImpl (which + // runtimeOverrides provides); a fresh spy confirms the disposal happened. + const fallbackStopSpy = FallbackAccountManager.prototype + .stopBackgroundRefresh as unknown as { mock?: { calls: unknown[] } } + expect(fallbackStopSpy.mock?.calls.length ?? 0).toBeGreaterThanOrEqual(1) + // Disposing must not schedule any additional intervals for this instance. + expect(timerTracking.disabledIntervalCalls).toBe(intervalCallsBeforeDispose) + }) + + test('dispose calls cacheKeepManager.stop', async () => { + const plugin = await getPlugin() + cacheKeepStopSpy.mockClear() + + await plugin.dispose?.() + + expect(cacheKeepStopSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe('releasePrimeManager slot accounting', () => { + const storageOptions = (path: string): PrimeManagerOptions => ({ + storagePath: path, + getAccountFingerprint: async () => '0123456789abcdef', + loadStorage: async () => null, + refreshQuota: async () => ({ + quota: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: Date.now(), + }, + fresh: true, + }), + sendPrime: async () => ({ ok: true, status: 200, ms: 1 }), + recordSuccess: async () => ({ + count: 1, + inputTokens: 0, + outputTokens: 0, + since: Date.now(), + }), + }) + + async function importRelease(): Promise< + (storagePath: string, slot: string) => void + > { + const mod = await import('../prime-manager-registry.ts') + if (typeof mod.releasePrimeManager !== 'function') { + throw new Error('releasePrimeManager is not exported') + } + return mod.releasePrimeManager as ( + storagePath: string, + slot: string, + ) => void + } + + test('releasing one of two slots keeps the shared manager alive for the sibling', async () => { + const releasePrimeManager = await importRelease() + const path = join( + tmpdir(), + `prime-shared-${Date.now()}-${Math.random()}.json`, + ) + const first = adoptPrimeManager( + path, + () => new PrimeManager(storageOptions(path)), + { slot: 'slot-a', rebind: () => {} }, + ) + const second = adoptPrimeManager( + path, + () => { + throw new Error('same-path adoption should not construct a duplicate') + }, + { slot: 'slot-b', rebind: () => {} }, + ) + expect(second).toBe(first) + first.start() + + releasePrimeManager(path, 'slot-a') + + // The sibling still holds a slot, so the manager must still be present + // in the registry (verifiable by re-adopting the slot returns the same + // instance) AND must still be running. + expect(first.isStopped()).toBe(false) + const readopted = adoptPrimeManager( + path, + () => { + throw new Error('manager should still be adopted by slot-b') + }, + { slot: 'slot-b', rebind: () => {} }, + ) + expect(readopted).toBe(first) + + // Cleanup so the orphan slot-b does not leak into the next case. + releasePrimeManager(path, 'slot-b') + expect(first.isStopped()).toBe(true) + }) + + test('releasing the last slot evicts and stops the manager', async () => { + const releasePrimeManager = await importRelease() + const path = join( + tmpdir(), + `prime-last-slot-${Date.now()}-${Math.random()}.json`, + ) + const manager = adoptPrimeManager( + path, + () => new PrimeManager(storageOptions(path)), + { slot: 'slot-solo', rebind: () => {} }, + ) + manager.start() + + releasePrimeManager(path, 'slot-solo') + + expect(manager.isStopped()).toBe(true) + // A subsequent adoption must construct a brand-new manager (registry entry + // gone), which proves the slot bookkeeping cleared the entry. + let constructed = 0 + adoptPrimeManager( + path, + () => { + constructed += 1 + return new PrimeManager(storageOptions(path)) + }, + { slot: 'slot-solo', rebind: () => {} }, + ) + expect(constructed).toBe(1) + }) + + test('releasing an unknown slot is a no-op', async () => { + const releasePrimeManager = await importRelease() + const path = join( + tmpdir(), + `prime-unknown-${Date.now()}-${Math.random()}.json`, + ) + const manager = adoptPrimeManager( + path, + () => new PrimeManager(storageOptions(path)), + { slot: 'slot-known', rebind: () => {} }, + ) + manager.start() + + expect(() => releasePrimeManager(path, 'slot-does-not-exist')).not.toThrow() + expect(manager.isStopped()).toBe(false) + // Idempotent: second release of the same slot must also not throw. + releasePrimeManager(path, 'slot-known') + expect(() => releasePrimeManager(path, 'slot-known')).not.toThrow() + + // Cleanup: the manager is already stopped from the first 'slot-known' call. + }) + + test('a late release does not clobber a successor slot mapping', async () => { + const releasePrimeManager = await importRelease() + const pathX = join( + tmpdir(), + `prime-late-x-${Date.now()}-${Math.random()}.json`, + ) + const pathY = join( + tmpdir(), + `prime-late-y-${Date.now()}-${Math.random()}.json`, + ) + const pathZ = join( + tmpdir(), + `prime-late-z-${Date.now()}-${Math.random()}.json`, + ) + + // Instance A adopts slot D under path X. + const initialX = adoptPrimeManager( + pathX, + () => new PrimeManager(storageOptions(pathX)), + { slot: 'D', rebind: () => {} }, + ) + initialX.start() + + // The slot is later re-adopted under path Y; adoptPrimeManager detaches + // it from the pathX entry and stops/evicts that entry. From here on, + // slot D belongs to pathY. + const managerY = adoptPrimeManager( + pathY, + () => new PrimeManager(storageOptions(pathY)), + { slot: 'D', rebind: () => {} }, + ) + managerY.start() + expect(initialX.isStopped()).toBe(true) + + // Instance A's dispose arrives late and calls release for (pathX, D). + // The pathX entry is already gone, so the only effect should be on + // slotFingerprints — and ONLY if the slot still points at pathX. With + // the unconditional delete, this clobbers pathY's mapping for D. + releasePrimeManager(pathX, 'D') + + // Now adopt slot D under path Z. The previous-fingerprint lookup must + // still see pathY so adoptPrimeManager detaches D from managerY before + // binding it to a new owner. + const managerZ = adoptPrimeManager( + pathZ, + () => new PrimeManager(storageOptions(pathZ)), + { slot: 'D', rebind: () => {} }, + ) + managerZ.start() + + // Per-key assertion: the pathY entry must have detached D, leaving it + // empty (no other slots were adopted there) so it stopped and was + // evicted from the registry. + expect(managerY.isStopped()).toBe(true) + // A fresh adoption under path Y must construct a brand-new manager + // rather than reusing managerY — that proves the entry was evicted. + let constructedUnderY = 0 + adoptPrimeManager( + pathY, + () => { + constructedUnderY += 1 + return new PrimeManager(storageOptions(pathY)) + }, + { slot: 're-entry', rebind: () => {} }, + ) + expect(constructedUnderY).toBe(1) + + // Cleanup so subsequent tests are not affected. + releasePrimeManager(pathZ, 'D') + releasePrimeManager(pathY, 're-entry') + }) +}) diff --git a/packages/opencode/src/tests/rpc-multi-project.test.ts b/packages/opencode/src/tests/rpc-multi-project.test.ts new file mode 100644 index 00000000..1ce0a9f4 --- /dev/null +++ b/packages/opencode/src/tests/rpc-multi-project.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createEmptyStorage, + QuotaHeaderFeedRegistry, + saveAccounts, +} from '@cortexkit/anthropic-auth-core' +import type { Hooks } from '@opencode-ai/plugin' +import { AnthropicAuthPlugin } from '../index' +import { resetNotificationsForTest } from '../rpc/notifications' +import { discoverPortFile } from '../rpc/port-file' +import { getRpcDir } from '../rpc/rpc-dir' +import type { RpcServerHandle } from '../rpc/rpc-server' + +type RpcGlobal = typeof globalThis & { + __anthropicAuthRpcServers?: Map +} + +let testRoot: string +let previousRpcDir: string | undefined +let previousAccountFile: string | undefined +let previousSidebarStateFile: string | undefined +let previousCacheKeepRegistryDir: string | undefined +let previousQuotaFeedDir: string | undefined +let startedRpcDirs: Set + +const disabledPluginRuntimeOverrides = { + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, +} + +function createMockClient(applyMarker?: string) { + return { + auth: { set: mock(() => Promise.resolve()) }, + session: { + promptAsync: mock(() => + applyMarker + ? Promise.reject(new Error(applyMarker)) + : Promise.resolve(), + ), + }, + } +} + +async function getPlugin( + directory: string, + applyMarker?: string, +): Promise { + const plugin = AnthropicAuthPlugin as unknown as ( + ctx: Parameters[0], + runtimeOverrides: typeof disabledPluginRuntimeOverrides, + ) => ReturnType + startedRpcDirs.add(getRpcDir(directory)) + return plugin( + { + // @ts-expect-error: minimal mock for testing + client: createMockClient(applyMarker), + directory, + }, + disabledPluginRuntimeOverrides, + ) +} + +async function applyViaRpc( + entry: { port: number; token: string }, + sessionId: string, +) { + const response = await fetch(`http://127.0.0.1:${entry.port}/rpc/apply`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${entry.token}`, + }, + body: JSON.stringify({ + command: 'claude-start', + arguments: '', + sessionId, + }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { text: string } +} + +async function stopRpcServers() { + const rpcGlobal = globalThis as RpcGlobal + const servers = rpcGlobal.__anthropicAuthRpcServers + const handles = new Set(servers?.values() ?? []) + await Promise.all([...handles].map((server) => server.stop())) + if (servers) { + servers.clear() + rpcGlobal.__anthropicAuthRpcServers = undefined + } +} + +beforeEach(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'aa-rpc-multi-project-')) + startedRpcDirs = new Set() + previousRpcDir = process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR + previousAccountFile = process.env.OPENCODE_ANTHROPIC_AUTH_FILE + previousSidebarStateFile = + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + previousCacheKeepRegistryDir = + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR + previousQuotaFeedDir = process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = '.rpc' + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( + testRoot, + 'anthropic-auth.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = join( + testRoot, + 'sidebar-state.json', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = join( + testRoot, + 'cachekeep-registry', + ) + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( + testRoot, + 'quota-header-feed', + ) + await stopRpcServers() +}) + +afterEach(async () => { + await stopRpcServers() + for (const rpcDir of startedRpcDirs) { + expect( + (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get(rpcDir), + ).toBeUndefined() + expect(await discoverPortFile(rpcDir)).toBeNull() + } + if (previousRpcDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_RPC_DIR = previousRpcDir + } + if (previousAccountFile === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = previousAccountFile + } + if (previousSidebarStateFile === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = + previousSidebarStateFile + } + if (previousCacheKeepRegistryDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_CACHEKEEP_REGISTRY_DIR = + previousCacheKeepRegistryDir + } + if (previousQuotaFeedDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = previousQuotaFeedDir + } + await rm(testRoot, { recursive: true, force: true }) + resetNotificationsForTest() +}) + +describe('RPC server lifecycle', () => { + test('dispose stops and removes its server when feed cleanup rejects', async () => { + await saveAccounts({ + ...createEmptyStorage(), + quotaHeaderFeed: { enabled: true }, + }) + const originalDispose = QuotaHeaderFeedRegistry.prototype.dispose + QuotaHeaderFeedRegistry.prototype.dispose = async () => { + throw new Error('feed disposal failed') + } + try { + const directory = join(testRoot, 'project') + const plugin = await getPlugin(directory) + const rpcDir = getRpcDir(directory) + const entry = await discoverPortFile(rpcDir) + + expect(entry).not.toBeNull() + await plugin.dispose?.() + + expect(await discoverPortFile(rpcDir)).toBeNull() + expect( + (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get(rpcDir), + ).toBeUndefined() + await expect( + fetch(`http://127.0.0.1:${entry?.port}/health`), + ).rejects.toThrow() + } finally { + QuotaHeaderFeedRegistry.prototype.dispose = originalDispose + } + }) + + test('keeps RPC servers live for distinct project directories', async () => { + const directoryA = join(testRoot, 'project-a') + const directoryB = join(testRoot, 'project-b') + + await getPlugin(directoryA) + await getPlugin(directoryB) + + const entryA = await discoverPortFile(getRpcDir(directoryA)) + const entryB = await discoverPortFile(getRpcDir(directoryB)) + + expect(entryA).not.toBeNull() + expect(entryB).not.toBeNull() + expect(entryA?.port).not.toBe(entryB?.port) + }) + + test('each project RPC server applies through its own plugin instance', async () => { + const directoryA = join(testRoot, 'project-a') + const directoryB = join(testRoot, 'project-b') + await getPlugin(directoryA, 'applied by project-a') + await getPlugin(directoryB, 'applied by project-b') + + const entryA = await discoverPortFile(getRpcDir(directoryA)) + const entryB = await discoverPortFile(getRpcDir(directoryB)) + + expect(entryA).not.toBeNull() + expect(entryB).not.toBeNull() + if (!entryA || !entryB) return + expect( + JSON.parse( + await readFile( + join(getRpcDir(directoryA), `port-${process.pid}.json`), + 'utf8', + ), + ), + ).toMatchObject({ port: entryA.port, token: entryA.token }) + expect( + JSON.parse( + await readFile( + join(getRpcDir(directoryB), `port-${process.pid}.json`), + 'utf8', + ), + ), + ).toMatchObject({ port: entryB.port, token: entryB.token }) + + expect((await applyViaRpc(entryA, 'session-a')).text).toContain( + 'applied by project-a', + ) + expect((await applyViaRpc(entryB, 'session-b')).text).toContain( + 'applied by project-b', + ) + }) + + test('dispose stops its directory while another project remains live', async () => { + const directoryA = join(testRoot, 'project-a') + const directoryB = join(testRoot, 'project-b') + const pluginA = await getPlugin(directoryA) + const pluginB = await getPlugin(directoryB) + const entryB = await discoverPortFile(getRpcDir(directoryB)) + + expect(entryB).not.toBeNull() + expect(pluginA.dispose).toBeFunction() + await pluginA.dispose?.() + + expect(await discoverPortFile(getRpcDir(directoryA))).toBeNull() + expect((await discoverPortFile(getRpcDir(directoryB)))?.port).toBe( + entryB?.port, + ) + expect( + (await fetch(`http://127.0.0.1:${entryB?.port}/health`)).status, + ).toBe(200) + await pluginB.dispose?.() + }) + + test('late disposal cannot remove a same-directory successor port file', async () => { + const directory = join(testRoot, 'project') + const first = await getPlugin(directory) + const second = await getPlugin(directory) + const successor = await discoverPortFile(getRpcDir(directory)) + const successorHandle = ( + globalThis as RpcGlobal + ).__anthropicAuthRpcServers?.get(getRpcDir(directory)) + + expect(successor).not.toBeNull() + expect(successorHandle).toBeDefined() + await first.dispose?.() + + expect( + (globalThis as RpcGlobal).__anthropicAuthRpcServers?.get( + getRpcDir(directory), + ), + ).toBe(successorHandle) + expect((await discoverPortFile(getRpcDir(directory)))?.port).toBe( + successor?.port, + ) + await second.dispose?.() + }) + + test('a dispose whose entry was replaced does not stop the successor server', async () => { + const directory = join(testRoot, 'project') + const first = await getPlugin(directory) + const rpcGlobal = globalThis as RpcGlobal + const rpcDir = getRpcDir(directory) + const firstHandle = rpcGlobal.__anthropicAuthRpcServers?.get(rpcDir) + const successorHandle: RpcServerHandle = { + port: firstHandle?.port ?? 0, + token: firstHandle?.token ?? '', + stop: mock(async () => {}), + } + + expect(firstHandle).toBeDefined() + if (!firstHandle) return + const stopSpy = mock(firstHandle.stop) + firstHandle.stop = stopSpy + rpcGlobal.__anthropicAuthRpcServers?.set(rpcDir, successorHandle) + + await first.dispose?.() + + // D2's port-file check would otherwise mask loss of D1. + expect(stopSpy).not.toHaveBeenCalled() + expect(rpcGlobal.__anthropicAuthRpcServers?.get(rpcDir)).toBe( + successorHandle, + ) + // Dispose refused to stop D1 by design; the spy wraps the real stop, so + // invoking it clears the dangling server and its port file before afterEach. + await stopSpy() + }) + + test('a disposed project can start a discoverable RPC server again', async () => { + const directory = join(testRoot, 'project') + const first = await getPlugin(directory) + + await first.dispose?.() + + const replacement = await getPlugin(directory) + const entry = await discoverPortFile(getRpcDir(directory)) + expect(entry).not.toBeNull() + expect((await fetch(`http://127.0.0.1:${entry?.port}/health`)).status).toBe( + 200, + ) + await replacement.dispose?.() + }) +}) diff --git a/packages/opencode/src/tests/rpc-notifications.test.ts b/packages/opencode/src/tests/rpc-notifications.test.ts index e79fd00e..c5df7076 100644 --- a/packages/opencode/src/tests/rpc-notifications.test.ts +++ b/packages/opencode/src/tests/rpc-notifications.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, test } from 'bun:test' +import { + __setLogTestSink, + type LogTestRecord, +} from '@cortexkit/anthropic-auth-core' import { drainNotifications, isTuiConnected, @@ -16,6 +20,78 @@ const payload = (command: OpenDialogPayload['command']): OpenDialogPayload => ({ describe('notifications', () => { beforeEach(() => resetNotificationsForTest()) + test('warns once when an unscoped drain leaves the queue intact', () => { + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + try { + drainNotifications(0) + drainNotifications(0) + expect( + records.filter( + (record) => + record.level === 'warn' && + record.message.includes('drain arrived without a session id'), + ), + ).toHaveLength(1) + } finally { + __setLogTestSink(null) + } + }) + + test('an unscoped drain delivers every pending notice', () => { + pushNotification(payload('claude-quota'), 's1') + pushNotification(payload('claude-dump'), 's2') + + expect( + drainNotifications(0, undefined).map((n) => n.payload.command), + ).toEqual(['claude-quota', 'claude-dump']) + }) + + test('an unscoped drain acknowledges without pruning other sessions', () => { + pushNotification(payload('claude-quota'), 's1') + pushNotification(payload('claude-dump'), 's2') + + // Acknowledged notices are not re-delivered to the client that acked them, + // and an unscoped ack must not speak for the sessions it does not name. + expect(drainNotifications(2, undefined)).toEqual([]) + expect(drainNotifications(0, 's2').map((n) => n.payload.command)).toEqual([ + 'claude-dump', + ]) + expect(drainNotifications(0, 's1').map((n) => n.payload.command)).toEqual([ + 'claude-quota', + ]) + }) + + test('reset re-arms the unscoped-drain warning after an earlier drain', () => { + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + try { + drainNotifications(0) + resetNotificationsForTest() + drainNotifications(0) + expect( + records.filter( + (record) => + record.level === 'warn' && + record.message.includes('drain arrived without a session id'), + ), + ).toHaveLength(2) + } finally { + __setLogTestSink(null) + } + }) + + test('a session-scoped drain prunes its own acknowledged notices', () => { + pushNotification(payload('claude-quota'), 's1') + pushNotification(payload('claude-dump'), 's2') + + const s1 = drainNotifications(0, 's1') + expect(drainNotifications(s1[0]?.id, 's1')).toEqual([]) + expect(drainNotifications(0, 's2').map((n) => n.payload.command)).toEqual([ + 'claude-dump', + ]) + }) + test('push then drain returns the item once, ordered', () => { pushNotification(payload('claude-quota'), 's1') pushNotification(payload('claude-fast'), 's1') @@ -29,12 +105,14 @@ describe('notifications', () => { expect(second).toEqual([]) }) - test('session scoping: a session only drains its own + global', () => { + test('every queued notice carries its session id and stays scoped to it', () => { pushNotification(payload('claude-quota'), 's1') pushNotification(payload('claude-dump'), 's2') - expect(drainNotifications(0, 's1').map((n) => n.payload.command)).toEqual([ - 'claude-quota', - ]) + const s1 = drainNotifications(0, 's1') + + expect(s1).toHaveLength(1) + expect(s1[0]?.sessionId).toBe('s1') + expect(s1.map((n) => n.payload.command)).toEqual(['claude-quota']) expect(drainNotifications(0, 's2').map((n) => n.payload.command)).toEqual([ 'claude-dump', ]) @@ -46,6 +124,14 @@ describe('notifications', () => { expect(isTuiConnected('s1')).toBe(true) }) + test('a drain only marks its own session as connected', () => { + drainNotifications(0, 's2') + expect(isTuiConnected('s1')).toBe(false) + expect(isTuiConnected('s2')).toBe(true) + // @ts-expect-error isTuiConnected requires a session id + expect(isTuiConnected()).toBe(false) + }) + test('queue cap evicts oldest beyond 100', () => { for (let i = 0; i < 130; i++) pushNotification(payload('claude-quota'), 's1') @@ -53,15 +139,8 @@ describe('notifications', () => { expect(all.length).toBe(100) }) - test('a global notification reaches every session and is not pruned by one ack', () => { - // push a global (no sessionId) notification + test('pushNotification requires a session id at compile time', () => { + // @ts-expect-error pushNotification requires a session id pushNotification(payload('claude-quota')) - const a = drainNotifications(0, 's1') - expect(a.length).toBe(1) - // s1 acks it - drainNotifications(a[0]?.id as number, 's1') - // s2 must STILL receive it - const b = drainNotifications(0, 's2') - expect(b.length).toBe(1) }) }) diff --git a/packages/opencode/src/tests/rpc-server.test.ts b/packages/opencode/src/tests/rpc-server.test.ts index d0e2081b..5e256016 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -7,6 +7,7 @@ import { pushNotification, resetNotificationsForTest, } from '../rpc/notifications' +import { discoverPortFile } from '../rpc/port-file' import { startRpcServer } from '../rpc/rpc-server' let stop: (() => Promise) | null = null @@ -174,4 +175,26 @@ describe('rpc-server', () => { process.removeListener('uncaughtException', onUnhandled) expect(unhandledError).toBeNull() }) + + test('stopping a stale server preserves its successor port file', async () => { + dir = await mkdtemp(join(tmpdir(), 'aa-rpcsrv-')) + const first = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + const second = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => ({ text: 'ok', knobs: {} }), + }) + stop = second.stop + + await first.stop() + + expect((await discoverPortFile(dir))?.port).toBe(second.port) + expect((await fetch(`http://127.0.0.1:${second.port}/health`)).status).toBe( + 200, + ) + }) })