diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index ec7764ec287..bc0ca7fec92 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4873,6 +4873,104 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => { + // Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded): + // the store subscriber calls flushPendingAgentStatuses() on every update. + // flush -> applyAgentStatus -> store.setAgentStatus notifies subscribers + // synchronously (like Zustand) -> subscriber -> flush again while the same + // event is still queued -> infinite recursion. Model setAgentStatus with a + // real synchronous notify so the re-entrancy is exercised end to end. + const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + let setAgentStatusCalls = 0 + const notify = (): void => { + const listener = subscribeListenerRef.current + if (listener) { + listener(storeState, storeState) + } + } + const storeState: StoreLike = buildStoreState({ + // Why: mirror Zustand — a state mutation notifies subscribers synchronously. + setAgentStatus: (paneKey: string, entry: unknown) => { + setAgentStatusCalls += 1 + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [paneKey]: entry + } + notify() + }, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: false } }, + // Pane does not exist yet -> the incoming event is buffered as pending. + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn((listener: StoreSubscribeListener) => { + subscribeListenerRef.current = listener + return () => { + subscribeListenerRef.current = null + } + }), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + if (typeof subscribeListenerRef.current !== 'function') { + throw new Error('Expected useAppStore.subscribe listener to be registered') + } + + // Event lands before the tab exists -> buffered as a pending retry. + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + expect(setAgentStatusCalls).toBe(0) + + // Tab hydrates; the next store update flushes the pending event. Without the + // re-entrancy guard this overflows the stack instead of applying once. + storeState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + } + storeState.terminalLayoutsByTabId = { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + + expect(() => notify()).not.toThrow() + // Applied exactly once — the re-entrant flush is a no-op, not a loop. + expect(setAgentStatusCalls).toBe(1) + }) + it('applies ready push events for an unmounted inactive terminal tab', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e341ff8a185..96697628bf4 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -842,6 +842,11 @@ export function useIpcEvents(): void { type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] let pendingAgentStatusRetryTimer: ReturnType | null = null + // Why: applyAgentStatus -> store.setAgentStatus notifies the store + // subscriber synchronously, which re-enters flushPendingAgentStatuses while + // the queue is still mid-drain. Guard against that re-entrancy so the same + // event is not reprocessed forever (crash 9fc89529: stack overflow). + let isFlushingAgentStatuses = false unsubs.push(attachMobileMarkdownBridge()) @@ -2917,25 +2922,37 @@ export function useIpcEvents(): void { } function flushPendingAgentStatuses(): void { + // Why: a re-entrant call (store subscriber firing during a setAgentStatus + // inside the loop below) must not reprocess the still-queued events — the + // outer flush already owns them. Bailing here breaks the infinite + // recursion without dropping work; the outer loop finishes the drain. + if (isFlushingAgentStatuses) { + return + } if (pendingAgentStatusEvents.length === 0) { return } - const now = Date.now() - const remaining: PendingAgentStatusEvent[] = [] - for (const event of pendingAgentStatusEvents) { - if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) { - continue + isFlushingAgentStatuses = true + try { + const now = Date.now() + const remaining: PendingAgentStatusEvent[] = [] + for (const event of pendingAgentStatusEvents) { + if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) { + continue + } + const result = applyAgentStatus(event.data, { retry: true }) + if (result === 'pending') { + remaining.push(event) + } } - const result = applyAgentStatus(event.data, { retry: true }) - if (result === 'pending') { - remaining.push(event) + pendingAgentStatusEvents.length = 0 + pendingAgentStatusEvents.push(...remaining) + if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + pendingAgentStatusRetryTimer = null } - } - pendingAgentStatusEvents.length = 0 - pendingAgentStatusEvents.push(...remaining) - if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - pendingAgentStatusRetryTimer = null + } finally { + isFlushingAgentStatuses = false } schedulePendingAgentStatusFlush() }