diff --git a/src/renderer/src/lib/runtime-session-mirror-targets.test.ts b/src/renderer/src/lib/runtime-session-mirror-targets.test.ts index 232cc796fd3..3719e5a0293 100644 --- a/src/renderer/src/lib/runtime-session-mirror-targets.test.ts +++ b/src/renderer/src/lib/runtime-session-mirror-targets.test.ts @@ -48,7 +48,8 @@ describe('getReachableRuntimeSessionMirrorTargets', () => { environmentId: 'online-env', runtimeId: 'runtime-online', connectionGeneration: 3, - pairingRevision: 101 + pairingRevision: 101, + hostContactEpoch: 0 } ]) }) @@ -87,7 +88,8 @@ describe('getReachableRuntimeSessionMirrorTargets', () => { environmentId: 'offline-env', runtimeId: 'runtime-recovered', connectionGeneration: 0, - pairingRevision: 200 + pairingRevision: 200, + hostContactEpoch: 0 } ]) }) diff --git a/src/renderer/src/lib/runtime-session-mirror-targets.ts b/src/renderer/src/lib/runtime-session-mirror-targets.ts index a9cf3aa1a96..fa16335d8f3 100644 --- a/src/renderer/src/lib/runtime-session-mirror-targets.ts +++ b/src/renderer/src/lib/runtime-session-mirror-targets.ts @@ -15,6 +15,7 @@ type RuntimeMirrorStatus = { remoteControl?: RuntimeStatus['remoteControl'] | null snapshot?: RuntimeHostStatusSnapshot connectionGeneration?: number + hostContactEpoch?: number } type RuntimeMirrorEnvironment = { @@ -28,6 +29,7 @@ export type RuntimeSessionMirrorTarget = { runtimeId: string connectionGeneration: number pairingRevision: number + hostContactEpoch: number } export type RuntimeSessionMirrorTargetState = Omit< @@ -67,7 +69,8 @@ export function getReachableRuntimeSessionMirrorTargets( environmentId, runtimeId, connectionGeneration: entry?.connectionGeneration ?? 0, - pairingRevision: environment.pairingRevision ?? environment.createdAt + pairingRevision: environment.pairingRevision ?? environment.createdAt, + hostContactEpoch: entry?.hostContactEpoch ?? 0 }) } return targets diff --git a/src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts b/src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts index ce4e3c06b4b..63f333fbcbf 100644 --- a/src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts +++ b/src/renderer/src/lib/runtime-session-mirror-unverifiable-host.test.ts @@ -70,7 +70,8 @@ describe('mirror targets and host connection state agree on one host', () => { environmentId: ENVIRONMENT_ID, runtimeId: 'rt-1', connectionGeneration: 4, - pairingRevision: 101 + pairingRevision: 101, + hostContactEpoch: 0 } ]) }) diff --git a/src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts b/src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts new file mode 100644 index 00000000000..ee87280f901 --- /dev/null +++ b/src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + createRuntimeStatusSlice, + getRuntimeEnvironmentConnectionGeneration, + type RuntimeStatusSlice +} from '@/store/slices/runtime-status' +import { + clearHostSessionMirrorHydration, + hasHostSessionMirrorHydrated, + markHostSessionMirrorHydrated +} from './host-session-mirror-hydration' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) + +const ENVIRONMENT_ID = 'env-a' +const WORKTREE_ID = 'wt-a' +const PAIRING_REVISION = 101 + +function createSliceStore() { + return create()((...a) => ({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice creator is declared against the whole AppState; this store holds only its own slice, which is all the code under test reads. + ...createRuntimeStatusSlice(...(a as unknown as Parameters)) + })) +} + +function makeStatus(runtimeId: string): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + } +} + +function makeSnapshot( + sequence: number, + patch: Partial & Pick +): RuntimeHostStatusSnapshot { + return { + environmentId: ENVIRONMENT_ID, + pairingRevision: PAIRING_REVISION, + sequence, + checkedAt: sequence, + status: makeStatus('rt-1'), + transport: 'ready', + ...patch + } +} + +function seedEnvironment(store: ReturnType): void { + const endpointId = `ws-${ENVIRONMENT_ID}` + store.setState({ + runtimeEnvironments: [ + { + id: ENVIRONMENT_ID, + name: ENVIRONMENT_ID, + createdAt: 100, + updatedAt: 100, + pairingRevision: PAIRING_REVISION, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: endpointId, kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: endpointId + } + ] + }) +} + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + clearHostSessionMirrorHydration(ENVIRONMENT_ID) + vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() }) +}) + +afterEach(() => { + clearHostSessionMirrorHydration(ENVIRONMENT_ID) + vi.unstubAllGlobals() +}) + +// The mirror's hydration verdict is stamped with the connection generation +// (host-session-mirror-hydration.ts), so anything that advances the generation discards it and +// every mirrored pane re-parks — the tab list rebuild. A flap is unverifiable, not a new +// connection (docs/reference/ssh-execution-boundary.md), so it must not discard that verdict. +it('keeps the mirror hydrated across an unverifiable probe on the same runtime', () => { + const store = createSliceStore() + seedEnvironment(store) + + store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(1, { verification: 'verified' })) + const connectedGeneration = getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID) + markHostSessionMirrorHydrated(ENVIRONMENT_ID) + expect(hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE_ID)).toBe(true) + + store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(2, { verification: 'unavailable' })) + expect(hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE_ID)).toBe(true) + + store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(3, { verification: 'verified' })) + expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(connectedGeneration) + expect(hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE_ID)).toBe(true) +}) + +// The opposite edge must still invalidate: a replacement runtime id is a real new connection, +// and a verdict from the previous one says nothing about the new one's PTYs. +it('discards the mirror hydration when the runtime itself was replaced', () => { + const store = createSliceStore() + seedEnvironment(store) + + store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(1, { verification: 'verified' })) + markHostSessionMirrorHydrated(ENVIRONMENT_ID) + expect(hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE_ID)).toBe(true) + + store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(2, { verification: 'unavailable' })) + store + .getState() + .applyRuntimeHostStatusSnapshot( + makeSnapshot(3, { verification: 'verified', status: makeStatus('rt-2') }) + ) + expect(hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE_ID)).toBe(false) +}) diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx index c61fe14e284..0be5ff68661 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx @@ -18,7 +18,10 @@ vi.mock('./web-session-terminal-handle-events', async (importOriginal) => { vi.mock('./use-runtime-session-mirror-environment-key', async () => { const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') return { - useRuntimeSessionMirrorEnvironmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) } }) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index 644debd3eb0..920ce37bc20 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -18,7 +18,10 @@ vi.mock('./web-session-terminal-handle-events', async (importOriginal) => { vi.mock('./use-runtime-session-mirror-environment-key', async () => { const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') return { - useRuntimeSessionMirrorEnvironmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) } }) diff --git a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts index be02031255c..344016acf4d 100644 --- a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts +++ b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts @@ -22,7 +22,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import type { AppState } from '@/store/types' import { selectRuntimeSessionMirrorTargetInputs, - useRuntimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys } from './use-runtime-session-mirror-environment-key' import { useWebSessionTabsSync } from './web-session-tabs-sync' @@ -95,7 +95,7 @@ function seedMirrorState(): void { ) } -describe('useRuntimeSessionMirrorEnvironmentKey', () => { +describe('useRuntimeSessionMirrorEnvironmentKeys', () => { beforeEach(() => { getMirrorTargets.mockClear() seedMirrorState() @@ -123,10 +123,10 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { ]) ) useAppStore.setState({ repos, worktreesByRepo }) - const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKey()) + const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKeys()) const initialCallCount = getMirrorTargets.mock.calls.length - expect(hook.result.current).toBe('env-a\u0001runtime-a\u00011\u0001101') + expect(hook.result.current.environmentKey).toBe('env-a\u0001runtime-a\u00011\u0001101') expect(initialCallCount).toBe(1) act(() => { @@ -231,17 +231,17 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { activeRuntimeEnvironmentId: null } }) - const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKey()) + const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKeys()) - expect(hook.result.current).toBe('') + expect(hook.result.current.environmentKey).toBe('') act(() => useAppStore.setState(change(useAppStore.getState()))) - expect(hook.result.current).toBe('env-b\u0001runtime-b\u00012\u0001201') + expect(hook.result.current.environmentKey).toBe('env-b\u0001runtime-b\u00012\u0001201') expect(getMirrorTargets).toHaveBeenCalledTimes(2) }) it('rebuilds the key when connection or pairing identity changes', () => { - const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKey()) + const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKeys()) act(() => { useAppStore.setState({ @@ -256,7 +256,7 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { ]) as AppState['runtimeStatusByEnvironmentId'] }) }) - expect(hook.result.current).toBe('env-a\u0001runtime-a\u00012\u0001101') + expect(hook.result.current.environmentKey).toBe('env-a\u0001runtime-a\u00012\u0001101') act(() => { useAppStore.setState({ @@ -265,12 +265,12 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { ] as PublicKnownRuntimeEnvironment[] }) }) - expect(hook.result.current).toBe('env-a\u0001runtime-a\u00012\u0001102') + expect(hook.result.current.environmentKey).toBe('env-a\u0001runtime-a\u00012\u0001102') expect(getMirrorTargets).toHaveBeenCalledTimes(3) }) it('clears the key when status, environment, or the final owner disappears', () => { - const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKey()) + const hook = renderHook(() => useRuntimeSessionMirrorEnvironmentKeys()) const onlineStatus = useAppStore.getState().runtimeStatusByEnvironmentId.get('env-a')! const environments = useAppStore.getState().runtimeEnvironments @@ -279,18 +279,18 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { runtimeStatusByEnvironmentId: new Map([['env-a', { ...onlineStatus, status: null }]]) }) }) - expect(hook.result.current).toBe('') + expect(hook.result.current.environmentKey).toBe('') act(() => { useAppStore.setState({ runtimeStatusByEnvironmentId: new Map([['env-a', onlineStatus]]) }) }) - expect(hook.result.current).toBe('env-a\u0001runtime-a\u00011\u0001101') + expect(hook.result.current.environmentKey).toBe('env-a\u0001runtime-a\u00011\u0001101') act(() => useAppStore.setState({ runtimeEnvironments: [] })) - expect(hook.result.current).toBe('') + expect(hook.result.current.environmentKey).toBe('') act(() => useAppStore.setState({ runtimeEnvironments: environments })) - expect(hook.result.current).toBe('env-a\u0001runtime-a\u00011\u0001101') + expect(hook.result.current.environmentKey).toBe('env-a\u0001runtime-a\u00011\u0001101') act(() => { useAppStore.setState({ @@ -300,7 +300,7 @@ describe('useRuntimeSessionMirrorEnvironmentKey', () => { } }) }) - expect(hook.result.current).toBe('') + expect(hook.result.current.environmentKey).toBe('') expect(getMirrorTargets).toHaveBeenCalledTimes(6) }) diff --git a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts index 2073d98300a..8800bbeed83 100644 --- a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts +++ b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts @@ -32,10 +32,24 @@ export function selectRuntimeSessionMirrorTargetInputs( } } -export function buildRuntimeSessionMirrorEnvironmentKey( +export type RuntimeSessionMirrorEnvironmentKeys = { + /** + * Identity of the mirrored set. Every retained-state stamp is cut from these fields, so moving + * this key invalidates the mirror -- which is exactly why a flap must not move it (#19647). + */ + environmentKey: string + /** + * Advances when a mirrored host answers again after contact was lost. Purely an effect + * dependency: it reinstalls the subscriptions the dead transport took with it, and is + * deliberately absent from `environmentKey` so no frame can be stamped with it. + */ + resubscribeSignal: string +} + +export function buildRuntimeSessionMirrorEnvironmentKeys( inputs: RuntimeSessionMirrorTargetInputs -): string { - return getReachableRuntimeSessionMirrorTargets({ +): RuntimeSessionMirrorEnvironmentKeys { + const targets = getReachableRuntimeSessionMirrorTargets({ settings: { activeRuntimeEnvironmentId: inputs.activeRuntimeEnvironmentId }, repos: inputs.repos, worktreesByRepo: inputs.worktreesByRepo, @@ -45,14 +59,20 @@ export function buildRuntimeSessionMirrorEnvironmentKey( runtimeEnvironments: inputs.runtimeEnvironments, runtimeStatusByEnvironmentId: inputs.runtimeStatusByEnvironmentId }) - .map( - ({ environmentId, runtimeId, connectionGeneration, pairingRevision }) => - `${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}` - ) - .join('\u0000') + return { + environmentKey: targets + .map( + ({ environmentId, runtimeId, connectionGeneration, pairingRevision }) => + `${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}` + ) + .join('\u0000'), + resubscribeSignal: targets + .map(({ environmentId, hostContactEpoch }) => `${environmentId}\u0001${hostContactEpoch}`) + .join('\u0000') + } } -export function useRuntimeSessionMirrorEnvironmentKey(): string { +export function useRuntimeSessionMirrorEnvironmentKeys(): RuntimeSessionMirrorEnvironmentKeys { // Why: agent/tab writes are hot; scan host ownership only when one of its sources changes. const inputs = useAppStore(useShallow(selectRuntimeSessionMirrorTargetInputs)) const { @@ -67,7 +87,7 @@ export function useRuntimeSessionMirrorEnvironmentKey(): string { } = inputs return useMemo( () => - buildRuntimeSessionMirrorEnvironmentKey({ + buildRuntimeSessionMirrorEnvironmentKeys({ activeRuntimeEnvironmentId, repos, worktreesByRepo, diff --git a/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx new file mode 100644 index 00000000000..ab881db7d81 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) + +const mocks = vi.hoisted(() => ({ getExplicitRuntimeEnvironmentIdForWorktree: vi.fn() })) + +vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getExplicitRuntimeEnvironmentIdForWorktree: mocks.getExplicitRuntimeEnvironmentIdForWorktree + } +}) + +import { useAppStore } from '@/store' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { clearHostLiveTerminalProbesForTests } from './host-live-terminal-probe' +import { + resetWebSessionTabsSnapshotFreshnessForTests, + useWebSessionTabsSync +} from './web-session-tabs-sync' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { buildRuntimeSessionMirrorEnvironmentKeys } from './use-runtime-session-mirror-environment-key' + +const ENV_A = 'env-a' +const WORKTREE = 'repo-a::worktree-a' +const REVISION_A = 101 +const initialState = useAppStore.getInitialState() + +type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe +type Recorded = { + request: Parameters[0] + callbacks: Parameters[1] + unsubscribe: ReturnType +} + +const subscriptions: Recorded[] = [] +const runtimeCall = vi.fn(async () => ({ + id: 'list-all', + ok: true as const, + result: { snapshots: [] }, + _meta: { runtimeId: 'runtime-a' } +})) +const runtimeSubscribe = vi.fn(async (request, callbacks) => { + const unsubscribe = vi.fn() + subscriptions.push({ request, callbacks, unsubscribe }) + return { unsubscribe, sendBinary: vi.fn() } +}) + +async function settle(): Promise { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve() + } +} + +function hostSnapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + return { + environmentId: ENV_A, + pairingRevision: REVISION_A, + sequence, + checkedAt: sequence, + status: makeStatus('runtime-a'), + verification: 'verified', + transport: 'ready', + ...patch + } +} + +function mirroredSubscriptions(method: string): Recorded[] { + return subscriptions.filter((entry) => entry.request.method === method) +} + +function makeStatus(runtimeId: string): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + } +} + +/** The dependencies the mirror-subscription effects actually read, rebuilt from current state. */ +function mirrorKeys(): ReturnType { + const state = useAppStore.getState() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder takes the whole app state; only the fields below reach the mirror-target scan. + return buildRuntimeSessionMirrorEnvironmentKeys({ + activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId ?? null, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId + } as Parameters[0]) +} + +/** Connect, then lose contact over a still-ready transport: the stream ends, the probe cannot ask. */ +async function connectThenLoseContact(): Promise { + renderHook(() => useWebSessionTabsSync()) + await act(async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(hostSnapshot(1)) + await settle() + }) + await act(async () => { + for (const entry of subscriptions) { + entry.callbacks.onResponse({ + id: 'ended', + ok: true, + result: { type: 'end' }, + _meta: { runtimeId: 'runtime-a' } + }) + } + useAppStore + .getState() + .applyRuntimeHostStatusSnapshot(hostSnapshot(2, { verification: 'unavailable' })) + await settle() + }) +} + +async function regainContact(): Promise { + await act(async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(hostSnapshot(3)) + await settle() + }) +} + +describe('session-tabs mirror across an outage and its recovery', () => { + beforeEach(() => { + subscriptions.length = 0 + runtimeCall.mockClear() + runtimeSubscribe.mockClear() + mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReset().mockReturnValue(ENV_A) + Object.defineProperty(window, 'api', { + configurable: true, + value: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + resetWebSessionTabsSnapshotFreshnessForTests() + clearHostLiveTerminalProbesForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mirror scan and revision ledger read only id, createdAt and pairingRevision. + const runtimeEnvironments = [ + { id: ENV_A, createdAt: 100, pairingRevision: REVISION_A } + ] as PublicKnownRuntimeEnvironment[] + replaceRuntimeEnvironmentRevisions(runtimeEnvironments) + useAppStore.setState( + { + ...initialState, + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: ENV_A }, + activeWorktreeId: WORKTREE, + workspaceSessionReady: true, + runtimeEnvironments, + runtimeStatusByEnvironmentId: new Map() + }, + true + ) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + replaceRuntimeEnvironmentRevisions([]) + resetWebSessionTabsSnapshotFreshnessForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + }) + + // Direction 1: the resubscribe trigger. The transport took both streams with it and nothing + // else revives them -- an 'end' frame resubscribes nothing and the parking layer retries only + // a rejected subscribe -- so regaining contact has to reinstall them itself. + it('reinstalls both session-tabs subscriptions when the host answers again', async () => { + await connectThenLoseContact() + const stranded = { + all: mirroredSubscriptions('session.tabs.subscribeAll').length, + active: mirroredSubscriptions('session.tabs.subscribe').length, + signal: mirrorKeys().resubscribeSignal + } + + await regainContact() + + expect(mirrorKeys().resubscribeSignal).not.toBe(stranded.signal) + expect(mirroredSubscriptions('session.tabs.subscribeAll')).toHaveLength(stranded.all + 1) + expect(mirroredSubscriptions('session.tabs.subscribe')).toHaveLength(stranded.active + 1) + }) + + // Direction 2: the mirror's cache key. #19647 -- recovery is not a second connection, so every + // retained-state stamp cut from this key stays valid and the mirror is never rebuilt. + it('holds the mirror environment key across the outage and the recovery', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(hostSnapshot(1)) + await settle() + }) + const connectedKey = mirrorKeys().environmentKey + expect(connectedKey).not.toBe('') + + await act(async () => { + useAppStore + .getState() + .applyRuntimeHostStatusSnapshot(hostSnapshot(2, { verification: 'unavailable' })) + await settle() + }) + expect(mirrorKeys().environmentKey).toBe(connectedKey) + + await regainContact() + expect(mirrorKeys().environmentKey).toBe(connectedKey) + }) + + // The two values only look alike: a replacement runtime is a new connection, so the key moves + // and the mirror is meant to be rebuilt. + it('still rebuilds the mirror key when the host returns as a replacement runtime', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(hostSnapshot(1)) + await settle() + }) + const connectedKey = mirrorKeys().environmentKey + + await act(async () => { + useAppStore + .getState() + .applyRuntimeHostStatusSnapshot(hostSnapshot(2, { verification: 'unavailable' })) + useAppStore.getState().applyRuntimeHostStatusSnapshot( + hostSnapshot(3, { + status: makeStatus('runtime-b') + }) + ) + await settle() + }) + + expect(mirrorKeys().environmentKey).not.toBe(connectedKey) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx index c5939e9aefd..06623338eae 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx @@ -13,7 +13,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('./use-runtime-session-mirror-environment-key', () => ({ - useRuntimeSessionMirrorEnvironmentKey: mocks.runtimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: mocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) })) vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index d17a6898d93..d5f05b11c0b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -17,7 +17,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('./use-runtime-session-mirror-environment-key', () => ({ - useRuntimeSessionMirrorEnvironmentKey: mocks.runtimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: mocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) })) vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { 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 b30662524d9..01c051f8ad2 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 @@ -16,7 +16,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('./use-runtime-session-mirror-environment-key', () => ({ - useRuntimeSessionMirrorEnvironmentKey: mocks.runtimeSessionMirrorEnvironmentKey + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: mocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) })) vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts index 231f2b1ce22..bb6eb4537af 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status' import { useAppStore } from '../../store' import { getExplicitRuntimeEnvironmentIdForWorktree } from '../../lib/worktree-runtime-owner' -import { useRuntimeSessionMirrorEnvironmentKey } from '../use-runtime-session-mirror-environment-key' +import { useRuntimeSessionMirrorEnvironmentKeys } from '../use-runtime-session-mirror-environment-key' import { sessionTabsFreshnessKey } from './tracking' import { clearWebSessionTabsTrackingForEnvironment } from './tracking-lifecycle' import { @@ -31,7 +31,8 @@ export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey() + const { environmentKey: runtimeSessionMirrorEnvironmentKey, resubscribeSignal } = + useRuntimeSessionMirrorEnvironmentKeys() const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) @@ -52,6 +53,14 @@ export function useWebSessionTabsSync(): void { ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0) : 0 }) + // Restart trigger only, deliberately not passed to the installer: the scoped stream died with + // the transport, but the frames it will resend still belong to the same connection generation. + const activeWorktreeRuntimeHostContactEpoch = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + return environmentId + ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.hostContactEpoch ?? 0) + : 0 + }) const activeWorktreeRuntimePairingRevision = useAppStore((state) => { const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) const environment = state.runtimeEnvironments.find( @@ -94,7 +103,9 @@ export function useWebSessionTabsSync(): void { ownerRevisions: ownerRevisionsRef } }) - }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) + // `resubscribeSignal` is a dependency and never an argument: a regained host needs its streams + // reinstalled, but the mirror state they refill is stamped with the key, which has not moved. + }, [runtimeSessionMirrorEnvironmentKey, resubscribeSignal, workspaceSessionReady]) useEffect(() => { return installActiveSessionTabsSubscription({ @@ -111,6 +122,7 @@ export function useWebSessionTabsSync(): void { activeWorktreeId, activeWorktreeRuntimeEnvironmentId, activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimeHostContactEpoch, activeWorktreeRuntimePairingRevision, activeWorktreeRuntimeId, workspaceSessionReady diff --git a/src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts b/src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts index e682b2be2f2..de5ed6f5659 100644 --- a/src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts +++ b/src/renderer/src/store/slices/runtime-status-reconnect-connection-generation.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { buildRuntimeSessionMirrorEnvironmentKey } from '@/runtime/use-runtime-session-mirror-environment-key' +import { buildRuntimeSessionMirrorEnvironmentKeys } from '@/runtime/use-runtime-session-mirror-environment-key' import { clearRuntimeEnvironmentConnectionGenerationsForTests, createRuntimeStatusSlice, @@ -54,10 +54,10 @@ function makeSnapshot( } } -/** The mirror-subscription effect dependency, rebuilt from the slice's current state. */ -function mirrorKey(store: Store): string { +/** The mirror-subscription effect dependencies, rebuilt from the slice's current state. */ +function mirrorKeys(store: Store): ReturnType { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder takes the whole app state; only the fields below reach the mirror-target scan. - return buildRuntimeSessionMirrorEnvironmentKey({ + return buildRuntimeSessionMirrorEnvironmentKeys({ activeRuntimeEnvironmentId: ENVIRONMENT_ID, repos: [], worktreesByRepo: {}, @@ -66,7 +66,7 @@ function mirrorKey(store: Store): string { restoredRuntimeHostIdByWorkspaceSessionKey: {}, runtimeEnvironments: store.getState().runtimeEnvironments, runtimeStatusByEnvironmentId: store.getState().runtimeStatusByEnvironmentId - } as Parameters[0]) + } as Parameters[0]) } function seedEnvironment(store: Store): void { @@ -98,44 +98,44 @@ afterEach(() => { }) describe('regaining contact is its own mirror-recovery trigger', () => { - // The mirror subscription is (re)installed by the effect in - // web-session-tabs-sync/use-web-session-tabs-sync.ts, keyed on - // useRuntimeSessionMirrorEnvironmentKey(). Before this change the only thing that - // moved that key across an outage was the target being dropped and re-added — the - // teardown was the recovery. Holding the target through the outage strands the mirror - // unless regaining contact moves the key on its own. - it('advances the connection generation when a host answers again after an outage', () => { + // The mirror subscriptions are (re)installed by the effects in + // web-session-tabs-sync/use-web-session-tabs-sync.ts. Holding the target through the outage + // strands them unless regaining contact triggers a reinstall, but the connection generation + // cannot be that trigger: it is the mirror's cache key, and moving it rebuilds the mirror + // (#19647). The two live on separate values, and this suite pins each to its own edge. + it('advances the contact epoch, not the connection generation, when a host answers again', () => { const store = createSliceStore() seedEnvironment(store) store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(1, { verification: 'verified' })) const connectedGeneration = getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID) - const connectedKey = mirrorKey(store) - expect(connectedKey).not.toBe('') + const connected = mirrorKeys(store) + expect(connected.environmentKey).not.toBe('') store .getState() .applyRuntimeHostStatusSnapshot(makeSnapshot(2, { verification: 'unavailable' })) expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(connectedGeneration) - expect(mirrorKey(store)).toBe(connectedKey) + expect(mirrorKeys(store)).toEqual(connected) store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(3, { verification: 'verified' })) - expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(connectedGeneration + 1) - expect(mirrorKey(store)).not.toBe(connectedKey) + expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(connectedGeneration) + expect(mirrorKeys(store).environmentKey).toBe(connected.environmentKey) + expect(mirrorKeys(store).resubscribeSignal).not.toBe(connected.resubscribeSignal) }) - it('does not advance the generation while the host keeps answering', () => { + it('does not advance either value while the host keeps answering', () => { const store = createSliceStore() seedEnvironment(store) store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(1, { verification: 'verified' })) const connectedGeneration = getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID) - const connectedKey = mirrorKey(store) + const connected = mirrorKeys(store) store.getState().applyRuntimeHostStatusSnapshot(makeSnapshot(2, { verification: 'verified' })) expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(connectedGeneration) - expect(mirrorKey(store)).toBe(connectedKey) + expect(mirrorKeys(store)).toEqual(connected) }) it('leaves a first publication with no prior entry on its original generation', () => { @@ -150,7 +150,7 @@ describe('regaining contact is its own mirror-recovery trigger', () => { expect(getRuntimeEnvironmentConnectionGeneration(ENVIRONMENT_ID)).toBe(before) }) - it('advances once, not twice, when the runtime restarted during the outage', () => { + it('advances the generation when the runtime restarted during the outage', () => { const store = createSliceStore() seedEnvironment(store) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts index 737e9831205..48973fedf93 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -82,13 +82,13 @@ it('represents failed verification honestly without manufacturing a session rest generation ) viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) - // The connection epoch is not the runtime session: regaining contact opens a new epoch - // (reads issued before the outage were against the lost connection, and the session - // mirror needs this edge to resume), while the runtime session is unchanged — same - // runtime id, no restart hook, no toast. + // Regaining contact on the same runtime is neither a new connection nor a new session: the + // generation holds so the session mirror is not rebuilt (#19647), and only the contact epoch + // — the mirror's resubscribe trigger — moves. No restart hook, no toast. expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( - (generation ?? 0) + 1 + generation ) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.hostContactEpoch).toBe(1) expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( 'rt-1' ) @@ -100,7 +100,7 @@ it('represents failed verification honestly without manufacturing a session rest viewer .getState() .applyRuntimeHostStatusSnapshot(snapshot(4, { status: { runtimeId: 'rt-2' } as RuntimeStatus })) - // A replacement runtime id is a restart, and it advances the epoch exactly once more. + // A replacement runtime id is a restart: a genuinely new connection, so the generation moves. expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( (reconnectedGeneration ?? 0) + 1 ) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts index b3543471d29..3a9c9a2ebac 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -24,6 +24,7 @@ export function applyRuntimeHostStatusSnapshot( snapshot, checkedAt: snapshot.checkedAt, connectionGeneration: previous?.connectionGeneration, + hostContactEpoch: previous?.hostContactEpoch, status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, remoteControl: snapshot.remoteControl } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index fdc843c748a..3e78d63194b 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -181,8 +181,9 @@ describe('runtime-status slice', () => { const map = store.getState().runtimeStatusByEnvironmentId expect(map.size).toBe(1) - // Generation 0: a first publication is not a reconnect, and going offline never bumps. - expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, connectionGeneration: 0 }) + // Both counters 0: a first publication is not a reconnect, and going offline never bumps. + const counters = { connectionGeneration: 0, hostContactEpoch: 0 } + expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, ...counters }) }) it('retains a learned paired device id after disconnecting a legacy environment', () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index c99e05a4861..1913fd6f4a5 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -200,13 +200,7 @@ export const createRuntimeStatusSlice: StateCreator