From 3d880e5182f874ea4654ea5ceec934ad258b5918 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:36:46 -0700 Subject: [PATCH] fix(session-tabs): a held paired-tab close survives an undecided re-pair, and a dropped one shows its tab at once (#30843) * fix(session-tabs): a held paired-tab close keeps hiding through an undecided re-pair, and its expiry redraws the tab * fix(session-tabs): dropping a held close also clears its hide under the current pairing * fix(session-tabs): redraw only the worktree a dropped close un-hid; inventory frames see the redraw mark * fix(session-tabs): a re-pair proven the same machine asks for the snapshot its moved closes wait on * fix(session-tabs): a dropped close clears only the current-pairing hide it laid * fix(session-tabs): every un-hide of a held close lets its worktree's current snapshot redraw --- ...inal-tab-close-environment-removal.test.ts | 102 +++++++++++++++++- ...d-terminal-tab-close-expiry-redraw.test.ts | 96 +++++++++++++++++ .../runtime/runtime-environment-revision.ts | 20 ++++ .../src/runtime/web-session-close-replay.ts | 54 +++++++++- .../global-session-inventory-event.ts | 21 ++-- .../runtime/web-session-tabs-sync/state.ts | 17 +++ .../tracking-lifecycle.ts | 13 +-- .../runtime/web-session-tabs-sync/tracking.ts | 5 +- ...ntime-environment-peer-replacement.test.ts | 18 +++- .../runtime-environment-peer-replacement.ts | 19 +++- 10 files changed, 327 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/runtime/paired-terminal-tab-close-expiry-redraw.test.ts diff --git a/src/renderer/src/runtime/paired-terminal-tab-close-environment-removal.test.ts b/src/renderer/src/runtime/paired-terminal-tab-close-environment-removal.test.ts index 30e03ac30c5..cd9218695e6 100644 --- a/src/renderer/src/runtime/paired-terminal-tab-close-environment-removal.test.ts +++ b/src/renderer/src/runtime/paired-terminal-tab-close-environment-removal.test.ts @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { getDefaultWorkspaceSession } from '../../../shared/constants' -import type { PairedTerminalTabCloseIntent } from '../../../shared/paired-terminal-tab-close-intent' +import { + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS, + type PairedTerminalTabCloseIntent +} from '../../../shared/paired-terminal-tab-close-intent' import { useAppStore, type AppState } from '@/store' import { resetDeferredPeerChecksForTests } from '@/store/slices/runtime-environment-peer-replacement' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' @@ -13,12 +16,19 @@ import { resetWebSessionCloseReplaysForTests, takeDueWebSessionCloseReplays } from './web-session-close-replay' -import { isWebSessionCloseIntentPending } from './web-session-close-intent' +import { + isWebSessionCloseIntentPending, + recordWebSessionCloseIntent +} from './web-session-close-intent' import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ observeAgentHookCompletionForNotification: vi.fn() })) +const refreshSnapshot = vi.hoisted(() => vi.fn(async () => {})) +vi.mock('./web-runtime-session-snapshot', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSnapshot +})) const WT = 'repo::/wt' const TAB = 'host-tab-1' @@ -41,7 +51,7 @@ function environment( } } -function managed(id: string, pairingRevision: number, hostKeyFingerprint: string) { +function managed(id: string, pairingRevision: number, hostKeyFingerprint?: string) { return environment(id, { pairingRevision, hostKeyFingerprint, @@ -81,6 +91,7 @@ beforeEach(() => { initialState = useAppStore.getState() }) afterEach(() => { + vi.useRealTimers() useAppStore.setState(initialState, true) replaceRuntimeEnvironmentRevisions([]) resetDeferredPeerChecksForTests() @@ -129,6 +140,91 @@ describe('held paired-host closes when a server is removed', () => { ]) }) + it("holds a managed re-pair's closes, unsent, until its host key decides the machine", async () => { + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')]) + hold('env-a') + // The re-pair lands before the catalog carries the host's key. + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)]) + + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + expect(heldRevisions('env-a')).toEqual([1]) + expect(hidden('env-a', 2)).toBe(true) + + refreshSnapshot.mockClear() + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-a')]) + + expect(heldRevisions('env-a')).toEqual([2]) + // An idle host may publish nothing more, so the decision itself asks for the snapshot. + await vi.waitFor(() => + expect(refreshSnapshot).toHaveBeenCalledWith('env-a', WT, { + expectedEnvironmentPairingRevision: 2 + }) + ) + const due = takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB])) + expect(due.map((replay) => replay.owner)).toEqual([ + { environmentId: 'env-a', pairingRevision: 2 } + ]) + // Once moved, a later catalog has nothing to move and asks for nothing. + refreshSnapshot.mockClear() + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-a')]) + await Promise.resolve() + expect(refreshSnapshot).not.toHaveBeenCalled() + }) + + it('keeps holding through a second undecided re-pair and moves them to the newest one', () => { + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')]) + hold('env-a') + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)]) + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 3)]) + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 3, 'key-a')]) + + expect(heldRevisions('env-a')).toEqual([3]) + }) + + it('drops the held closes once the late host key proves another machine', () => { + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')]) + hold('env-a') + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)]) + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + + expect(hidden('env-a', 2)).toBe(true) + + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2, 'key-reinstalled')]) + + expect(heldRevisions('env-a')).toBeUndefined() + expect(hidden('env-a', 2)).toBe(false) + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + }) + + it('shows the tab again when a close held through an undecided re-pair expires', () => { + vi.useFakeTimers({ toFake: ['Date'] }) + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 1, 'key-a')]) + hold('env-a') + useAppStore.getState().setRuntimeEnvironments([managed('env-a', 2)]) + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + vi.setSystemTime(Date.now() + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1) + + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + + expect(heldRevisions('env-a')).toBeUndefined() + expect(hidden('env-a', 2)).toBe(false) + }) + + it("dropping an old pairing's close leaves a fresh close of the same tab hidden", () => { + useAppStore.getState().setRuntimeEnvironments([environment('env-a')]) + hold('env-a') + replaceRuntimeEnvironmentRevisions([{ id: 'env-a', createdAt: 1, pairingRevision: 2 }]) + // The user closes the tab again under the new pairing; that close is still in flight. + recordWebSessionCloseIntent({ environmentId: 'env-a', pairingRevision: 2 }, WT, TAB, Date.now()) + + expect(takeDueWebSessionCloseReplays(STORE, 'env-a', WT, new Set([TAB]))).toEqual([]) + + expect(heldRevisions('env-a')).toBeUndefined() + expect(hidden('env-a', 2)).toBe(true) + }) + it('drops, at the first catalog, the closes of a server removed while the app was closed', () => { useAppStore.getState().hydrateWorkspaceSession({ ...getDefaultWorkspaceSession(), diff --git a/src/renderer/src/runtime/paired-terminal-tab-close-expiry-redraw.test.ts b/src/renderer/src/runtime/paired-terminal-tab-close-expiry-redraw.test.ts new file mode 100644 index 00000000000..50bafd3e9cd --- /dev/null +++ b/src/renderer/src/runtime/paired-terminal-tab-close-expiry-redraw.test.ts @@ -0,0 +1,96 @@ +// A held close that expires is dropped when the host's snapshot arrives. The tab it hid must come +// back on that snapshot even when it is unchanged, not wait for the host to publish a change. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS } from '../../../shared/paired-terminal-tab-close-intent' +import { useAppStore, type AppState } from '@/store' +import { appPairedTerminalTabCloseIntentStore as STORE } from './paired-terminal-tab-close-intent-store' +import { + holdWebSessionCloseForReplay, + resetWebSessionCloseReplaysForTests +} from './web-session-close-replay' +import { isWebSessionCloseIntentPending } from './web-session-close-intent' +import { recordReceivedWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking' +import { shouldApplyWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + makeSnapshot, + resetWebSessionTabsSyncTestState, + WT +} from './web-session-tabs-sync-test-harness' + +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +const HOST_TAB = 'host-tab-1' +const SNAPSHOT = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB, + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready', + terminal: 'term_host', + isActive: true + } +]) +const OWNER = { environmentId: ENV, pairingRevision: 1 } + +let initialState: AppState + +beforeEach(() => { + initialState = useAppStore.getState() + vi.useFakeTimers({ now: NOW }) + resetWebSessionTabsSyncTestState() + replaceRuntimeEnvironmentRevisions([{ id: ENV, createdAt: 0, pairingRevision: 1 }]) +}) +afterEach(() => { + vi.useRealTimers() + useAppStore.setState(initialState, true) + resetWebSessionCloseReplaysForTests() + resetWebSessionTabsSyncTestState() + replaceRuntimeEnvironmentRevisions([]) +}) + +describe('an expired held paired-host close', () => { + it('lets the unchanged snapshot that dropped it redraw the tab', () => { + holdWebSessionCloseForReplay(STORE, OWNER, WT, [HOST_TAB]) + // The tab is hidden when this snapshot applies. + expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true) + expect(isWebSessionCloseIntentPending(OWNER, WT, HOST_TAB, Date.now())).toBe(true) + + vi.setSystemTime(NOW + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1) + recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT) + + expect(useAppStore.getState().pairedTerminalTabCloseIntentsByEnvironment[ENV]).toBeUndefined() + expect(isWebSessionCloseIntentPending(OWNER, WT, HOST_TAB, Date.now())).toBe(false) + expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true) + }) + + it('redraws another worktree whose expired close the same drop pruned', () => { + const otherWorktree = 'repo::/other' + const otherSnapshot = { ...SNAPSHOT, worktree: otherWorktree } + holdWebSessionCloseForReplay(STORE, OWNER, WT, [HOST_TAB]) + holdWebSessionCloseForReplay(STORE, OWNER, otherWorktree, [HOST_TAB]) + expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true) + expect(shouldApplyWebSessionTabsSnapshot(otherSnapshot, ENV)).toBe(true) + + vi.setSystemTime(NOW + PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS + 1) + recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT) + + expect(useAppStore.getState().pairedTerminalTabCloseIntentsByEnvironment[ENV]).toBeUndefined() + expect(isWebSessionCloseIntentPending(OWNER, otherWorktree, HOST_TAB, Date.now())).toBe(false) + expect(shouldApplyWebSessionTabsSnapshot(otherSnapshot, ENV)).toBe(true) + }) + + it('still refuses an unchanged snapshot when nothing was un-hidden', () => { + expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(true) + recordReceivedWebSessionTabsSnapshot(ENV, SNAPSHOT) + expect(shouldApplyWebSessionTabsSnapshot(SNAPSHOT, ENV)).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/runtime-environment-revision.ts b/src/renderer/src/runtime/runtime-environment-revision.ts index 9c0a2b7d8d3..2fc62085c91 100644 --- a/src/renderer/src/runtime/runtime-environment-revision.ts +++ b/src/renderer/src/runtime/runtime-environment-revision.ts @@ -1,6 +1,8 @@ const revisionByEnvironmentId = new Map() // Pairings proven to continue the same machine: old revision → the revision that replaced it. const continuedRevisionsByEnvironmentId = new Map>() +// Re-pairs whose host identity is not decided yet: id → the pairing they may continue. +const pendingContinuationByEnvironmentId = new Map() const retirementListeners = new Set<(environmentIds: readonly string[]) => void>() const revisionChangeListeners = new Set<(environmentIds: readonly string[]) => void>() @@ -9,6 +11,8 @@ export type RuntimeEnvironmentContinuity = { retired: readonly string[] /** Re-pairs proven to be the same machine (same registration and host key). */ sameHost: readonly { id: string; fromRevision: number; toRevision: number }[] + /** Re-pairs not yet proven either way, with the pairing they may continue. */ + pending?: readonly { id: string; fromRevision: number }[] } export function replaceRuntimeEnvironmentRevisions( @@ -39,6 +43,12 @@ export function replaceRuntimeEnvironmentRevisions( continued.set(fromRevision, toRevision) continuedRevisionsByEnvironmentId.set(id, continued) } + pendingContinuationByEnvironmentId.clear() + for (const { id, fromRevision } of continuity.pending ?? []) { + if (nextIds.has(id) && !retired.has(id)) { + pendingContinuationByEnvironmentId.set(id, fromRevision) + } + } revisionByEnvironmentId.clear() for (const environment of environments) { revisionByEnvironmentId.set( @@ -97,6 +107,16 @@ export function resolveContinuedRuntimeEnvironmentRevision( return current } +/** True while a re-pair that may continue `revision` is still waiting for its host identity. */ +export function isRuntimeEnvironmentContinuationPending( + environmentId: string, + revision: number | undefined +): boolean { + return ( + revision !== undefined && pendingContinuationByEnvironmentId.get(environmentId) === revision + ) +} + export function captureRuntimeEnvironmentRequestRevision( environmentId: string, expectedRevision?: number diff --git a/src/renderer/src/runtime/web-session-close-replay.ts b/src/renderer/src/runtime/web-session-close-replay.ts index c6d361f70f5..7dd5938e030 100644 --- a/src/renderer/src/runtime/web-session-close-replay.ts +++ b/src/renderer/src/runtime/web-session-close-replay.ts @@ -10,13 +10,17 @@ import { type PairedTerminalTabCloseIntent } from '../../../shared/paired-terminal-tab-close-intent' import type { PairedTerminalTabCloseIntentsByEnvironment } from './paired-terminal-tab-close-intents' -import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { + getRuntimeEnvironmentRevision, + isRuntimeEnvironmentContinuationPending +} from './runtime-environment-revision' import { clearWebSessionCloseIntent, makeWebSessionCloseIntentDurable, recordWebSessionCloseIntent } from './web-session-close-intent' import type { WebSessionIntentOwner } from './web-session-intent-owner' +import { acceptReplayedWebSessionTabsSnapshot } from './web-session-tabs-sync/state' /** Bounded so a host that keeps failing the close hands the tab back instead of hiding it forever. */ export const MAX_WEB_SESSION_CLOSE_REPLAY_ATTEMPTS = 3 @@ -38,6 +42,8 @@ export type WebSessionCloseReplay = { // Session-scoped on purpose: after a reload a held close starts a fresh attempt count. const attemptsByIntent = new Map() +// Held closes also hidden under the current pairing while their own was unknown or undecided. +const hiddenUnderCurrentPairing = new Set() function attemptKey(environmentId: string, intentKey: string): string { return `${environmentId}\0${intentKey}` @@ -84,6 +90,8 @@ function revealHeldClose( * The one way a held close leaves the store. Whatever the change removed -- the named intents, a * cap eviction, or anything past the age bound that the store prunes on the way -- is never sent * again, so it stops hiding its tab, except a delivered close the host's next snapshot will settle. + * The tab was hidden when its worktree's current snapshot applied, so that snapshot may apply + * again unchanged; otherwise the tab stays hidden until the host publishes a change. */ function changeHeldIntents( store: Pick, @@ -93,17 +101,35 @@ function changeHeldIntents( ): void { const before = listHeldIntents(store, environmentId) change() - const kept = new Set(listHeldIntents(store, environmentId).map(pairedTerminalTabCloseIntentKey)) + const after = listHeldIntents(store, environmentId) + const kept = new Set(after.map(pairedTerminalTabCloseIntentKey)) + const stillHeldTabs = new Set( + after.flatMap((intent) => intent.hostTabIds.map((id) => `${intent.worktreeId}\0${id}`)) + ) + const revealedWorktreeIds = new Set() for (const intent of before) { const key = pairedTerminalTabCloseIntentKey(intent) if (kept.has(key)) { continue } attemptsByIntent.delete(attemptKey(environmentId, key)) + const hidUnderCurrent = hiddenUnderCurrentPairing.delete(attemptKey(environmentId, key)) if (!stillHidden.has(key)) { revealHeldClose(intentOwner(environmentId, intent), intent.worktreeId, intent.hostTabIds) + // Only the hide this close laid: a fresh close of the same tab may hide there too. + if (hidUnderCurrent) { + revealHeldClose( + { environmentId }, + intent.worktreeId, + intent.hostTabIds.filter((id) => !stillHeldTabs.has(`${intent.worktreeId}\0${id}`)) + ) + } + revealedWorktreeIds.add(intent.worktreeId) } } + for (const worktreeId of revealedWorktreeIds) { + acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) + } } /** Keeps an unsent user close, hiding its tab; a repeat for the same tab keeps its attempt count. */ @@ -156,9 +182,18 @@ export function takeDueWebSessionCloseReplays( // Why: after a reload a snapshot can land before the environment list publishes revisions. // Unknown is not a mismatch: keep hiding the tab (under the key the snapshot apply reads now) // and send nothing until the pairing can be checked. + // A re-pair still waiting for its host identity is unknown in the same way: it may yet prove to + // continue this close's pairing (the catalog then moves it) or to be another machine (dropped). + // A key that never arrives holds it until the age bound; safe, since nothing is sent meanwhile. const expired = now - intent.closedAt > PAIRED_TERMINAL_TAB_CLOSE_INTENT_MAX_AGE_MS - if (!expired && currentRevision === undefined && intent.pairingRevision !== undefined) { + if ( + !expired && + intent.pairingRevision !== undefined && + (currentRevision === undefined || + isRuntimeEnvironmentContinuationPending(environmentId, intent.pairingRevision)) + ) { hideHeldClose({ environmentId }, intent.worktreeId, intent.hostTabIds) + hiddenUnderCurrentPairing.add(key) continue } // Why: the close was the old pairing's, so a replacement host must never receive it; and past @@ -271,6 +306,18 @@ export function reconcileWebSessionCloseReplaysWithCatalog( for (const intent of rebased) { hideHeldClose(intentOwner(environmentId, intent), intent.worktreeId, intent.hostTabIds) } + // Why: a close held while the re-pair was undecided waits for a snapshot, and an idle host may + // publish none; ask for one so the moved close is sent now. + const worktreeIds = [...new Set(rebased.map((intent) => intent.worktreeId))] + // Lazy: the snapshot module reaches the store, which loads this module. + void import('./web-runtime-session-snapshot').then(({ refreshWebRuntimeSessionTabsSnapshot }) => + worktreeIds.forEach( + (worktreeId) => + void refreshWebRuntimeSessionTabsSnapshot(environmentId, worktreeId, { + expectedEnvironmentPairingRevision: toRevision + }) + ) + ) } } @@ -303,4 +350,5 @@ export function replayWebSessionClosesDueForSnapshot( export function resetWebSessionCloseReplaysForTests(): void { attemptsByIntent.clear() + hiddenUnderCurrentPairing.clear() } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index f8692972c69..60f85e7ca94 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -53,16 +53,6 @@ export function handleGlobalSessionInventoryEvent({ }: GlobalSessionInventoryEventArgs): void { const skipUnchangedResumeWork = awaitingVisibilityResumeInventory.value && !replayed awaitingVisibilityResumeInventory.value = false - const unchanged = event.snapshots.map((snapshot) => { - const key = `${environmentId}:${snapshot.worktree}` - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - return Boolean( - skipUnchangedResumeWork && - !replayableSessionTabsSnapshotByWorktree.has(key) && - freshness?.publicationEpoch === snapshot.publicationEpoch && - freshness.snapshotVersion === snapshot.snapshotVersion - ) - }) const receivedFrames = event.snapshots.map((snapshot) => { const frame = recordReceivedWebSessionTabsSnapshot( environmentId, @@ -73,6 +63,17 @@ export function handleGlobalSessionInventoryEvent({ coordinator.recordSnapshotReceipt(environmentId, snapshot, frame, runtimeId) return frame }) + // After receipt: a held close dropped at receipt marks its unchanged frame for redraw. + const unchanged = event.snapshots.map((snapshot) => { + const key = `${environmentId}:${snapshot.worktree}` + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + return Boolean( + skipUnchangedResumeWork && + !replayableSessionTabsSnapshotByWorktree.has(key) && + freshness?.publicationEpoch === snapshot.publicationEpoch && + freshness.snapshotVersion === snapshot.snapshotVersion + ) + }) const inventoryFrame = recordReceivedWebSessionTabsInventory(environmentId) const missing = coordinator.recordInventoryReceipt( environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index aa0297c250f..a73e8bac635 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -89,6 +89,23 @@ export type VisibilityResumeOmission = { export const latestSessionTabsSnapshotByWorktree = new Map() export const replayableSessionTabsSnapshotByWorktree = new Map() + +export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string { + return `${environmentId}:${worktreeId}` +} + +// Why: a replay may repeat the current epoch/version; permit only that exact +// identity once so an older concurrent frame cannot bypass monotonic ordering. +export function acceptReplayedWebSessionTabsSnapshot( + environmentId: string, + worktreeId: string +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsSnapshotByWorktree.get(key) + if (current) { + replayableSessionTabsSnapshotByWorktree.set(key, current) + } +} export const latestReceivedSessionTabsSnapshotByWorktree = new Map< string, ReceivedSessionTabsSnapshot diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts index 5f3465511f7..d845037045b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -76,18 +76,7 @@ export function getLatestWebSessionTabsPublicationEpoch( ) } -// Why: a replay may repeat the current epoch/version; permit only that exact -// identity once so an older concurrent frame cannot bypass monotonic ordering. -export function acceptReplayedWebSessionTabsSnapshot( - environmentId: string, - worktreeId: string -): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsSnapshotByWorktree.get(key) - if (current) { - replayableSessionTabsSnapshotByWorktree.set(key, current) - } -} +export { acceptReplayedWebSessionTabsSnapshot } from './state' export function resetWebSessionTabsSnapshotFreshnessForTests(): void { latestSessionTabsSnapshotByWorktree.clear() replayableSessionTabsSnapshotByWorktree.clear() diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index 50c6130d53c..a29e24ac373 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -10,6 +10,7 @@ import { setBoundedSessionTabsReceipt, trackedSessionTabsWorktreeIdsByEnvironment, nextReceivedSessionTabsFrame, + sessionTabsFreshnessKey, type SnapshotFreshness, type SessionTabsListAllResult, type TrackedWebSessionTabsWorktree @@ -34,9 +35,7 @@ export function isSessionTabsListAllResult(value: unknown): value is SessionTabs ) } -export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string { - return `${environmentId}:${worktreeId}` -} +export { sessionTabsFreshnessKey } from './state' export function advancesSessionTabsFreshness( snapshot: RuntimeMobileSessionTabsResult, diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts index 11b0a8da756..3133b4c7cc8 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.test.ts @@ -77,7 +77,8 @@ describe('which re-pairs a live pane may follow', () => { it('reports a same-key update as a same-host rotation from the old pairing', () => { expect(classify(managed(), managed({ pairingRevision: 2 }))).toEqual({ retired: [], - sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] + sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }], + pending: [] }) }) @@ -88,10 +89,17 @@ describe('which re-pairs a live pane may follow', () => { expect(classify(managed(), managed({ generation: 2, pairingRevision: 2 })).sameHost).toEqual([]) }) + it('reports no pending re-pair when the old pairing had no key to prove it by', () => { + expect( + classify(managed({ hostKey: null }), managed({ pairingRevision: 2, hostKey: null })).pending + ).toEqual([]) + }) + it('reports a deferred re-pair only once its key proves the same host, from the original pairing', () => { expect(classify(managed(), managed({ pairingRevision: 2, hostKey: null }))).toEqual({ retired: [], - sameHost: [] + sameHost: [], + pending: [{ id: 'env-1', fromRevision: 1 }] }) expect( classifyPeerReplacements( @@ -99,6 +107,10 @@ describe('which re-pairs a live pane may follow', () => { [managed({ pairingRevision: 2 })], [] ) - ).toEqual({ retired: [], sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }] }) + ).toEqual({ + retired: [], + sameHost: [{ id: 'env-1', fromRevision: 1, toRevision: 2 }], + pending: [] + }) }) }) diff --git a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts index 6df2cea52f2..7f5d46d7e85 100644 --- a/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts +++ b/src/renderer/src/store/slices/runtime-environment-peer-replacement.ts @@ -49,6 +49,7 @@ function pairingRevisionOf(environment: CatalogEnvironment | undefined): number } export type SameHostPairingRotation = { id: string; fromRevision: number; toRevision: number } +export type PendingPairingRotation = { id: string; fromRevision: number } /** * `retired`: ids that now name a different machine, whose workspaces and tabs are retired. A managed @@ -56,14 +57,18 @@ export type SameHostPairingRotation = { id: string; fromRevision: number; toRevi * pairing handshake proves, is unchanged under the same SSH target registration. A registration * alone is no proof (a reinstall or a target that resolves elsewhere keeps it), so a re-pair whose * key is not known yet is decided later, once a catalog carries it, rather than purged on a guess. - * `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. Re-pairs in - * neither list are still unresolved. + * `sameHost`: re-pairs proven to be the same machine, with the pairing they continue. `pending`: + * unresolved re-pairs that can still prove the same machine, with the pairing they would continue. */ export function classifyPeerReplacements( previous: readonly CatalogEnvironment[], next: readonly CatalogEnvironment[], replacedIds: readonly string[] -): { retired: string[]; sameHost: SameHostPairingRotation[] } { +): { + retired: string[] + sameHost: SameHostPairingRotation[] + pending: PendingPairingRotation[] +} { const nextById = new Map(next.map((environment) => [environment.id, environment])) const retired: string[] = [] const sameHost: SameHostPairingRotation[] = [] @@ -115,5 +120,11 @@ export function classifyPeerReplacements( recordSameHost(id, deferred.revision) } } - return { retired, sameHost } + // Why a known prior key only: without one the re-pair can never be proven, so nothing waits on it. + const pending = [...deferredHostKeyById].flatMap(([id, deferred]) => + deferred.hostKey !== null && deferred.revision !== null + ? [{ id, fromRevision: deferred.revision }] + : [] + ) + return { retired, sameHost, pending } }