From b4686f55c4edbfd443cef065af9f3e0a98ff303b Mon Sep 17 00:00:00 2001 From: Meapri <104259736+Meapri@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:32:23 +0900 Subject: [PATCH] fix(runtime): refuse, never hang, a session tab inventory no graph publishes `session.tabs.listAll` (and the inventory census behind `session.tabs.subscribe`) withholds its answer until the renderer publishes the current graph, because a client without the authoritative-inventory capability would prune tabs from an unpublished list. On a host whose graph never publishes (orcad before its headless placeholder graph, or a stalled renderer) that wait had no bound, so a pre-capability client hung forever. The wait now gives up after 30s with `runtime_unavailable`, which every client already treats as retryable, and drops its waiter. Co-Authored-By: Claude Opus 5.5 --- ...-for-session-tabs-inventory-publication.ts | 15 +++++- ...session-tabs-inventory-publication.test.ts | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts b/src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts index 4d7b1845049f..6db470a1bfad 100644 --- a/src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts +++ b/src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts @@ -2,13 +2,26 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees } from './orca-runtime-collect-mobile-visible-graph-changed-worktrees' +// Why bounded: an old client is withheld until the renderer publishes (it would prune tabs from +// an unpublished list), but a host whose graph never publishes must refuse, not hang the call. +export const SESSION_TABS_INVENTORY_PUBLICATION_WAIT_MS = 30_000 + export class OrcaRuntimeWithWaitForSessionTabsInventoryPublication extends OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees { - protected waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { + protected waitForSessionTabsInventoryPublication( + signal?: AbortSignal, + timeoutMs = SESSION_TABS_INVENTORY_PUBLICATION_WAIT_MS + ): Promise { if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { return Promise.resolve() } return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject(new Error('runtime_unavailable')) + }, timeoutMs) + timer.unref?.() const cleanup = (): void => { + clearTimeout(timer) this.sessionTabsInventoryWaiters.delete(onPublished) signal?.removeEventListener('abort', onAbort) } diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts index 854059958bd2..dbe7e3d8b642 100644 --- a/src/main/runtime/session-tabs-inventory-publication.test.ts +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import { SESSION_TABS_INVENTORY_PUBLICATION_WAIT_MS } from './orca-runtime-wait-for-session-tabs-inventory-publication' +import { RpcDispatcher } from './rpc/dispatcher' +import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs' type InventoryInternals = { sessionTabsInventoryWaiters: Set<() => void> @@ -368,6 +371,50 @@ describe('authoritative session tab inventory publication', () => { expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0) }) + it('refuses with runtime_unavailable instead of hanging when no graph ever publishes', async () => { + vi.useFakeTimers() + try { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + const settled = pending.catch((error: unknown) => error) + await waitForInventoryWaiter(runtime) + + await vi.advanceTimersByTimeAsync(SESSION_TABS_INVENTORY_PUBLICATION_WAIT_MS) + + expect(await settled).toEqual(new Error('runtime_unavailable')) + expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('answers an old client over RPC with runtime_unavailable when no graph publishes', async () => { + vi.useFakeTimers() + try { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + // No clientCapabilities: the pre-capability client shape. + const pending = dispatcher.dispatchStreaming( + { id: 'req-1', authToken: 'tok', method: 'session.tabs.listAll' }, + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + await waitForInventoryWaiter(runtime) + await vi.advanceTimersByTimeAsync(SESSION_TABS_INVENTORY_PUBLICATION_WAIT_MS) + await pending + + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable' } + }) + } finally { + vi.useRealTimers() + } + }) + it('does not scan when the inventory request is already cancelled', async () => { const runtime = createInventoryRuntime() const collect = vi.spyOn(