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
This commit is contained in:
Neil
2026-09-17 02:07:52 -07:00
committed by GitHub
parent 851befa929
commit 96eb97aad6
19 changed files with 511 additions and 75 deletions
@@ -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
}
])
})
@@ -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
@@ -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
}
])
})
@@ -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<RuntimeStatusSlice>()((...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<typeof createRuntimeStatusSlice>))
}))
}
function makeStatus(runtimeId: string): RuntimeStatus {
return {
runtimeId,
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}
}
function makeSnapshot(
sequence: number,
patch: Partial<RuntimeHostStatusSnapshot> & Pick<RuntimeHostStatusSnapshot, 'verification'>
): RuntimeHostStatusSnapshot {
return {
environmentId: ENVIRONMENT_ID,
pairingRevision: PAIRING_REVISION,
sequence,
checkedAt: sequence,
status: makeStatus('rt-1'),
transport: 'ready',
...patch
}
}
function seedEnvironment(store: ReturnType<typeof createSliceStore>): 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)
})
@@ -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: ''
})
}
})
@@ -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: ''
})
}
})
@@ -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)
})
@@ -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,
@@ -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<typeof WorktreeRuntimeOwnerModule>()
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<RuntimeSubscribe>[0]
callbacks: Parameters<RuntimeSubscribe>[1]
unsubscribe: ReturnType<typeof vi.fn>
}
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<RuntimeSubscribe>(async (request, callbacks) => {
const unsubscribe = vi.fn()
subscriptions.push({ request, callbacks, unsubscribe })
return { unsubscribe, sendBinary: vi.fn() }
})
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve()
}
}
function hostSnapshot(
sequence: number,
patch: Partial<RuntimeHostStatusSnapshot> = {}
): 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<typeof buildRuntimeSessionMirrorEnvironmentKeys> {
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<typeof buildRuntimeSessionMirrorEnvironmentKeys>[0])
}
/** Connect, then lose contact over a still-ready transport: the stream ends, the probe cannot ask. */
async function connectThenLoseContact(): Promise<void> {
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<void> {
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)
})
})
@@ -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) => {
@@ -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) => {
@@ -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) => {
@@ -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
@@ -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<typeof buildRuntimeSessionMirrorEnvironmentKeys> {
// 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<typeof buildRuntimeSessionMirrorEnvironmentKey>[0])
} as Parameters<typeof buildRuntimeSessionMirrorEnvironmentKeys>[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)
@@ -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
)
@@ -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
}
@@ -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', () => {
@@ -200,13 +200,7 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
// already issued against this very connection — a startup worktree scan that had
// already answered was discarded, leaving those repos absent until an unrelated
// refresh (#19241).
// Why also on a same-runtime return: regaining contact is a new connection epoch, and
// it is the session mirror's "the host is back" trigger. The mirror used to be rebuilt
// as a side effect of having been destroyed when contact was lost; now that it is held
// through the outage, only this edge restores its subscription.
const reconnectedAfterLostContact = status.status !== null && previous?.status === null
const connectionChanged =
previous !== undefined && (runtimeSessionStarted || reconnectedAfterLostContact)
const connectionChanged = previous !== undefined && runtimeSessionStarted
const activeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim()
const connectionGeneration = connectionChanged
? runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(
@@ -217,12 +211,20 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration(
environmentId
))
// A same-runtime return is not a new connection, so it must not move the generation the
// mirror is keyed on. It still needs its own "the host is back" edge: the streams died with
// the transport, an 'end' frame resubscribes nothing, and the parking layer retries only a
// rejected subscribe. This counter is that edge, read only as a subscription-effect dep.
const reconnectedAfterLostContact = status.status !== null && previous?.status === null
const hostContactEpoch =
(previous?.hostContactEpoch ?? status.hostContactEpoch ?? 0) +
(reconnectedAfterLostContact ? 1 : 0)
// Why the session flag and not `connectionChanged`: integration-readiness caches key
// off the runtime session, for which a first publication is a real transition.
if (activeEnvironmentId === environmentId && (sessionEnded || runtimeSessionStarted)) {
bumpProviderRuntimeSessionGeneration()
}
const nextEntry = { ...status, connectionGeneration }
const nextEntry = { ...status, connectionGeneration, hostContactEpoch }
const currentEntry = s.runtimeStatusByEnvironmentId.get(environmentId)
// Why: an unchanged re-probe must not invalidate every Map subscriber. Real
// transitions change `status` or advance `connectionGeneration`, so they still write.
+10
View File
@@ -29,7 +29,17 @@ export type RuntimeEnvironmentStatus = {
remoteControl?: RuntimeStatus['remoteControl'] | null
appVersion?: string | null
checkedAt: number
/**
* Identity of the connection: which socket epoch retained state belongs to. Every cache key,
* stamp and settle fence compares this, so advancing it invalidates the session mirror.
*/
connectionGeneration?: number
/**
* Edge count of "the host answered again after we lost contact". A resubscribe trigger only —
* the streams died with the transport and nothing else revives them. Never an identity, a cache
* key, or a fence: that is `connectionGeneration`, and a flap must not move it (#19647).
*/
hostContactEpoch?: number
}
/**