From fa52db8c6d015783fcaccfd6ea0f3d9fe8f36113 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 17:40:33 -0700 Subject: [PATCH] test(runtime): pin replay containment on the deadline path too Cherry-picked from aa98edf35ab (nwparker/adv3-failure-fixes) with its implementation hunk dropped: a second agent found the same throwing-replay hole independently, and `15f34014153` already closed it on this branch with an equivalent guard. Applying both would have been a double-apply, and the two spellings of the log line would have shipped side by side. The tests are worth keeping regardless. The first duplicates coverage 15f34014153 already has; the second does not -- it drives the throw from the DEADLINE path rather than the store-write path, which is a separate call into releaseWaiter and was unpinned. Spies retargeted from console.error to console.warn, the channel the guard that actually shipped writes to, so the suite silences what the code emits. --- ...rror-handle-gap-replay-containment.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/renderer/src/lib/host-mirror-handle-gap-replay-containment.test.ts diff --git a/src/renderer/src/lib/host-mirror-handle-gap-replay-containment.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-replay-containment.test.ts new file mode 100644 index 00000000000..ee651411c6a --- /dev/null +++ b/src/renderer/src/lib/host-mirror-handle-gap-replay-containment.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { + HOST_MIRROR_HANDLE_GAP_DEADLINE_MS, + countParkedHostMirrorHandleGapPanesForTests, + parkUntilHostMirrorHandleLands, + resetHostMirrorHandleGapWaitsForTests +} from './host-mirror-handle-gap-wait' + +// What this pins: the parked replay is `resumeSleepingAgentSessionsForWorktree`, a large +// synchronous sweep, and it is released from inside `useAppStore.subscribe`. Zustand notifies +// listeners in a plain loop, so a replay that throws escapes the `setState` that triggered it: +// the listeners registered after this module never see the write, and every sibling pane the +// same mirror frame made due is left parked. The waiter's own state is torn down before `run`, +// so containing the throw holds nothing back. + +const initialAppStoreState = useAppStore.getState() +const ENV_ID = 'env-gap-containment' + +function seedTwoMirroredPanes(): void { + useAppStore.setState({ + tabsByWorktree: { + wt: [ + { id: 'tab-a', title: 'a', ptyId: null }, + { id: 'tab-b', title: 'b', ptyId: null } + ] as never + }, + ptyIdsByTabId: {} + }) +} + +describe('host-mirror handle-gap replay containment', () => { + beforeEach(() => { + vi.useFakeTimers() + useAppStore.setState(initialAppStoreState, true) + }) + + afterEach(() => { + resetHostMirrorHandleGapWaitsForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + useAppStore.setState(initialAppStoreState, true) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('a replay that throws neither aborts the store write nor strands its sibling panes', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedTwoMirroredPanes() + const siblingReplay = vi.fn() + parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-a', () => { + throw new Error('replay blew up') + }) + parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-b', siblingReplay) + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(2) + + // A store subscriber registered after this module's, so it is notified after the drain. + const laterSubscriber = vi.fn() + const unsubscribe = useAppStore.subscribe(laterSubscriber) + + // One mirror frame lands both handles, making both waiters due on a single store write. + expect(() => + useAppStore.setState({ ptyIdsByTabId: { 'tab-a': ['pty-a'], 'tab-b': ['pty-b'] } }) + ).not.toThrow() + unsubscribe() + + expect(siblingReplay).toHaveBeenCalledTimes(1) + expect(laterSubscriber).toHaveBeenCalledTimes(1) + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) + }) + + it('a replay that throws on the deadline path does not escape the timer', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + seedTwoMirroredPanes() + parkUntilHostMirrorHandleLands(ENV_ID, 'wt', 'tab-a', () => { + throw new Error('replay blew up') + }) + + expect(() => vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS)).not.toThrow() + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) + }) +})