mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
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:
@@ -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 } = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user