fix(runtime-host): Explorer and Source Control load again once a runtime host answers (#36684)

* fix(runtime-host): Explorer and Source Control load again once a runtime host answers

After a relaunch with a WSL-host workspace active, both panels read before the host connected:
Explorer kept "the Orca server … isn't answering" until Retry, and Source Control kept a clean
status until a reload. The runtime status stays verified across the relaunch, so the existing
contact-regained signal never fired when the WSL host's connection came up.

- subscribeRuntimeHostContactRegained also fires when the environment's WSL host turns ready.
- Explorer reloads a failed root on that signal (gated: never boots a stopped distro), as it
  already does for SSH providers.
- Git status polling reads again on that signal.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime-host): keep the Explorer's contact retry current from an effect, not render

React Doctor flags a ref mutated during render.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-31 06:34:29 -07:00
committed by GitHub
co-authored by Claude
parent 63fc1f11c5
commit 68a4bfb58f
7 changed files with 232 additions and 2 deletions
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
const contact = vi.hoisted(() => ({ listeners: new Map<string, () => void>() }))
vi.mock('@/runtime/runtime-host-contact-regained', () => ({
subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => {
contact.listeners.set(environmentId, listener)
return () => contact.listeners.delete(environmentId)
}
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
runtimeEnvironmentIdOrLocalUnlessTwin: (_state: unknown, workspaceId: string | null) =>
workspaceId === 'wt-host' ? 'wsl:ubuntu' : null
}))
vi.mock('@/store', () => ({
useAppStore: <T,>(selector: (state: unknown) => T) =>
selector({ settings: {}, sshConnectedGeneration: 0 })
}))
vi.mock('@/lib/workspace-switch-user-act', () => ({ takeRecentWorkspaceSwitchWake: () => false }))
import { useFileExplorerTreeLoadEffects } from './use-file-explorer-tree-load-effects'
afterEach(() => {
cleanup()
contact.listeners.clear()
})
function render(workspaceKey: string, rootError: string | null) {
const resetAndLoad = vi.fn()
const hook = renderHook(
({ error }: { error: string | null }) =>
useFileExplorerTreeLoadEffects({
workspaceKey,
visibleFilesWorktreePath: '/home/u/app',
expanded: new Set(),
dirCache: {},
loadingDirPaths: new Set(),
rootError: error,
isDirStale: () => false,
loadDir: vi.fn(async () => true),
refreshTree: vi.fn(async () => undefined),
resetAndLoad,
resetSelection: vi.fn(),
setNameFilterQuery: vi.fn()
}),
{ initialProps: { error: rootError } }
)
resetAndLoad.mockClear()
return { hook, resetAndLoad }
}
// QA F1: after a relaunch the tree loaded before the WSL host answered and showed its error until Retry.
it('reloads a tree that failed once its runtime host answers again', () => {
const { resetAndLoad } = render('wt-host', 'The Orca server in the WSL distro isn’t answering')
contact.listeners.get('wsl:ubuntu')?.()
expect(resetAndLoad).toHaveBeenCalledOnce()
// Without the user acting, so a stopped distro is never booted for it (E9).
expect(resetAndLoad).toHaveBeenCalledWith()
})
it('leaves a tree that loaded fine alone, and a local workspace never subscribes', () => {
const { hook, resetAndLoad } = render('wt-host', 'failed')
hook.rerender({ error: null })
contact.listeners.get('wsl:ubuntu')?.()
expect(resetAndLoad).not.toHaveBeenCalled()
cleanup()
contact.listeners.clear()
render('wt-local', 'failed')
expect(contact.listeners.size).toBe(0)
})
@@ -7,6 +7,8 @@ import { clearFileExplorerUndoHistory } from './fileExplorerUndoRedo'
import type { DirCache } from './file-explorer-types'
import { splitPathSegments } from '../../../../shared/cross-platform-path'
import { takeRecentWorkspaceSwitchWake } from '@/lib/workspace-switch-user-act'
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner'
import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained'
type UseFileExplorerTreeLoadEffectsParams = {
/** The store worktree id, keying the user's own switches to this workspace. */
@@ -107,6 +109,28 @@ export function useFileExplorerTreeLoadEffects({
}
}, [sshConnectedGeneration, visibleFilesWorktreePath]) // eslint-disable-line react-hooks/exhaustive-deps
// The same for a runtime host (WSL or SSH server): a relaunch can load before it answers.
const runtimeEnvironmentId = useAppStore((s) =>
runtimeEnvironmentIdOrLocalUnlessTwin(s, workspaceKey)
)
const retryAfterContactRef = useRef(() => {})
// Kept current from an effect, not render; the subscription below outlives renders.
useEffect(() => {
retryAfterContactRef.current = () => {
if (visibleFilesWorktreePath && rootError) {
resetAndLoad()
}
}
}, [visibleFilesWorktreePath, rootError, resetAndLoad])
useEffect(() => {
if (!runtimeEnvironmentId) {
return
}
return subscribeRuntimeHostContactRegained(runtimeEnvironmentId, () =>
retryAfterContactRef.current()
)
}, [runtimeEnvironmentId])
useEffect(() => {
if (
!visibleFilesWorktreePath ||
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
const contact = vi.hoisted(() => ({ listeners: new Map<string, () => void>() }))
vi.mock('@/runtime/runtime-host-contact-regained', () => ({
subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => {
contact.listeners.set(environmentId, listener)
return () => contact.listeners.delete(environmentId)
}
}))
vi.mock('@/lib/window-visibility-interval', () => ({ isWindowVisible: () => true }))
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import { useRefreshOnHostContact } from './use-refresh-on-host-contact'
afterEach(() => {
cleanup()
contact.listeners.clear()
})
// QA F1: Source Control showed "clean" after a relaunch while the guest had a modified file.
it('reads Git status again when the runtime host answers, while it may fetch', () => {
const scheduler = { current: { refreshNow: vi.fn() } }
const canFetch = { current: true }
renderHook(() =>
useRefreshOnHostContact(toRuntimeExecutionHostId('wsl:ubuntu'), scheduler, canFetch)
)
contact.listeners.get('wsl:ubuntu')?.()
expect(scheduler.current.refreshNow).toHaveBeenCalledOnce()
canFetch.current = false
contact.listeners.get('wsl:ubuntu')?.()
expect(scheduler.current.refreshNow).toHaveBeenCalledOnce()
})
it('does nothing for a workspace on this computer', () => {
renderHook(() =>
useRefreshOnHostContact('local', { current: { refreshNow: vi.fn() } }, { current: true })
)
expect(contact.listeners.size).toBe(0)
})
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained'
import { parseExecutionHostId } from '../../../../shared/execution-host'
/** A status read before the runtime host answered (a relaunch) came back empty; read again once it does. */
export function useRefreshOnHostContact(
executionHostId: string | null | undefined,
scheduler: { readonly current: { refreshNow: () => void } | null },
canFetch: { readonly current: boolean }
): void {
const host = parseExecutionHostId(executionHostId)
const environmentId = host?.kind === 'runtime' ? host.environmentId : null
useEffect(() => {
if (!environmentId) {
return
}
return subscribeRuntimeHostContactRegained(environmentId, () => {
if (canFetch.current && isWindowVisible()) {
scheduler.current?.refreshNow()
}
})
}, [environmentId, scheduler, canFetch])
}
@@ -6,6 +6,7 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { refreshGitStatusForWorktree } from './git-status-refresh'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import { takeRecentWorkspaceSwitchWake } from '@/lib/workspace-switch-user-act'
import { useRefreshOnHostContact } from './use-refresh-on-host-contact'
import {
hasInteractiveActiveGitStatusConsumer,
shouldPollActiveGitStatus
@@ -249,6 +250,8 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
}
}, [activeStatusPollScope, canFetchActiveWorktreeGitStatus])
useRefreshOnHostContact(activeExecutionHostId, statusSchedulerRef, canApplyScheduledStatusRef)
const previousConsumerVisibilityRef = useRef({
worktreeId: activeWorktreeId,
visible: interactiveConsumerVisible,
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/store', async () => {
const { create } = await import('zustand')
return {
useAppStore: create(() => {
const wslHostStatuses: Record<string, { state: WslHostConnectionState }> = {}
return { runtimeStatusByEnvironmentId: new Map(), runtimeEnvironments: [], wslHostStatuses }
})
}
})
import { useAppStore } from '@/store'
import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained'
import type { WslHostConnectionState } from '../../../shared/wsl-host-types'
function wslHost(state: WslHostConnectionState): void {
useAppStore.setState({
wslHostStatuses: {
'wsl:ubuntu': { environmentId: 'wsl:ubuntu', distro: 'Ubuntu', state, detail: null }
}
})
}
// QA F1: after a relaunch the runtime status stayed verified while the WSL host connected, so
// panels that loaded before it never heard the host was back.
describe('regaining a WSL host', () => {
it('counts the host becoming ready, once per return', () => {
wslHost('connecting')
const listener = vi.fn()
const stop = subscribeRuntimeHostContactRegained('wsl:ubuntu', listener)
wslHost('ready')
expect(listener).toHaveBeenCalledOnce()
wslHost('ready')
expect(listener).toHaveBeenCalledOnce()
wslHost('distro-stopped')
expect(listener).toHaveBeenCalledOnce()
wslHost('ready')
expect(listener).toHaveBeenCalledTimes(2)
stop()
})
it('ignores another environment', () => {
const listener = vi.fn()
const stop = subscribeRuntimeHostContactRegained('wsl:debian', listener)
wslHost('connecting')
wslHost('ready')
expect(listener).not.toHaveBeenCalled()
stop()
})
})
@@ -13,25 +13,37 @@ function contactMarks(state: AppState, environmentId: string): string {
: runtimeEnvironmentContinuityStamp(environmentId)
}
function wslHostReady(state: AppState, environmentId: string): boolean {
// Optional: test stores built from one slice carry no WSL host statuses.
return state.wslHostStatuses?.[environmentId]?.state === 'ready'
}
/**
* Calls `listener` each time this client regains contact with an environment's runtime: the
* same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`),
* or resolved pairing continuity published by runtime-status.ts.
* resolved pairing continuity published by runtime-status.ts, or its WSL host's connection
* becoming ready, which a status kept verified across a relaunch does not show.
*/
export function subscribeRuntimeHostContactRegained(
environmentId: string,
listener: () => void
): () => void {
let lastMarks = contactMarks(useAppStore.getState(), environmentId)
let lastWslReady = wslHostReady(useAppStore.getState(), environmentId)
return useAppStore.subscribe((state, previousState) => {
const wslChanged = state.wslHostStatuses !== previousState.wslHostStatuses
if (
!wslChanged &&
state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId &&
state.runtimeEnvironments === previousState.runtimeEnvironments
) {
return
}
const wslReady = wslHostReady(state, environmentId)
const becameReady = wslReady && !lastWslReady
lastWslReady = wslReady
const marks = contactMarks(state, environmentId)
if (marks === lastMarks) {
if (marks === lastMarks && !becameReady) {
return
}
lastMarks = marks