-
Notifications
You must be signed in to change notification settings - Fork 16
fix(opencode): stop per-instance background services on dispose #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ce5104
9d94623
9b63bbe
353d158
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)`, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live Prompt for AI agents |
||
| 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) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, number>() | ||
| 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a legacy TUI polls without Prompt for AI agents |
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: After the auth loader starts
mainBackgroundRefreshTimer, disposing this plugin never clears it. ClearmainBackgroundRefreshTimerwithruntimeTimers.clearIntervaland set it tonullduring disposal so reloads do not leave stale auth refresh workers running.Prompt for AI agents