mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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.
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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<string>())
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof createOrcadStartupTiming>['time'],
|
||||
readiness: OrcadFeatureReadiness
|
||||
): Promise<void> {
|
||||
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')
|
||||
}
|
||||
@@ -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<TName extends string, TSchema extends ZodType | null,
|
||||
// Every new method must explicitly choose caller permissions.
|
||||
readonly permission: RpcMethodPermission
|
||||
readonly params: TSchema
|
||||
// Startup phases this method needs; the dispatcher waits for them, bounded (orcad only).
|
||||
readonly requires?: readonly OrcadFeature[]
|
||||
readonly handler: (params: RpcParsedParams<TSchema>, ctx: RpcContext) => TResult
|
||||
}
|
||||
|
||||
@@ -192,6 +195,7 @@ export type RpcTypedStreamingMethod<TName extends string, TSchema extends ZodTyp
|
||||
readonly name: TName
|
||||
readonly permission: RpcMethodPermission
|
||||
readonly params: TSchema
|
||||
readonly requires?: readonly OrcadFeature[]
|
||||
readonly stream: true
|
||||
readonly handler: (
|
||||
params: RpcParsedParams<TSchema>,
|
||||
@@ -210,6 +214,7 @@ type RpcMethodWithParams<P> = {
|
||||
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<P> = {
|
||||
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<void>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RpcContext, 'caller'>): 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<string> = 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[] }
|
||||
)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
]
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<OrcadFeature, { opened: Promise<void>; open: () => void }>()
|
||||
|
||||
constructor(closed: readonly OrcadFeature[] = []) {
|
||||
for (const feature of closed) {
|
||||
let open!: () => void
|
||||
const opened = new Promise<void>((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<boolean> {
|
||||
const waits = features.flatMap((feature) => this.pending.get(feature)?.opened ?? [])
|
||||
if (waits.length === 0) {
|
||||
return true
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = new Promise<false>((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<void> | 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<M extends { readonly requires?: readonly OrcadFeature[] }>(
|
||||
feature: OrcadFeature,
|
||||
methods: readonly M[]
|
||||
): M[] {
|
||||
return methods.map((method) => ({
|
||||
...method,
|
||||
requires: [...(method.requires ?? []), feature]
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user