diff --git a/src/renderer/src/components/landing-preflight-runtime.ts b/src/renderer/src/components/landing-preflight-runtime.ts index 6df10613668..23fd132376f 100644 --- a/src/renderer/src/components/landing-preflight-runtime.ts +++ b/src/renderer/src/components/landing-preflight-runtime.ts @@ -1,6 +1,10 @@ import { useEffect, useMemo } from 'react' import { useAppStore } from '../store' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +import { + isConnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' import { getLandingPreflightIssues, hasGitHubBackedProject, @@ -18,10 +22,13 @@ export function useLandingPreflightRuntime(): { preflightIssues: PreflightIssue[ return 'local' } const runtimeStatus = s.runtimeStatusByEnvironmentId.get(environmentId) + // Why the shared verdict and not `entry.status`: an unverifiable probe nulls it while the + // transport is still up, and reading that as unreachable discarded the whole preflight + // result for a host that never went away (docs/reference/ssh-execution-boundary.md). const reachability = runtimeStatus - ? runtimeStatus.status === null - ? 'unreachable' - : 'reachable' + ? isConnectedRuntimeHostState(runtimeHostConnectionStateForEntry(runtimeStatus)) + ? 'reachable' + : 'unreachable' : 'unknown' return `${environmentId}:${runtimeStatus?.connectionGeneration ?? 0}:${reachability}` }) diff --git a/src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx b/src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx new file mode 100644 index 00000000000..6903f29c0d4 --- /dev/null +++ b/src/renderer/src/components/landing-preflight-unverifiable-host.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { useAppStore } from '../store' +import type { RuntimeEnvironmentStatus } from '../store/slices/runtime-status-types' +import type { AppState } from '../store/types' +import { useLandingPreflightRuntime } from './landing-preflight-runtime' + +const ENVIRONMENT_ID = 'environment-a' +const initialState = useAppStore.getInitialState() +const invalidate = vi.fn() +const refresh = vi.fn(async () => {}) + +function makeStatus(): RuntimeStatus { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + } +} + +function snapshot(overrides: Partial = {}): RuntimeHostStatusSnapshot { + return { + environmentId: ENVIRONMENT_ID, + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: makeStatus(), + verification: 'verified', + transport: 'ready', + ...overrides + } +} + +function setStatusEntry(entry: RuntimeEnvironmentStatus): void { + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, entry]]) + }) +} + +describe('landing preflight under an unverifiable host probe', () => { + beforeEach(() => { + invalidate.mockClear() + refresh.mockClear() + useAppStore.setState( + { + ...initialState, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook under test reads only activeRuntimeEnvironmentId; the rest of GlobalSettings never reaches it. + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID } as AppState['settings'], + invalidatePreflightStatus: invalidate, + refreshPreflightStatus: refresh + }, + true + ) + setStatusEntry({ status: makeStatus(), snapshot: snapshot(), checkedAt: 1 }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + }) + + it('keeps preflight state when a ready host answers an unverifiable probe', () => { + renderHook(() => useLandingPreflightRuntime()) + expect(invalidate).not.toHaveBeenCalled() + + act(() => { + setStatusEntry({ + status: null, + snapshot: snapshot({ sequence: 2, checkedAt: 2, verification: 'unavailable' }), + checkedAt: 2 + }) + }) + + expect(invalidate).not.toHaveBeenCalled() + }) + + it('still discards preflight state once the transport goes down', () => { + renderHook(() => useLandingPreflightRuntime()) + + act(() => { + setStatusEntry({ + status: null, + snapshot: snapshot({ + sequence: 2, + checkedAt: 2, + verification: 'unavailable', + transport: 'disconnected' + }), + checkedAt: 2 + }) + }) + + expect(invalidate).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts index d6ec4dd5ec6..83d5c3373da 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts @@ -1,4 +1,8 @@ import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + isConnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import type { AppState } from '../../store/types' @@ -21,19 +25,34 @@ export function getRuntimeClientEventEnvironmentIds( ids.add(activeEnvironmentId) } for (const environment of state.runtimeEnvironments ?? []) { - if (state.runtimeStatusByEnvironmentId?.get(environment.id)?.status) { + if (isRuntimeHostStillInContact(state, environment.id)) { ids.add(environment.id) } } return [...ids] } +/** + * Why the shared verdict and not `entry.status`: an unverifiable probe nulls `entry.status` + * while the transport stays up and the host keeps delivering. Reading that as "gone" dropped + * the client-event subscription and fired the disconnect edge on a live host. Contact is lost + * only once the transport itself says so (docs/reference/ssh-execution-boundary.md). + */ +function isRuntimeHostStillInContact( + state: RuntimeEnvironmentStoreSyncState, + environmentId: string +): boolean { + return isConnectedRuntimeHostState( + runtimeHostConnectionStateForEntry(state.runtimeStatusByEnvironmentId?.get(environmentId)) + ) +} + export function getReachableRuntimeEnvironmentIds( state: RuntimeEnvironmentStoreSyncState ): string[] { const ids: string[] = [] - for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { - if (status?.status) { + for (const environmentId of state.runtimeStatusByEnvironmentId?.keys() ?? []) { + if (isRuntimeHostStillInContact(state, environmentId)) { ids.push(environmentId) } } diff --git a/src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts b/src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts new file mode 100644 index 00000000000..4e625c06c02 --- /dev/null +++ b/src/renderer/src/lib/runtime-host-unverifiable-probe-readers.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + getReachableRuntimeEnvironmentIds, + getRuntimeClientEventEnvironmentIds +} from '@/hooks/ipc-events/runtime-environment-subscription-selection' +import type { RuntimeEnvironmentStoreSyncState } from '@/hooks/ipc-events/runtime-environment-subscription-selection' +import { + selectRuntimeAwareSshError, + selectRuntimeAwareSshStatus +} from '@/store/slices/runtime-environment-ssh-selectors' + +const ENVIRONMENT_ID = 'environment-a' +const VERIFIED_STATUS: RuntimeStatus = { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 +} + +function snapshot(overrides: Partial = {}): RuntimeHostStatusSnapshot { + return { + environmentId: ENVIRONMENT_ID, + pairingRevision: 1, + sequence: 2, + checkedAt: 2, + status: VERIFIED_STATUS, + verification: 'verified', + transport: 'ready', + ...overrides + } +} + +/** The defect shape: the host answered once, its transport is still up, the last probe did not answer. */ +function unverifiableWhileReady(): { + status: null + checkedAt: number + snapshot: RuntimeHostStatusSnapshot +} { + return { status: null, checkedAt: 2, snapshot: snapshot({ verification: 'unavailable' }) } +} + +function transportDown(): { status: null; checkedAt: number; snapshot: RuntimeHostStatusSnapshot } { + return { + status: null, + checkedAt: 2, + snapshot: snapshot({ verification: 'unavailable', transport: 'disconnected' }) + } +} + +function syncState( + entry: { + status: RuntimeStatus | null + checkedAt: number + snapshot?: RuntimeHostStatusSnapshot + } | null +): RuntimeEnvironmentStoreSyncState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the readers under test consult only the four fields below; the rest of AppState never reaches them. + return { + runtimeEnvironments: [{ id: ENVIRONMENT_ID, createdAt: 1 }], + runtimeStatusByEnvironmentId: entry ? new Map([[ENVIRONMENT_ID, entry]]) : new Map(), + settings: { activeRuntimeEnvironmentId: null }, + sshStateByEnvironment: new Map() + } as unknown as RuntimeEnvironmentStoreSyncState +} + +describe('runtime client-event subscription selection', () => { + it('keeps a host whose transport is ready but whose last probe went unverifiable', () => { + expect(getRuntimeClientEventEnvironmentIds(syncState(unverifiableWhileReady()))).toEqual([ + ENVIRONMENT_ID + ]) + }) + + it('keeps that host in the reachable set, so no spurious disconnect edge fires', () => { + expect(getReachableRuntimeEnvironmentIds(syncState(unverifiableWhileReady()))).toEqual([ + ENVIRONMENT_ID + ]) + }) + + it('still drops a host whose transport went down', () => { + expect(getRuntimeClientEventEnvironmentIds(syncState(transportDown()))).toEqual([]) + expect(getReachableRuntimeEnvironmentIds(syncState(transportDown()))).toEqual([]) + }) + + it('still drops a retired host and one that never answered', () => { + const retired = { status: null, checkedAt: 2, snapshot: snapshot({ retired: true }) } + expect(getRuntimeClientEventEnvironmentIds(syncState(retired))).toEqual([]) + expect( + getRuntimeClientEventEnvironmentIds( + syncState({ + status: null, + checkedAt: 0, + snapshot: snapshot({ status: null, verification: 'checking' }) + }) + ) + ).toEqual([]) + expect(getRuntimeClientEventEnvironmentIds(syncState(null))).toEqual([]) + }) + + it('keeps a verified host', () => { + expect( + getRuntimeClientEventEnvironmentIds( + syncState({ status: VERIFIED_STATUS, checkedAt: 2, snapshot: snapshot() }) + ) + ).toEqual([ENVIRONMENT_ID]) + }) +}) + +describe('runtime-aware SSH selectors', () => { + function sshState(entry: { + status: RuntimeStatus | null + checkedAt: number + snapshot?: RuntimeHostStatusSnapshot + }) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selectors read only the SSH maps and the status map built below. + return { + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map([ + [ + ENVIRONMENT_ID, + { + targetsHydrated: true, + connectionStates: new Map([['target-a', { status: 'connected', error: 'boom' }]]), + targetLabels: new Map([['target-a', 'Target A']]), + removedTargetLabels: new Map() + } + ] + ]), + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, entry]]) + } as unknown as Parameters[0] + } + + it('keeps reporting a mirrored SSH target while the host probe is unverifiable', () => { + const state = sshState(unverifiableWhileReady()) + expect(selectRuntimeAwareSshStatus(state, ENVIRONMENT_ID, 'target-a')).toBe('connected') + expect(selectRuntimeAwareSshError(state, ENVIRONMENT_ID, 'target-a')).toBe('boom') + }) + + it('still withholds SSH state once the transport is down', () => { + const state = sshState(transportDown()) + expect(selectRuntimeAwareSshStatus(state, ENVIRONMENT_ID, 'target-a')).toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx new file mode 100644 index 00000000000..c5939e9aefd --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-unverifiable-host.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner' + +const mocks = vi.hoisted(() => ({ + getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(), + runtimeSessionMirrorEnvironmentKey: vi.fn() +})) + +vi.mock('./use-runtime-session-mirror-environment-key', () => ({ + useRuntimeSessionMirrorEnvironmentKey: mocks.runtimeSessionMirrorEnvironmentKey +})) + +vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getExplicitRuntimeEnvironmentIdForWorktree: mocks.getExplicitRuntimeEnvironmentIdForWorktree + } +}) + +import { useAppStore } from '@/store' +import type { RuntimeEnvironmentStatus } from '@/store/slices/runtime-status-types' +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' + +const ENV_A = 'env-a' +const WORKTREE = 'repo-a::worktree-a' +const REVISION_A = 101 +const MIRROR_KEY = `${ENV_A}runtime-a0${REVISION_A}` +const initialState = useAppStore.getInitialState() + +type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe +type RuntimeSubscription = { + request: Parameters[0] + unsubscribe: ReturnType +} + +const subscriptions: RuntimeSubscription[] = [] +const runtimeCall = vi.fn(async (_args: { method: string }) => ({ + id: 'list-all', + ok: true as const, + result: { snapshots: [] }, + _meta: { runtimeId: 'runtime-a' } +})) +const runtimeSubscribe = vi.fn(async (request) => { + const unsubscribe = vi.fn() + subscriptions.push({ request, unsubscribe }) + return { unsubscribe, sendBinary: vi.fn() } +}) + +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +function makeStatus(runtimeId: string): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + } +} + +function verifiedSnapshot(): RuntimeHostStatusSnapshot { + return { + environmentId: ENV_A, + pairingRevision: REVISION_A, + sequence: 1, + checkedAt: 1, + status: makeStatus('runtime-a'), + verification: 'verified', + transport: 'ready' + } +} + +function setRuntimeStatusEntry(entry: RuntimeEnvironmentStatus): void { + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map([[ENV_A, entry]]) }) +} + +function activeTabsSubscriptions(): RuntimeSubscription[] { + return subscriptions.filter(({ request }) => request.method === 'session.tabs.subscribe') +} + +describe('useWebSessionTabsSync under an unverifiable host probe', () => { + beforeEach(() => { + subscriptions.length = 0 + runtimeCall.mockClear() + runtimeSubscribe.mockClear() + mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReset().mockReturnValue(ENV_A) + mocks.runtimeSessionMirrorEnvironmentKey.mockReset().mockReturnValue(MIRROR_KEY) + Object.defineProperty(window, 'api', { + configurable: true, + value: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + resetWebSessionTabsSnapshotFreshnessForTests() + clearHostLiveTerminalProbesForTests() + // 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, + activeWorktreeId: WORKTREE, + workspaceSessionReady: true, + runtimeEnvironments, + runtimeStatusByEnvironmentId: new Map([ + [ + ENV_A, + { + status: makeStatus('runtime-a'), + snapshot: verifiedSnapshot(), + checkedAt: 1, + connectionGeneration: 1 + } + ] + ]) + }, + true + ) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + replaceRuntimeEnvironmentRevisions([]) + resetWebSessionTabsSnapshotFreshnessForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + }) + + it('holds the active session-tabs subscription when the probe goes unverifiable', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(settle) + const held = activeTabsSubscriptions() + expect(held).toHaveLength(1) + + // The transport is still ready and the host is still delivering; only the probe failed. + await act(async () => { + setRuntimeStatusEntry({ + status: null, + snapshot: { ...verifiedSnapshot(), sequence: 2, checkedAt: 2, verification: 'unavailable' }, + checkedAt: 2, + connectionGeneration: 1 + }) + await settle() + }) + + expect(held[0]!.unsubscribe).not.toHaveBeenCalled() + expect(activeTabsSubscriptions()).toHaveLength(1) + }) + + it('still restarts the subscription when the host answers with a replacement runtime', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(settle) + expect(activeTabsSubscriptions()).toHaveLength(1) + + await act(async () => { + setRuntimeStatusEntry({ + status: makeStatus('runtime-b'), + snapshot: { + ...verifiedSnapshot(), + sequence: 2, + checkedAt: 2, + status: makeStatus('runtime-b') + }, + checkedAt: 2, + connectionGeneration: 1 + }) + await settle() + }) + + expect(activeTabsSubscriptions()).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts index d11e259538d..231f2b1ce22 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts @@ -1,4 +1,5 @@ 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' @@ -35,11 +36,14 @@ export function useWebSessionTabsSync(): void { getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) // Keep this subscription dependency: a runtime reconnect can retain the same environment id - // while replacing its runtime instance, which must restart the scoped stream. + // while replacing its runtime instance, which must restart the scoped stream. Read the last + // identity the host answered with, not `entry.status` — an unverifiable probe nulls that and + // cold-rebuilt this stream for a host that was still delivering. const activeWorktreeRuntimeId = useAppStore((state) => { const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) return environmentId - ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null) + ? (lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(environmentId)) + ?.runtimeId ?? null) : null }) const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => { diff --git a/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts index 662f72d581f..7cd1942db3a 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts @@ -1,5 +1,9 @@ import type { AppState } from '../types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { + isConnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' type RuntimeAwareSshReadState = Pick< AppState, @@ -11,8 +15,13 @@ type RuntimeAwareSshReadState = Pick< > & Partial> +// Why the shared verdict and not `entry.status`: an unverifiable probe nulls it while the +// transport is still up, and blanking the mirrored SSH rows of a host that never went away +// reads as "the targets vanished" (docs/reference/ssh-execution-boundary.md). function isEnvironmentReachable(state: RuntimeAwareSshReadState, environmentId: string): boolean { - return Boolean(state.runtimeStatusByEnvironmentId?.get(environmentId)?.status) + return isConnectedRuntimeHostState( + runtimeHostConnectionStateForEntry(state.runtimeStatusByEnvironmentId?.get(environmentId)) + ) } export function selectRuntimeAwareSshStatus(