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
This commit is contained in:
Neil
2026-10-19 15:36:46 -07:00
committed by GitHub
parent 90797aebca
commit 3d880e5182
10 changed files with 327 additions and 38 deletions
@@ -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(),
@@ -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)
})
})
@@ -1,6 +1,8 @@
const revisionByEnvironmentId = new Map<string, number>()
// Pairings proven to continue the same machine: old revision → the revision that replaced it.
const continuedRevisionsByEnvironmentId = new Map<string, Map<number, number>>()
// Re-pairs whose host identity is not decided yet: id → the pairing they may continue.
const pendingContinuationByEnvironmentId = new Map<string, number>()
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
@@ -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<string, { attempts: number; inFlight: boolean }>()
// Held closes also hidden under the current pairing while their own was unknown or undecided.
const hiddenUnderCurrentPairing = new Set<string>()
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<PairedTerminalTabCloseIntentStore, 'getIntents'>,
@@ -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<string>()
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()
}
@@ -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,
@@ -89,6 +89,23 @@ export type VisibilityResumeOmission = {
export const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
export const replayableSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
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
@@ -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()
@@ -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,
@@ -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: []
})
})
})
@@ -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 }
}