diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 9a4f679faee..5885e536bcc 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -40,13 +40,17 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' import { + acceptReplayedWebSessionTabsSnapshot, _getWebSessionTabsRecoveryTrackingCountsForTest, _getWebSessionTabsTrackingCountsForTest, resetWebSessionTabsSnapshotFreshnessForTests, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' -import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' +import { + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT, + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS +} from './window-visibility-subscription-parking' const ENV_A = 'env-a' const ENV_B = 'env-b' @@ -298,6 +302,34 @@ describe('useWebSessionTabsSync window visibility', () => { hook.unmount() }) + it('replays an unchanged resume inventory while a repair replay is armed', async () => { + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + const snapshot = makeBrowserSnapshot() + await publish(findSubscription('session.tabs.subscribeAll', ENV_A), { + type: 'snapshots', + snapshots: [snapshot] + }) + mocks.recoverSnapshot.mockClear() + acceptReplayedWebSessionTabsSnapshot(ENV_A, WORKTREE) + act(() => useAppStore.setState({ browserTabsByWorktree: {} })) + + act(() => { + setDocumentVisibility('hidden') + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + setDocumentVisibility('visible') + }) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), { + type: 'snapshots', + snapshots: [snapshot] + }) + + expect(mocks.recoverSnapshot).toHaveBeenCalledOnce() + expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1) + hook.unmount() + }) + it('starts hidden without remote IO and hydrates once on reveal', async () => { setDocumentVisibility('hidden') const hook = renderHook(() => useWebSessionTabsSync()) @@ -757,7 +789,8 @@ describe('useWebSessionTabsSync window visibility', () => { act(() => { setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + // A second park in the same install pays the widened park delay. + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * 2) setDocumentVisibility('visible') }) await act(settle) @@ -769,6 +802,49 @@ describe('useWebSessionTabsSync window visibility', () => { hook.unmount() }) + it('drops an omission fence one generation after the inventory that set it', async () => { + const parkAndReveal = async (): Promise => { + act(() => { + setDocumentVisibility('hidden') + vi.advanceTimersByTime( + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT + ) + setDocumentVisibility('visible') + }) + await act(settle) + } + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + const snapshot = { ...makeBrowserSnapshot(), snapshotVersion: 2 } + await publish(findSubscription('session.tabs.subscribeAll', ENV_A), { + type: 'snapshots', + snapshots: [snapshot] + }) + + await parkAndReveal() + await publish(findSubscription('session.tabs.subscribeAll', ENV_A, 1), { + type: 'snapshots', + snapshots: [] + }) + expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined() + + await parkAndReveal() + await publish(findSubscription('session.tabs.subscribe', ENV_A, 2), { + type: 'snapshot', + ...snapshot + }) + expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined() + + await parkAndReveal() + await publish(findSubscription('session.tabs.subscribe', ENV_A, 3), { + type: 'snapshot', + ...snapshot + }) + expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toHaveLength(1) + hook.unmount() + }) + it('fences recovery that finishes after the stream is parked', async () => { const deferredRecovery = createDeferred() mocks.recoverSnapshot.mockImplementationOnce(() => deferredRecovery.promise) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 2964c51f0c4..0a12beffb01 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -131,6 +131,7 @@ type VisibilityResumeOmission = { environmentId: string inventoryReceivedFrame: number superseded: boolean + visibilityGeneration: number } const latestSessionTabsSnapshotByWorktree = new Map() @@ -396,6 +397,8 @@ function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: stri } } +// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever +// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. function buildMissingWebSessionTabsRemovals( environmentId: string, trackedWorktrees: readonly TrackedWebSessionTabsWorktree[], @@ -3501,7 +3504,8 @@ export function useWebSessionTabsSync(): void { baseline: missing.trackedWorktree.freshness, environmentId, inventoryReceivedFrame, - superseded: false + superseded: false, + visibilityGeneration }) recordReceivedWebSessionTabsRemoval( environmentId, @@ -3520,8 +3524,12 @@ export function useWebSessionTabsSync(): void { restartingSpecIndexes: readonly number[] }): void => { const activeRuntimeWorktreeKey = activeRuntimeWorktreeKeyRef.current - for (const key of visibilityResumeOmissionsByKey.keys()) { - if (key !== activeRuntimeWorktreeKey) { + for (const [key, omission] of visibilityResumeOmissionsByKey) { + // Why: the active worktree's scoped stream resumes before its host inventory, so its fence outlives exactly one resume - never more. + if ( + key !== activeRuntimeWorktreeKey || + omission.visibilityGeneration < visibilityGeneration - 1 + ) { visibilityResumeOmissionsByKey.delete(key) } } @@ -3608,13 +3616,14 @@ export function useWebSessionTabsSync(): void { if (event.type === 'snapshots') { const skipUnchangedResumeWork = awaitingVisibilityResumeInventory && !replayed awaitingVisibilityResumeInventory = false - // Why: exact resume inventories prove presence without needing orphan recovery or a redundant store replay. + // Why: an unchanged epoch/version proves the host published nothing while parked, so there is no missed frame to recover or replay. const unchangedVisibilityResumeSnapshots = event.snapshots.map((snapshot) => { - const freshness = latestSessionTabsSnapshotByWorktree.get( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const freshness = latestSessionTabsSnapshotByWorktree.get(key) return Boolean( skipUnchangedResumeWork && + // Why: an armed replay is an outstanding repair request, so that worktree must be rebuilt even when the host looks unchanged. + !replayableSessionTabsSnapshotByWorktree.has(key) && freshness?.publicationEpoch === snapshot.publicationEpoch && freshness.snapshotVersion === snapshot.snapshotVersion ) diff --git a/src/renderer/src/runtime/window-visibility-subscription-parking.test.ts b/src/renderer/src/runtime/window-visibility-subscription-parking.test.ts index 9e7af61989b..605b74f0ca4 100644 --- a/src/renderer/src/runtime/window-visibility-subscription-parking.test.ts +++ b/src/renderer/src/runtime/window-visibility-subscription-parking.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' import { installWindowVisibilitySubscriptionParking, + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS, WINDOW_VISIBILITY_SUBSCRIPTION_RETRY_INITIAL_MS, type WindowVisibilitySubscriptionSpec @@ -108,6 +109,38 @@ describe('installWindowVisibilitySubscriptionParking', () => { dispose() }) + it('widens the park delay after a quickly undone park and resets it after a long hide', async () => { + const harness = resolvedSpec() + const dispose = installWindowVisibilitySubscriptionParking([harness.spec]) + await settle() + + setDocumentVisibility('hidden') + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + setDocumentVisibility('visible') + await settle() + expect(harness.subscribe).toHaveBeenCalledTimes(2) + + setDocumentVisibility('hidden') + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + expect(harness.unsubscribes[1]).not.toHaveBeenCalled() + + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + expect(harness.unsubscribes[1]).toHaveBeenCalledTimes(1) + + const longHideMs = + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * + WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT + vi.advanceTimersByTime(longHideMs) + setDocumentVisibility('visible') + await settle() + expect(harness.subscribe).toHaveBeenCalledTimes(3) + + setDocumentVisibility('hidden') + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + expect(harness.unsubscribes[2]).toHaveBeenCalledTimes(1) + dispose() + }) + it('reports each visibility generation before restarting its subscriptions', async () => { const first = resolvedSpec() const second = resolvedSpec() @@ -168,7 +201,7 @@ describe('installWindowVisibilitySubscriptionParking', () => { expect(vi.getTimerCount()).toBe(0) setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * 2) setDocumentVisibility('visible') dispose() vi.advanceTimersByTime(100) @@ -181,17 +214,19 @@ describe('installWindowVisibilitySubscriptionParking', () => { const first = resolvedSpec() const active = resolvedSpec() const third = resolvedSpec() + const parkDelayMs = 100 const dispose = installWindowVisibilitySubscriptionParking( [first.spec, active.spec, third.spec], { getVisibilityResumePriority: (index) => (index === 1 ? 0 : 1), + parkDelayMs, visibilityResumeStaggerMs: 1_000 } ) await settle() setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + vi.advanceTimersByTime(parkDelayMs) setDocumentVisibility('visible') await settle() expect( @@ -199,7 +234,7 @@ describe('installWindowVisibilitySubscriptionParking', () => { ).toEqual([1, 2, 1]) setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + vi.advanceTimersByTime(parkDelayMs) vi.advanceTimersByTime(5_000) expect( [first.subscribe, active.subscribe, third.subscribe].map((mock) => mock.mock.calls.length) @@ -280,7 +315,7 @@ describe('installWindowVisibilitySubscriptionParking', () => { expect(harness.subscribe).toHaveBeenCalledTimes(2) setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * 2) expect(harness.unsubscribes[1]).toHaveBeenCalledTimes(1) dispose() }) @@ -365,7 +400,7 @@ describe('installWindowVisibilitySubscriptionParking', () => { expect(subscribe).toHaveBeenCalledTimes(3) setDocumentVisibility('hidden') - vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS * 2) setDocumentVisibility('visible') await settle() expect(subscribe).toHaveBeenCalledTimes(4) diff --git a/src/renderer/src/runtime/window-visibility-subscription-parking.ts b/src/renderer/src/runtime/window-visibility-subscription-parking.ts index c05bbe11730..5c622fb112b 100644 --- a/src/renderer/src/runtime/window-visibility-subscription-parking.ts +++ b/src/renderer/src/runtime/window-visibility-subscription-parking.ts @@ -5,6 +5,8 @@ import { } from '@/components/terminal-pane/stale-document-visibility' export const WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS = 500 +// Why: resuming re-enumerates every host, so repeated short hides must widen the park delay instead of paying that cost each cycle. +export const WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT = 8 export const WINDOW_VISIBILITY_SUBSCRIPTION_RETRY_INITIAL_MS = 1_000 const WINDOW_VISIBILITY_SUBSCRIPTION_RETRY_MAX_MS = 30_000 const WINDOW_VISIBILITY_SUBSCRIPTION_RETRY_JITTER_MS = 250 @@ -52,10 +54,13 @@ export function installWindowVisibilitySubscriptionParking( options: WindowVisibilitySubscriptionParkingOptions = {} ): () => void { const parkDelayMs = options.parkDelayMs ?? WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS + const maxParkDelayMs = parkDelayMs * WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT let disposed = false let effectiveVisible = getWindowVisible() let visibilityGeneration = effectiveVisible ? 0 : 1 let parkTimer: ReturnType | null = null + let currentParkDelayMs = parkDelayMs + let hiddenSinceMs: number | null = null const entries: SubscriptionEntry[] = specs.map(() => ({ desired: false, generation: 0, @@ -240,8 +245,17 @@ export function installWindowVisibilitySubscriptionParking( const reconcileVisibility = (): void => { if (getWindowVisible()) { cancelPark() + const hiddenMs = hiddenSinceMs === null ? null : Date.now() - hiddenSinceMs + hiddenSinceMs = null if (!effectiveVisible) { effectiveVisible = true + if (hiddenMs !== null) { + // Why: a park that the user undoes quickly costs more than it saves, so back off until one hide outlasts the ceiling. + currentParkDelayMs = + hiddenMs >= maxParkDelayMs + ? parkDelayMs + : Math.min(currentParkDelayMs * 2, maxParkDelayMs) + } startAll(visibilityGeneration > 0) } return @@ -249,6 +263,7 @@ export function installWindowVisibilitySubscriptionParking( if (!effectiveVisible || parkTimer !== null) { return } + hiddenSinceMs = Date.now() parkTimer = setTimeout(() => { parkTimer = null if (getWindowVisible()) { @@ -258,7 +273,7 @@ export function installWindowVisibilitySubscriptionParking( effectiveVisible = false visibilityGeneration += 1 stopAll() - }, parkDelayMs) + }, currentParkDelayMs) } if (effectiveVisible) {