From 96eb97aad641f3d8324bd5488d51c06a3eba59ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:07:52 -0700 Subject: [PATCH] fix(runtime): split the host-contact epoch out of the connection generation (#20359) * fix(runtime): split the host-contact epoch out of the connection generation `connectionGeneration` carried two meanings and one reader was always wrong. holding the session mirror through an outage leaves its subscriptions stranded and that edge was the only thing left to revive them. But the same value is the mirror's cache key -- use-runtime-session-mirror-environment-key.ts keys the subscription effect on it, every published frame is stamped with it, and web-session-terminal-retirement-proof-ledger.ts drops retained proofs when it moves. So the bump #20085 needed as a resubscribe signal re-keyed and rebuilt the mirror after any brief flap, which is the #19647 symptom #19873/#20059 fix. Measured first: with the reconnect bump deleted, an ended stream followed by recovery issues zero resubscribes, and the mirror's subscribe call registers no `onClose`, so main's terminal close is dropped. #20085's claim is true -- the subscription really is dead after recovery -- so the trigger has to exist. It just must not be the cache key. Give each meaning its own value: - `connectionGeneration` returns to identity only: a new runtime session, a re-pair, an explicit clear. A same-runtime return no longer moves it, so no stamp, fence or retained proof is invalidated by a flap. - `hostContactEpoch` counts "the host answered again after we lost contact". It lives on the store entry and is read only as a dependency of the two subscription effects in use-web-session-tabs-sync.ts -- never passed to an installer, never part of `environmentKey`, so it cannot become a stamp. `useRuntimeSessionMirrorEnvironmentKey` becomes `useRuntimeSessionMirrorEnvironmentKeys`, returning `environmentKey` (identity) and `resubscribeSignal` (the epoch edge) from the one target scan, so the hot ownership scan is not doubled. Each direction is pinned by its own test: removing the resubscribe dependency fails only 'reinstalls both session-tabs subscriptions when the host answers again'; restoring the reconnect bump fails only the two key-stability tests. * test(runtime): pin the mirror hydration verdict across a host flap The generation tests assert the key string; this asserts what the user feels. The mirror's hydration verdict is stamped with the connection generation, so any bump discards it and every mirrored pane re-parks -- the tab-list rebuild. Held across an unverifiable probe, still discarded when the runtime id actually moved. * test(runtime): build real host statuses instead of casting partials --- .../runtime-session-mirror-targets.test.ts | 6 +- .../src/lib/runtime-session-mirror-targets.ts | 5 +- ...e-session-mirror-unverifiable-host.test.ts | 3 +- ...host-session-mirror-flap-hydration.test.ts | 124 +++++++++ ...n-mirror-hydration-frame-ordering.test.tsx | 5 +- ...sion-mirror-settle-receipt-frames.test.tsx | 5 +- ...ime-session-mirror-environment-key.test.ts | 32 +-- ...-runtime-session-mirror-environment-key.ts | 40 ++- ...n-tabs-sync-reconnect-resubscribe.test.tsx | 245 ++++++++++++++++++ ...ssion-tabs-sync-unverifiable-host.test.tsx | 5 +- ...on-tabs-sync-visibility-collision.test.tsx | 5 +- ...ssion-tabs-sync-window-visibility.test.tsx | 5 +- .../use-web-session-tabs-sync.ts | 18 +- ...us-reconnect-connection-generation.test.ts | 42 +-- .../slices/runtime-status-snapshot.test.ts | 12 +- .../store/slices/runtime-status-snapshot.ts | 1 + .../src/store/slices/runtime-status.test.ts | 5 +- .../src/store/slices/runtime-status.ts | 18 +- src/shared/runtime-host-status.ts | 10 + 19 files changed, 511 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/runtime/host-session-mirror-flap-hydration.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx 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