fix(agent-status): guard against re-entrant pending-status flush (crash 9fc89529) (#8671)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-13 23:08:19 -07:00
committed by GitHub
co-authored by Orca
parent e53a0a0158
commit 9a324bf333
2 changed files with 129 additions and 14 deletions
@@ -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<string, unknown>),
[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 } = {
+31 -14
View File
@@ -842,6 +842,11 @@ export function useIpcEvents(): void {
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | 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()
}