mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user