From 68a4bfb58f25d0bbf04e4e5d56debeade35e4e31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:34:29 -0700 Subject: [PATCH] fix(runtime-host): Explorer and Source Control load again once a runtime host answers (#36684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude --- .../file-explorer-host-contact-retry.test.tsx | 72 +++++++++++++++++++ .../use-file-explorer-tree-load-effects.ts | 24 +++++++ .../use-refresh-on-host-contact.test.ts | 42 +++++++++++ .../use-refresh-on-host-contact.ts | 24 +++++++ .../right-sidebar/useGitStatusPolling.ts | 3 + ...me-host-contact-regained-wsl-ready.test.ts | 53 ++++++++++++++ .../runtime/runtime-host-contact-regained.ts | 16 ++++- 7 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-host-contact-retry.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.test.ts create mode 100644 src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.ts create mode 100644 src/renderer/src/runtime/runtime-host-contact-regained-wsl-ready.test.ts diff --git a/src/renderer/src/components/right-sidebar/file-explorer-host-contact-retry.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-host-contact-retry.test.tsx new file mode 100644 index 00000000000..3f9aadf3695 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-host-contact-retry.test.tsx @@ -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 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: (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) +}) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts index 9feddf986cf..e92fda4c620 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-load-effects.ts @@ -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 || diff --git a/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.test.ts b/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.test.ts new file mode 100644 index 00000000000..8ed5c3e88af --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.test.ts @@ -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 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) +}) diff --git a/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.ts b/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.ts new file mode 100644 index 00000000000..8b5d6e0f92d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-refresh-on-host-contact.ts @@ -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]) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 1256299a899..bf9ed2f00bd 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -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, diff --git a/src/renderer/src/runtime/runtime-host-contact-regained-wsl-ready.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained-wsl-ready.test.ts new file mode 100644 index 00000000000..e918f9220b1 --- /dev/null +++ b/src/renderer/src/runtime/runtime-host-contact-regained-wsl-ready.test.ts @@ -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 = {} + 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() + }) +}) diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.ts b/src/renderer/src/runtime/runtime-host-contact-regained.ts index 4ba155a4bad..b10153e6c37 100644 --- a/src/renderer/src/runtime/runtime-host-contact-regained.ts +++ b/src/renderer/src/runtime/runtime-host-contact-regained.ts @@ -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