-
Notifications
You must be signed in to change notification settings - Fork 7
fix(rpc): scope loopback servers by project #145
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -1004,7 +1004,8 @@ export async function CodexAuthPlugin( | |
| // command.execute.before reads this; if null (auth not loaded yet), | ||
| // the command is rejected with a message. | ||
| let cmdCtx: CommandContext | null = null | ||
| let activeRpcServer: RpcServerHandle | null = null | ||
| const ownedCacheKeepManagers = new Map<string, CacheKeepManager>() | ||
| const ownedRpcServers = new Map<string, RpcServerHandle>() | ||
| let sidebarStateFileForEvents: string | undefined | ||
|
|
||
| // Per-loader poller: each plugin invocation owns its timer and callback, so | ||
|
|
@@ -1041,16 +1042,29 @@ export async function CodexAuthPlugin( | |
| backgroundQuotaRefresh.stop() | ||
| for (const websocketFetch of websocketFetches) websocketFetch.close() | ||
| websocketFetches.length = 0 | ||
| if (activeRpcServer) { | ||
| await activeRpcServer.stop().catch(() => {}) | ||
| const rpcGlobal = globalThis as { | ||
| __openaiAuthRpcServer?: RpcServerHandle | ||
| const cacheKeepGlobal = globalThis as { | ||
| __openaiAuthCacheKeepManagers?: Map<string, CacheKeepManager> | ||
| } | ||
| for (const [key, manager] of ownedCacheKeepManagers) { | ||
| if ( | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManagers?.get(key) === manager | ||
| ) { | ||
| manager.stop() | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManagers.delete(key) | ||
| } | ||
| if (rpcGlobal.__openaiAuthRpcServer === activeRpcServer) { | ||
| rpcGlobal.__openaiAuthRpcServer = undefined | ||
| } | ||
| ownedCacheKeepManagers.clear() | ||
|
|
||
| const rpcGlobal = globalThis as { | ||
| __openaiAuthRpcServers?: Map<string, RpcServerHandle> | ||
| } | ||
| for (const [key, rpcServer] of ownedRpcServers) { | ||
| if (rpcGlobal.__openaiAuthRpcServers?.get(key) === rpcServer) { | ||
| await rpcServer.stop().catch(() => {}) | ||
| rpcGlobal.__openaiAuthRpcServers.delete(key) | ||
| } | ||
| activeRpcServer = null | ||
| } | ||
| ownedRpcServers.clear() | ||
| }, | ||
| async event(input) { | ||
| if (input.event.type !== 'session.deleted') return | ||
|
|
@@ -1179,6 +1193,11 @@ export async function CodexAuthPlugin( | |
| const auth = await getAuth() | ||
| if (auth.type !== 'oauth') return {} | ||
|
|
||
| const rpcDir = input.directory | ||
| ? await resolveRpcDir(input.directory) | ||
| : undefined | ||
| const cacheKeepKey = rpcDir?.dir ?? getConfigPath() | ||
|
|
||
| // Migration: seed the multi-account store from the existing token (idempotent) | ||
| await migrateIfNeeded( | ||
| { | ||
|
|
@@ -1523,9 +1542,12 @@ export async function CodexAuthPlugin( | |
| return mainRefreshPromise | ||
| } | ||
| const cacheKeepGlobal = globalThis as { | ||
| __openaiAuthCacheKeepManager?: CacheKeepManager | ||
| __openaiAuthCacheKeepManagers?: Map<string, CacheKeepManager> | ||
| } | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManager?.stop() | ||
| const cacheKeepManagers = | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManagers ?? new Map() | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManagers = cacheKeepManagers | ||
| cacheKeepManagers.get(cacheKeepKey)?.stop() | ||
| const cacheKeepManager = new CacheKeepManager({ | ||
| fetchImpl: fetch, | ||
| getMainToken: async () => { | ||
|
|
@@ -1563,7 +1585,8 @@ export async function CodexAuthPlugin( | |
| getWindow: () => cacheKeepWindow, | ||
| getSustain: () => cacheKeepSustain, | ||
| }) | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManager = cacheKeepManager | ||
| cacheKeepManagers.set(cacheKeepKey, cacheKeepManager) | ||
| ownedCacheKeepManagers.set(cacheKeepKey, cacheKeepManager) | ||
|
|
||
| async function pushQuota( | ||
| snapshot: Record<string, unknown>, | ||
|
|
@@ -1905,14 +1928,16 @@ export async function CodexAuthPlugin( | |
| } | ||
|
|
||
| let rpcServer: RpcServerHandle | null = null | ||
| if (input.directory) { | ||
| const rpcDir = await resolveRpcDir(input.directory) | ||
| if (rpcDir) { | ||
| const rpcGlobal = globalThis as { | ||
| __openaiAuthRpcServer?: RpcServerHandle | ||
| __openaiAuthRpcServers?: Map<string, RpcServerHandle> | ||
| } | ||
| if (rpcGlobal.__openaiAuthRpcServer) { | ||
| await rpcGlobal.__openaiAuthRpcServer.stop().catch(() => {}) | ||
| rpcGlobal.__openaiAuthRpcServer = undefined | ||
| const rpcServers = rpcGlobal.__openaiAuthRpcServers ?? new Map() | ||
| rpcGlobal.__openaiAuthRpcServers = rpcServers | ||
| const existingRpcServer = rpcServers.get(rpcDir.dir) | ||
| if (existingRpcServer) { | ||
| await existingRpcServer.stop().catch(() => {}) | ||
| rpcServers.delete(rpcDir.dir) | ||
| } | ||
| try { | ||
| rpcServer = await startRpcServer({ | ||
|
|
@@ -1934,8 +1959,8 @@ export async function CodexAuthPlugin( | |
| return { text: payload.text, knobs: payload.knobs } | ||
| }, | ||
| }) | ||
| rpcGlobal.__openaiAuthRpcServer = rpcServer | ||
| activeRpcServer = rpcServer | ||
| rpcServers.set(rpcDir.dir, rpcServer) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
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 two loaders for one directory start concurrently, both can pass the empty-map check before either asynchronous Prompt for AI agents |
||
| ownedRpcServers.set(rpcDir.dir, rpcServer) | ||
| } catch { | ||
| // RPC is best-effort; the plugin must not fail if the port file | ||
| // can't be written (e.g. missing directory in test environments). | ||
|
|
@@ -3410,26 +3435,6 @@ export async function CodexAuthPlugin( | |
| ).catch(() => {}) | ||
| return finalResponse | ||
| }, | ||
| async dispose() { | ||
| backgroundQuotaRefresh.stop() | ||
| cacheKeepManager.stop() | ||
| if ( | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManager === cacheKeepManager | ||
| ) { | ||
| cacheKeepGlobal.__openaiAuthCacheKeepManager = undefined | ||
| } | ||
| fallbackManager.stopBackgroundRefresh() | ||
| if (activeRpcServer) { | ||
| await activeRpcServer.stop().catch(() => {}) | ||
| const rpcGlobal = globalThis as { | ||
| __openaiAuthRpcServer?: RpcServerHandle | ||
| } | ||
| if (rpcGlobal.__openaiAuthRpcServer === activeRpcServer) { | ||
| rpcGlobal.__openaiAuthRpcServer = undefined | ||
| } | ||
| activeRpcServer = null | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| methods: [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
|
|
@@ -78,6 +78,7 @@ export async function startRpcServer( | |
| // every endpoint holding a dead connection for 90s. | ||
| const handlerTimeoutMs = options.timeoutMs ?? 90_000 | ||
| const receiptTimeoutMs = options.receiptTimeoutMs ?? 2_000 | ||
| let warnedMissingNotificationSession = false | ||
| const server = createServer((req, res) => { | ||
| req.setTimeout(handlerTimeoutMs, () => { | ||
| req.socket.destroy() | ||
|
|
@@ -107,9 +108,17 @@ export async function startRpcServer( | |
| const body = await readBody(req) | ||
| const params = JSON.parse(body || '{}') as Record<string, unknown> | ||
| if (method === 'pending-notifications') { | ||
| const sessionId = | ||
| typeof params.sessionId === 'string' ? params.sessionId : undefined | ||
| if (sessionId === undefined && !warnedMissingNotificationSession) { | ||
| warnedMissingNotificationSession = true | ||
| log.warn('rpc notification drain missing session id', { | ||
| pid: process.pid, | ||
| }) | ||
| } | ||
| const messages = options.drain( | ||
| Number(params.lastReceivedId ?? 0), | ||
| typeof params.sessionId === 'string' ? params.sessionId : undefined, | ||
| sessionId, | ||
| ) | ||
| return json(200, { messages }) | ||
| } | ||
|
|
@@ -164,9 +173,12 @@ export async function startRpcServer( | |
| token, | ||
| async stop() { | ||
| await new Promise<void>((resolve) => server.close(() => resolve())) | ||
| await unlink(join(options.dir, `port-${process.pid}.json`)).catch( | ||
| () => {}, | ||
| ) | ||
| const portFile = join(options.dir, `port-${process.pid}.json`) | ||
| 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) | ||
|
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 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 Prompt for AI agents |
||
| await unlink(portFile).catch(() => {}) | ||
| }, | ||
| } | ||
| } | ||
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.
P2: When an OAuth loader has fallback accounts, disposing the plugin leaves its
FallbackAccountManagerinterval running. Track each fallback manager and callstopBackgroundRefresh()during disposal.Prompt for AI agents