From 1e8d09aaf7d17a4ff6ef82f6c280c4e2c0ffdfc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:12:46 -0700 Subject: [PATCH] feat(orcad): per-feature readiness latches and requires: on RPC methods (#35571) Adds OrcadFeatureReadiness (accounts, sessionSearch, orchestration, unshownPlacement) and a requires: field on defineMethod. The dispatcher waits a bounded 10 s for a method's features, then refuses with the existing retryable runtime_unavailable. terminal.adoptOrphans waits for unshown placement; reconcile-terminals stays on the critical path. Every latch opens where its phase finishes today, before RPC starts, so behaviour is unchanged. --- src/main/orcad/orcad-entry.ts | 13 ++- .../orcad-restored-terminal-authority.test.ts | 39 +++++++ .../orcad-restored-terminal-authority.ts | 21 ++++ src/main/runtime/rpc/core.ts | 6 + .../rpc/dispatcher-feature-readiness.test.ts | 107 ++++++++++++++++++ src/main/runtime/rpc/dispatcher.ts | 14 +++ src/main/runtime/rpc/methods/accounts.ts | 15 ++- src/main/runtime/rpc/methods/ai-vault.ts | 3 + src/main/runtime/rpc/methods/orchestration.ts | 6 +- .../runtime/rpc/methods/terminal-orphan.ts | 2 + .../orcad-feature-readiness-manifest.test.ts | 73 ++++++++++++ .../rpc/orcad-feature-readiness.test.ts | 48 ++++++++ .../runtime/rpc/orcad-feature-readiness.ts | 102 +++++++++++++++++ 13 files changed, 440 insertions(+), 9 deletions(-) create mode 100644 src/main/orcad/orcad-restored-terminal-authority.test.ts create mode 100644 src/main/orcad/orcad-restored-terminal-authority.ts create mode 100644 src/main/runtime/rpc/dispatcher-feature-readiness.test.ts create mode 100644 src/main/runtime/rpc/orcad-feature-readiness-manifest.test.ts create mode 100644 src/main/runtime/rpc/orcad-feature-readiness.test.ts create mode 100644 src/main/runtime/rpc/orcad-feature-readiness.ts diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 99ccb07a41f..b02185a2c26 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -29,7 +29,8 @@ import { installOrcadProcessErrorGuards } from './orcad-process-error-guards' import { orcadWsPortOptions } from './orcad-ws-port-policy' import { createOrcadStartupTiming } from './orcad-startup-timing' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' -import { placeUnshownLiveTerminalsBestEffort } from '../runtime/terminal/adoption/unshown-live-terminal-placement' +import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority' +import { installOrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness' export { parseArgs } @@ -178,6 +179,8 @@ async function startOrcadRuntime( registerCleanup ) + // Gates open where each phase finishes today; RPC starts after all of them (no behavior change). + const featureReadiness = installOrcadFeatureReadiness() const { createOrcadAccountServices } = await import('./orcad-account-services') const accountLaunch = createOrcadAccountServices({ store: profileStore, @@ -224,6 +227,7 @@ async function startOrcadRuntime( // Restore prior desktop-pushed account selection before automations or CLI launches. const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store') restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath) + featureReadiness.open('accounts') const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ @@ -231,6 +235,7 @@ async function startOrcadRuntime( getSettings: () => profileStore.getSettings() }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) + featureReadiness.open('sessionSearch') const { startAgentStateRulesLiveUpdates } = await import('../runtime/agent-state-rules/agent-state-rules-live-update') @@ -266,11 +271,7 @@ async function startOrcadRuntime( // Recover browser/orchestration authority before clients attach to the runtime graph. runtime.rehydrateClientHostedBrowserPages() - await timed('orchestration', () => runtime.refreshRestoredOrchestrationAuthority()) - await timed('reconcile-terminals', () => runtime.reconcileLegacyWorkerTerminals()) - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - await placeUnshownLiveTerminalsBestEffort(runtime) - } + await restoreOrcadTerminalAuthority(runtime, timed, featureReadiness) runtime.agentTransitions.seedRestoredRows() void runtime.sidebarFacts?.reconcileSortStampTiers(getLocalPtyProvider()) diff --git a/src/main/orcad/orcad-restored-terminal-authority.test.ts b/src/main/orcad/orcad-restored-terminal-authority.test.ts new file mode 100644 index 00000000000..900cdaeb13c --- /dev/null +++ b/src/main/orcad/orcad-restored-terminal-authority.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness' + +const steps = vi.hoisted(() => new Array()) +vi.mock('../runtime/terminal/adoption/unshown-live-terminal-placement', () => ({ + placeUnshownLiveTerminalsBestEffort: async () => { + steps.push('placement') + } +})) +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority' + +describe('restoreOrcadTerminalAuthority', () => { + it('opens each latch only once its phase is done, with reconcile ungated in between', async () => { + const readiness = new OrcadFeatureReadiness(['orchestration', 'unshownPlacement']) + const latches = (): string => + `orchestration=${readiness.isOpen('orchestration')} placement=${readiness.isOpen('unshownPlacement')}` + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the two restore phases are called. + const runtime = { + refreshRestoredOrchestrationAuthority: async () => { + steps.push(`orchestration ${latches()}`) + }, + reconcileLegacyWorkerTerminals: async () => { + steps.push(`reconcile ${latches()}`) + } + } as unknown as OrcaRuntimeService + + await restoreOrcadTerminalAuthority(runtime, (_name, run) => run(), readiness) + + expect(steps).toEqual([ + 'orchestration orchestration=false placement=false', + 'reconcile orchestration=true placement=false', + 'placement' + ]) + expect(readiness.isOpen('unshownPlacement')).toBe(true) + }) +}) diff --git a/src/main/orcad/orcad-restored-terminal-authority.ts b/src/main/orcad/orcad-restored-terminal-authority.ts new file mode 100644 index 00000000000..8cc5e1fa736 --- /dev/null +++ b/src/main/orcad/orcad-restored-terminal-authority.ts @@ -0,0 +1,21 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness' +import { placeUnshownLiveTerminalsBestEffort } from '../runtime/terminal/adoption/unshown-live-terminal-placement' +import type { createOrcadStartupTiming } from './orcad-startup-timing' + +/** Restored orchestration authority, the legacy terminal reconcile and unshown placement, in order. */ +export async function restoreOrcadTerminalAuthority( + runtime: OrcaRuntimeService, + timed: ReturnType['time'], + readiness: OrcadFeatureReadiness +): Promise { + await timed('orchestration', () => runtime.refreshRestoredOrchestrationAuthority()) + readiness.open('orchestration') + // Terminal inventory stays on the critical path: no latch, every client needs it at connect. + await timed('reconcile-terminals', () => runtime.reconcileLegacyWorkerTerminals()) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await placeUnshownLiveTerminalsBestEffort(runtime) + } + readiness.open('unshownPlacement') +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 87124c250bb..738f5bb6526 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -23,6 +23,7 @@ import type { RpcCallerIdentity } from './rpc-caller-identity' import type { OrcadPeerDesktopsResult } from '../../../shared/orcad-peer-desktops-contract' import type { RpcBinarySender } from './rpc-binary-sender' import type { RpcMethodPermission } from './rpc-method-permission' +import type { OrcadFeature } from './orcad-feature-readiness' import type { RegisterDeviceKeyParams, RegisterDeviceKeyResult @@ -178,6 +179,8 @@ export type RpcTypedMethod, ctx: RpcContext) => TResult } @@ -192,6 +195,7 @@ export type RpcTypedStreamingMethod, @@ -210,6 +214,7 @@ type RpcMethodWithParams

= { readonly name: string readonly permission: RpcMethodPermission readonly params: ZodType | null + readonly requires?: readonly OrcadFeature[] readonly handler: (params: P, ctx: RpcContext) => unknown } @@ -217,6 +222,7 @@ type RpcStreamingMethodWithParams

= { readonly name: string readonly permission: RpcMethodPermission readonly params: ZodType | null + readonly requires?: readonly OrcadFeature[] readonly stream: true readonly handler: (params: P, ctx: RpcContext, emit: (result: unknown) => void) => Promise } diff --git a/src/main/runtime/rpc/dispatcher-feature-readiness.test.ts b/src/main/runtime/rpc/dispatcher-feature-readiness.test.ts new file mode 100644 index 00000000000..bfb9974cbfe --- /dev/null +++ b/src/main/runtime/rpc/dispatcher-feature-readiness.test.ts @@ -0,0 +1,107 @@ +import './unused-default-rpc-methods.test-fixture' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + _resetOrcadFeatureReadinessForTests, + installOrcadFeatureReadiness, + ORCAD_FEATURE_WAIT_MS +} from './orcad-feature-readiness' + +const request = (method: string): RpcRequest => ({ id: 'r1', authToken: 't', method, params: {} }) + +function dispatcher(): { dispatcher: RpcDispatcher; ran: string[] } { + const ran: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members for plain methods. + const runtime = { + getRuntimeId: () => 'rt', + recordFeatureInteraction: vi.fn() + } as unknown as OrcaRuntimeService + const methods = [ + defineMethod({ + name: 'aiVault.searchStatus', + requires: ['sessionSearch'], + permission: 'workspace', + params: z.object({}), + handler: () => { + ran.push('searchStatus') + return { ready: true } + } + }), + defineMethod({ + name: 'status.get', + permission: 'workspace', + params: z.object({}), + handler: () => { + ran.push('status') + return {} + } + }), + defineStreamingMethod({ + name: 'accounts.subscribe', + requires: ['accounts'], + permission: 'workspace', + params: z.object({}), + handler: async (_params, _ctx, emit) => { + ran.push('subscribe') + emit({ type: 'ready' }) + } + }) + ] + return { dispatcher: new RpcDispatcher({ runtime, methods }), ran } +} + +afterEach(() => { + _resetOrcadFeatureReadinessForTests() + vi.useRealTimers() +}) + +describe('RPC methods that require an orcad startup phase', () => { + it('run at once where no readiness is installed (the desktop)', async () => { + const { dispatcher: rpc, ran } = dispatcher() + await expect(rpc.dispatch(request('aiVault.searchStatus'))).resolves.toMatchObject({ ok: true }) + expect(ran).toEqual(['searchStatus']) + }) + + it('wait for their phase, while methods that need none run at once', async () => { + const readiness = installOrcadFeatureReadiness() + const { dispatcher: rpc, ran } = dispatcher() + const search = rpc.dispatch(request('aiVault.searchStatus')) + await expect(rpc.dispatch(request('status.get'))).resolves.toMatchObject({ ok: true }) + expect(ran).toEqual(['status']) + + readiness.open('sessionSearch') + await expect(search).resolves.toMatchObject({ ok: true, result: { ready: true } }) + expect(ran).toEqual(['status', 'searchStatus']) + }) + + it('refuse retryably with runtime_unavailable once the bounded wait runs out', async () => { + vi.useFakeTimers() + installOrcadFeatureReadiness() + const { dispatcher: rpc, ran } = dispatcher() + const search = rpc.dispatch(request('aiVault.searchStatus')) + await vi.advanceTimersByTimeAsync(ORCAD_FEATURE_WAIT_MS) + await expect(search).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable' } + }) + expect(ran).toEqual([]) + }) + + it('gate streaming methods the same way', async () => { + const readiness = installOrcadFeatureReadiness() + const { dispatcher: rpc, ran } = dispatcher() + const replies: string[] = [] + const stream = rpc.dispatchStreaming(request('accounts.subscribe'), (reply) => + replies.push(reply) + ) + await Promise.resolve() + expect(ran).toEqual([]) + readiness.open('accounts') + await stream + expect(ran).toEqual(['subscribe']) + expect(replies).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 218dad8a48f..5f24e240e1f 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -33,6 +33,7 @@ import { recordRuntimeFeatureInteraction } from './runtime-feature-interaction' import type { RpcCallerScope } from './rpc-caller-scope' import { runWithActiveRpcCaller } from './rpc-active-caller' import { admitRpcRequest } from './rpc-request-admission-prologue' +import { rpcMethodFeatureWait } from './orcad-feature-readiness' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -83,6 +84,10 @@ export class RpcDispatcher { emulatorProbe(`rpc ${request.method}`, request.params) } try { + const featureWait = rpcMethodFeatureWait(admission.method.requires) + if (featureWait) { + await featureWait + } return successResponse(request.id, meta, await this.invokeUnary(admission, options)) } catch (error) { if (probeEmulator) { @@ -113,6 +118,15 @@ export class RpcDispatcher { return } const { method, request, params, caller } = admission + const featureWait = rpcMethodFeatureWait(method.requires) + if (featureWait) { + try { + await featureWait + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, meta, error))) + return + } + } if (!isStreamingMethod(method)) { try { diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 3319eccbc92..1809b04befd 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,4 +1,5 @@ import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' +import type { OrcadFeature } from '../orcad-feature-readiness' import { rpcCallerOperationKey } from '../rpc-caller-identity' import { HostAccountSignInFlows } from '../../host-account-sign-in-flows' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -46,7 +47,7 @@ function signInOwner({ caller }: Pick): string { return rpcCallerOperationKey(caller) } -export const ACCOUNT_METHODS = [ +const ACCOUNT_METHOD_DEFINITIONS = [ // Only the owning desktop pushes this host's account launch environment, including WSL. defineMethod({ name: 'accounts.setHostLaunchEnv', @@ -270,3 +271,15 @@ export const ACCOUNT_METHODS = [ } }) ] + +// The desktop's launch-env push and unsubscribes never touch the account services. +const ACCOUNT_SERVICE_FREE_METHODS: ReadonlySet = new Set([ + 'accounts.setHostLaunchEnv', + 'accounts.unsubscribe' +]) + +export const ACCOUNT_METHODS = ACCOUNT_METHOD_DEFINITIONS.map((method) => + ACCOUNT_SERVICE_FREE_METHODS.has(method.name) + ? method + : { ...method, requires: ['accounts'] satisfies OrcadFeature[] } +) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 97d7c9dba03..02399adc706 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -45,6 +45,7 @@ export { export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.searchSessions', + requires: ['sessionSearch'], permission: 'workspace', params: AiVaultSearchRequestSchema, handler: (params, context) => { @@ -60,6 +61,7 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.searchStatus', + requires: ['sessionSearch'], permission: 'workspace', params: AiVaultSearchStatusRequestSchema, handler: (params, { clientKind }) => @@ -67,6 +69,7 @@ export const AI_VAULT_METHODS = [ }), defineMethod({ name: 'aiVault.setSearchEnabled', + requires: ['sessionSearch'], permission: 'settings-write', params: AiVaultSetSearchEnabledParamsSchema, handler: async (params, { runtime, clientKind, pairedDeviceId }) => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index ce98a055326..e749f49cf61 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,3 +1,4 @@ +import { requiringOrcadFeature } from '../orcad-feature-readiness' import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods' @@ -11,7 +12,8 @@ import { ORCHESTRATION_GATE_METHODS } from './orchestration/gates/gates' import { ORCHESTRATION_RESET_METHODS } from './orchestration/runs/reset-methods' import { ORCHESTRATION_CALLER_METHODS } from './orchestration/caller-show' -export const ORCHESTRATION_METHODS = [ +// Every orchestration method reads the authority restored at startup. +export const ORCHESTRATION_METHODS = requiringOrcadFeature('orchestration', [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, @@ -24,4 +26,4 @@ export const ORCHESTRATION_METHODS = [ ...ORCHESTRATION_GATE_METHODS, ...ORCHESTRATION_RESET_METHODS, ...ORCHESTRATION_CALLER_METHODS -] +]) diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 74700378d45..e25cff88aed 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -4,6 +4,8 @@ import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-o export const TERMINAL_ORPHAN_METHODS = [ defineMethod({ name: 'terminal.adoptOrphans', + // Adopting before placement could adopt a PTY the host is about to place itself. + requires: ['unshownPlacement'], permission: 'workspace', params: TerminalAdoptOrphans, handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params) diff --git a/src/main/runtime/rpc/orcad-feature-readiness-manifest.test.ts b/src/main/runtime/rpc/orcad-feature-readiness-manifest.test.ts new file mode 100644 index 00000000000..5b11d3d7d8a --- /dev/null +++ b/src/main/runtime/rpc/orcad-feature-readiness-manifest.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ALL_RPC_METHODS } from './methods' +import { ACCOUNT_METHODS } from './methods/accounts' +import type { OrcadFeature } from './orcad-feature-readiness' + +const requiresOf = (name: string): readonly OrcadFeature[] | undefined => + ALL_RPC_METHODS.find((method) => method.name === name)?.requires + +describe('which RPC methods wait for an orcad startup phase', () => { + it('gates the account services, search index, orchestration and orphan adoption', () => { + for (const method of ALL_RPC_METHODS) { + const expected: OrcadFeature[] = [] + // Claude/Codex account services only; usage credentials and Antigravity live elsewhere. + if ( + ACCOUNT_METHODS.some((account) => account.name === method.name) && + method.name !== 'accounts.setHostLaunchEnv' && + method.name !== 'accounts.unsubscribe' + ) { + expected.push('accounts') + } + if (method.name.startsWith('orchestration.')) { + expected.push('orchestration') + } + if (/^aiVault\.(searchSessions|searchStatus|setSearchEnabled)$/.test(method.name)) { + expected.push('sessionSearch') + } + if (method.name === 'terminal.adoptOrphans') { + expected.push('unshownPlacement') + } + expect({ name: method.name, requires: method.requires ?? [] }).toEqual({ + name: method.name, + requires: expected + }) + } + }) + + it('never gates what every client calls at connect', () => { + for (const name of [ + 'status.get', + 'repo.list', + 'session.tabs.subscribe', + 'terminal.list', + 'worktree.teardownMissingTerminals', + 'accounts.setHostLaunchEnv' + ]) { + expect(ALL_RPC_METHODS.some((method) => method.name === name)).toBe(true) + expect(requiresOf(name)).toBeUndefined() + } + }) + + // No behavior change yet: orcad opens every latch before RPC starts accepting calls. + it('opens every latch before orcad starts RPC', () => { + const read = (file: string): string => + readFileSync(join(__dirname, '../../orcad', file), 'utf8') + const entry = read('orcad-entry.ts') + const restore = read('orcad-restored-terminal-authority.ts') + const rpcStart = entry.indexOf("await timed('rpc', () => rpc.start())") + const restored = entry.indexOf('await restoreOrcadTerminalAuthority(') + expect(rpcStart).toBeGreaterThan(0) + expect(restored).toBeGreaterThan(0) + expect(restored).toBeLessThan(rpcStart) + for (const feature of ['accounts', 'sessionSearch']) { + const opened = entry.indexOf(`featureReadiness.open('${feature}')`) + expect(opened, feature).toBeGreaterThan(0) + expect(opened, feature).toBeLessThan(rpcStart) + } + for (const feature of ['orchestration', 'unshownPlacement']) { + expect(restore).toContain(`readiness.open('${feature}')`) + } + }) +}) diff --git a/src/main/runtime/rpc/orcad-feature-readiness.test.ts b/src/main/runtime/rpc/orcad-feature-readiness.test.ts new file mode 100644 index 00000000000..f9300bbdea5 --- /dev/null +++ b/src/main/runtime/rpc/orcad-feature-readiness.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _resetOrcadFeatureReadinessForTests, + installOrcadFeatureReadiness, + ORCAD_FEATURES, + rpcMethodFeatureWait +} from './orcad-feature-readiness' + +afterEach(() => { + _resetOrcadFeatureReadinessForTests() + vi.useRealTimers() +}) + +describe('OrcadFeatureReadiness', () => { + it('adds no await for open or undeclared features, so ungated dispatch starts synchronously', () => { + expect(rpcMethodFeatureWait(undefined)).toBeNull() + expect(rpcMethodFeatureWait(['accounts'])).toBeNull() + const readiness = installOrcadFeatureReadiness() + expect(rpcMethodFeatureWait([])).toBeNull() + readiness.open('accounts') + expect(rpcMethodFeatureWait(['accounts'])).toBeNull() + }) + + it('starts with every feature closed under orcad and waits for all a method names', async () => { + const readiness = installOrcadFeatureReadiness() + expect(ORCAD_FEATURES.every((feature) => !readiness.isOpen(feature))).toBe(true) + let settled = false + const wait = rpcMethodFeatureWait(['accounts', 'orchestration'])?.then(() => { + settled = true + }) + readiness.open('accounts') + await Promise.resolve() + expect(settled).toBe(false) + readiness.open('orchestration') + await wait + expect(settled).toBe(true) + }) + + it('refuses with runtime_unavailable after the bound and leaves no timer behind', async () => { + vi.useFakeTimers() + installOrcadFeatureReadiness() + const wait = rpcMethodFeatureWait(['sessionSearch'], 50) + const refused = expect(wait).rejects.toThrow('runtime_unavailable') + await vi.advanceTimersByTimeAsync(50) + await refused + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/rpc/orcad-feature-readiness.ts b/src/main/runtime/rpc/orcad-feature-readiness.ts new file mode 100644 index 00000000000..4f6a8745940 --- /dev/null +++ b/src/main/runtime/rpc/orcad-feature-readiness.ts @@ -0,0 +1,102 @@ +/** + * Startup phases an orcad host finishes after its first RPC could arrive. A method that needs one + * declares it (`requires`); the dispatcher waits a bounded time, then refuses retryably. Hosts that + * never install a readiness (the desktop) have every feature open. + */ +export type OrcadFeature = 'accounts' | 'sessionSearch' | 'orchestration' | 'unshownPlacement' + +export const ORCAD_FEATURES: readonly OrcadFeature[] = [ + 'accounts', + 'sessionSearch', + 'orchestration', + 'unshownPlacement' +] + +// Long enough to ride out a cold start; short enough that a caller's own retry takes over. +export const ORCAD_FEATURE_WAIT_MS = 10_000 + +export class OrcadFeatureReadiness { + private readonly pending = new Map; open: () => void }>() + + constructor(closed: readonly OrcadFeature[] = []) { + for (const feature of closed) { + let open!: () => void + const opened = new Promise((resolve) => { + open = resolve + }) + this.pending.set(feature, { opened, open }) + } + } + + isOpen(feature: OrcadFeature): boolean { + return !this.pending.has(feature) + } + + open(feature: OrcadFeature): void { + this.pending.get(feature)?.open() + this.pending.delete(feature) + } + + /** True once every feature is open; false if any is still closed after `timeoutMs`. */ + async waitFor(features: readonly OrcadFeature[], timeoutMs: number): Promise { + const waits = features.flatMap((feature) => this.pending.get(feature)?.opened ?? []) + if (waits.length === 0) { + return true + } + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + }) + try { + return await Promise.race([Promise.all(waits).then(() => true), timedOut]) + } finally { + clearTimeout(timer) + } + } +} + +// One runtime per orcad process; the desktop's never installs one, so all features read open. +let current = new OrcadFeatureReadiness() + +export function orcadFeatureReadiness(): OrcadFeatureReadiness { + return current +} + +/** orcad startup: every feature starts closed and opens where its phase finishes. */ +export function installOrcadFeatureReadiness(): OrcadFeatureReadiness { + current = new OrcadFeatureReadiness(ORCAD_FEATURES) + return current +} + +export function _resetOrcadFeatureReadinessForTests(): void { + current = new OrcadFeatureReadiness() +} + +/** + * The wait for a method's required features, or null when nothing is closed, so ungated dispatch + * keeps its synchronous start. Refuses with the existing retryable `runtime_unavailable`. + */ +export function rpcMethodFeatureWait( + requires: readonly OrcadFeature[] | undefined, + timeoutMs = ORCAD_FEATURE_WAIT_MS +): Promise | null { + if (!requires?.some((feature) => !current.isOpen(feature))) { + return null + } + return current.waitFor(requires, timeoutMs).then((ready) => { + if (!ready) { + throw new Error('runtime_unavailable') + } + }) +} + +/** Declares `feature` on each method of a family, e.g. every `orchestration.*` method. */ +export function requiringOrcadFeature( + feature: OrcadFeature, + methods: readonly M[] +): M[] { + return methods.map((method) => ({ + ...method, + requires: [...(method.requires ?? []), feature] + })) +}