mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(runtime): hold four more host reads through an unverifiable probe (#20095)
* fix(runtime): hold the session mirror through an unverifiable probe Two derivations read the same host state and reached opposite verdicts, and the destructive one won. When a status probe came back unverifiable over a still-ready transport, runtimeHostConnectionStateForEntry called the host 'runtime-unavailable' (connected) while getReachableRuntimeSessionMirrorTargets dropped it, tearing down and cold-rebuilding the session-tab mirror while the host's flows were still delivering. The root cause is that applyRuntimeHostStatusSnapshot nulls entry.status for any non-verified probe while the snapshot retains the runtime identity. The connection-state reader consults the snapshot; the mirror-target reader did not. Give both readers one answer: - lastVerifiedRuntimeStatus() in shared/runtime-host-status.ts is now the single definition of "the last identity the host answered with". runtime-status.ts already had this inline as previousVerifiedStatus and now calls it. - The mirror-target reader asks the shared connection verdict instead of entry.status, gated on isDisconnectedRuntimeHostState -- only the one exit verdict earns a destructive read, per docs/reference/ssh-execution-boundary.md. 'checking' and 'reconnecting' are unverifiable, not evidence of an exit. Holding the target through the outage would strand the mirror on its own: the subscription is installed by the effect in use-web-session-tabs-sync.ts keyed on useRuntimeSessionMirrorEnvironmentKey(), a stream 'end' frame is dropped without resubscribing, and the parking layer retries only a rejected subscribe call. The teardown was the recovery. So regaining contact now advances the connection epoch, giving recovery its own "the host is back" trigger rather than leaving the mirror to be restored as a side effect of having been destroyed. The connection epoch is not the runtime session: a same-runtime return fires no restart hook, no provider session bump, and no toast. * test(runtime): drop the redundant status casts the new casting gate rejects * fix(runtime): hold four more host reads through an unverifiable probe Siblings of the session-mirror defect fixed in #20085. runtime-status-snapshot nulls `entry.status` for any non-verified probe while the snapshot retains the host's identity, so a host with a ready transport that is still delivering reads as gone to anything gating on `entry.status != null`. - client-event subscription selection dropped the stream for such a host, and its disconnect edge bumped the SSH generation, rebuilding even the active host's subscription - the web client's active session-tabs stream tore down and cold-rebuilt, twice per blip - landing preflight discarded its whole result - runtime-aware SSH selectors blanked mirrored target rows Each now reads the shared verdict, isConnectedRuntimeHostState of runtimeHostConnectionStateForEntry, or lastVerifiedRuntimeStatus where the read is host identity rather than reachability. No new predicate: every "genuinely gone" case is byte-identical, so nothing gains a retry loop. * test(runtime): build real host statuses instead of casting partials
This commit is contained in:
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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<typeof selectRuntimeAwareSshStatus>[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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof WorktreeRuntimeOwnerModule>()
|
||||
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<RuntimeSubscribe>[0]
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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<RuntimeSubscribe>(async (request) => {
|
||||
const unsubscribe = vi.fn()
|
||||
subscriptions.push({ request, unsubscribe })
|
||||
return { unsubscribe, sendBinary: vi.fn() }
|
||||
})
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Pick<AppState, 'runtimeStatusByEnvironmentId'>>
|
||||
|
||||
// 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(
|
||||
|
||||
Reference in New Issue
Block a user