mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
fix(wsl-host): a pane parked by a stopped host reopens once Start brings the host back (#36784)
* fix(wsl-host): a pane parked by a stopped host reopens once Start brings the host back * fix(wsl-host): clear only the returned host's stopped refusal; test the strip and the bridge
This commit is contained in:
@@ -30,9 +30,7 @@ export function wslHostNotConnectedMessage(distro: string): string {
|
||||
return `The WSL host ${distro} isn’t connected. If it’s stopped, click Start; otherwise open one of its projects or connect it in Settings → Remote Orca Servers.`
|
||||
}
|
||||
|
||||
export function wslHostStoppedMessage(distro: string): string {
|
||||
return `${distro} is stopped. To start it, click Start or open a new terminal in one of its projects.`
|
||||
}
|
||||
export { wslHostStoppedMessage } from '../../shared/wsl-host-stopped-message'
|
||||
|
||||
/** Shown on a paired phone once a stop is confirmed: only the desktop starts a distro. */
|
||||
export function wslHostStoppedForPhoneMessage(distro: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
import { useWslHostUnavailableReason } from './use-wsl-host-unavailable-reason'
|
||||
@@ -18,6 +18,8 @@ import { useRetryPanesWhenWslHostReturns } from './use-wsl-host-return-retry'
|
||||
import { useCalmStatusReveal } from '@/hooks/use-calm-status-reveal'
|
||||
import { useAppStore } from '@/store'
|
||||
import { paneShowsWslHostStoppedCard } from '@/lib/wsl-host-stopped-status'
|
||||
import { withoutWslHostStoppedLines } from '../../../../shared/wsl-host-stopped-message'
|
||||
import { mapPaneTerminalErrors } from './terminal-error-accumulation'
|
||||
import type { TerminalPaneController } from './use-terminal-pane-controller'
|
||||
|
||||
/** Reconnect pairs refused servers, signs in SSH hosts awaiting credentials, or otherwise retries the pane. */
|
||||
@@ -70,7 +72,24 @@ export function TerminalPaneRecoveryPortals({
|
||||
ownerEnvironmentId ? state.wslHostStatuses[ownerEnvironmentId] : undefined
|
||||
)
|
||||
const stoppedWslDistro = ownerWslHost?.state === 'distro-stopped' ? ownerWslHost.distro : null
|
||||
useRetryPanesWhenWslHostReturns(ownerEnvironmentId, ownerWslHost?.state, paneTransportsRef)
|
||||
const { setTerminalError, setTerminalErrorsByPaneId } = controller
|
||||
const ownerWslDistro = ownerWslHost?.distro ?? null
|
||||
// Why scoped to this host's distro: a pane on another, still-stopped host keeps its line.
|
||||
const clearStoppedHostErrors = useCallback(() => {
|
||||
if (!ownerWslDistro) {
|
||||
return
|
||||
}
|
||||
const strip = (error: string): string | null =>
|
||||
withoutWslHostStoppedLines(error, ownerWslDistro)
|
||||
setTerminalError((current) => (current === null ? null : strip(current)))
|
||||
setTerminalErrorsByPaneId((current) => mapPaneTerminalErrors(current, strip))
|
||||
}, [ownerWslDistro, setTerminalError, setTerminalErrorsByPaneId])
|
||||
useRetryPanesWhenWslHostReturns(
|
||||
ownerEnvironmentId,
|
||||
ownerWslHost?.state,
|
||||
paneTransportsRef,
|
||||
clearStoppedHostErrors
|
||||
)
|
||||
const retryingOutageEnvironmentId = useRetryingOutageEnvironmentId(controller.worktreeId)
|
||||
const isolated = controller.isolatedPaneKey !== null
|
||||
// Why: a pane waiting for SSH sign-in says so itself; "reconnecting" over it would be false.
|
||||
|
||||
@@ -219,6 +219,24 @@ describe('a runtime pane bridged to an older relay’s terminal', () => {
|
||||
expect(transport.getPtyId()).toBe('remote:env-1@@t-1')
|
||||
})
|
||||
|
||||
it('leaves a bridged pane’s host-return reopen alone, and forwards it once the pane runs on orcad', async () => {
|
||||
bridge.attach.mockResolvedValueOnce({ status: 'attached', replay: 'SCREEN', acquisitionId: 1 })
|
||||
const bridgedInner = innerTransport()
|
||||
bridgedInner.reopenAfterHostReturned = vi.fn(() => true)
|
||||
const bridged = createLegacyRelayBridgeTransport(PTY, bridgedInner)
|
||||
await bridged.connect({ url: '', sessionId: PTY, callbacks: callbacks() })
|
||||
expect(bridged.reopenAfterHostReturned?.()).toBe(false)
|
||||
expect(bridgedInner.reopenAfterHostReturned).not.toHaveBeenCalled()
|
||||
|
||||
bridge.attach.mockResolvedValueOnce({ status: 'exited' })
|
||||
const inner = innerTransport()
|
||||
inner.reopenAfterHostReturned = vi.fn(() => true)
|
||||
const forwarded = createLegacyRelayBridgeTransport(PTY, inner)
|
||||
await forwarded.connect({ url: '', sessionId: PTY, callbacks: callbacks() })
|
||||
expect(forwarded.reopenAfterHostReturned?.()).toBe(true)
|
||||
expect(inner.reopenAfterHostReturned).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an unreachable terminal bound and reattaches once the relay answers', async () => {
|
||||
bridge.attach.mockResolvedValueOnce({ status: 'unverifiable' })
|
||||
const transport = createLegacyRelayBridgeTransport(PTY, innerTransport())
|
||||
|
||||
@@ -231,6 +231,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp
|
||||
getConnectionId: () => (bridged() ? null : inner.getConnectionId?.()),
|
||||
ownsRecovery: () => (bridged() ? false : (inner.ownsRecovery?.() ?? false)),
|
||||
retryRecovery: () => (bridged() ? false : (inner.retryRecovery?.() ?? false)),
|
||||
reopenAfterHostReturned: () => !bridged() && (inner.reopenAfterHostReturned?.() ?? false),
|
||||
setOutputPaused: (paused) => {
|
||||
if (!bridged()) {
|
||||
return inner.setOutputPaused?.(paused) ?? false
|
||||
|
||||
@@ -236,6 +236,11 @@ export type PtyTransport = {
|
||||
getRecoveryState?: () => PtyTransportRecoveryState
|
||||
/** Starts a fresh connection epoch while preserving the authoritative remote PTY identity. */
|
||||
retryRecovery?: () => boolean
|
||||
/**
|
||||
* The host the pane met stopped is running again: reopen a pane whose connect or attach was
|
||||
* refused and left it idle (no retry armed), the way it opened. False when there is none to reopen.
|
||||
*/
|
||||
reopenAfterHostReturned?: () => boolean
|
||||
/** True while the transport has a retry armed or parked; pane-level remounts must defer to it. */
|
||||
ownsRecovery?: () => boolean
|
||||
/** Typed input the host has not applied yet, which a remount would discard. */
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => {
|
||||
throw new Error('These transport fixtures must not read app-store ownership')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { wslHostStoppedMessage } from '../../../../shared/wsl-host-stopped-message'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
createRemoteRuntimeTransportMocks,
|
||||
type MultiplexSubscriptionCallbacks
|
||||
} from './remote-runtime-pty-transport-test-harness'
|
||||
|
||||
const ORIGINAL_ID = 'remote:env-1@@terminal-1'
|
||||
|
||||
let callbacks: MultiplexSubscriptionCallbacks = null
|
||||
let resolvedHandle = 'terminal-1'
|
||||
const { runtimeCall, latestSubscribePayload, emitSnapshot, resetRemoteRuntimeTransport } =
|
||||
createRemoteRuntimeTransportMocks({
|
||||
getCallbacks: () => callbacks,
|
||||
setCallbacks: (value) => {
|
||||
callbacks = value
|
||||
},
|
||||
getResolvedPaneHandle: () => resolvedHandle,
|
||||
setResolvedPaneHandle: (value) => {
|
||||
resolvedHandle = value
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(resetRemoteRuntimeTransport)
|
||||
|
||||
async function completeAttach(): Promise<void> {
|
||||
await vi.waitFor(() => expect(latestSubscribePayload()).toBeDefined())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
|
||||
emitSnapshot(streamId, 'prompt$ ')
|
||||
}
|
||||
|
||||
/** Every runtime call is refused the way main refuses one for a stopped distro, until `start()`. */
|
||||
function stoppedHost(): { start: () => void } {
|
||||
let stopped = true
|
||||
const call = runtimeCall.getMockImplementation()
|
||||
runtimeCall.mockImplementation(async (request: { method: string }) => {
|
||||
if (stopped) {
|
||||
throw new Error(
|
||||
`Error invoking remote method 'runtimeEnvironments:call': Error: ${wslHostStoppedMessage('Ubuntu')}`
|
||||
)
|
||||
}
|
||||
return call?.(request)
|
||||
})
|
||||
return {
|
||||
start: () => {
|
||||
stopped = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('a restored remote pane whose host was stopped', () => {
|
||||
it.each(['connect', 'attach'] as const)(
|
||||
'reopens by %s once the host is running again, though its own retry has nothing armed',
|
||||
async (entry) => {
|
||||
const host = stoppedHost()
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
const onError = vi.fn()
|
||||
if (entry === 'connect') {
|
||||
void transport.connect({ url: '', sessionId: ORIGINAL_ID, callbacks: { onError } })
|
||||
} else {
|
||||
transport.attach({ existingPtyId: ORIGINAL_ID, cols: 80, rows: 24, callbacks: { onError } })
|
||||
}
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled())
|
||||
await flushAsyncTicks()
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
|
||||
host.start()
|
||||
// The refusal armed nothing, so the pane's own retry cannot bring it back.
|
||||
expect(transport.retryRecovery?.()).toBe(false)
|
||||
expect(transport.reopenAfterHostReturned?.()).toBe(true)
|
||||
await completeAttach()
|
||||
|
||||
await vi.waitFor(() => expect(transport.getPtyId()).toBe(ORIGINAL_ID))
|
||||
// Connected now: a second host return does not open it again.
|
||||
expect(transport.reopenAfterHostReturned?.()).toBe(false)
|
||||
transport.destroy?.()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a pane that is still connecting to its own path', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
|
||||
expect(transport.reopenAfterHostReturned?.()).toBe(false)
|
||||
void transport.connect({ url: '', sessionId: ORIGINAL_ID, callbacks: {} })
|
||||
expect(transport.reopenAfterHostReturned?.()).toBe(false)
|
||||
transport.destroy?.()
|
||||
})
|
||||
})
|
||||
@@ -3930,6 +3930,14 @@ export function createRemoteRuntimePtyTransport(
|
||||
surfacedErrorMessages.clear()
|
||||
},
|
||||
|
||||
reopenAfterHostReturned() {
|
||||
// Why only an idle pane: one already retrying or connected owns its own path back.
|
||||
if (connecting || recovery.currentPhase !== 'idle') {
|
||||
return false
|
||||
}
|
||||
return replayLastTransportEntryPoint()
|
||||
},
|
||||
|
||||
retryRecovery() {
|
||||
if (recovery.hasOwnerRefresh || surfacedErrorMessages.has(ownerChanged)) {
|
||||
return recovery.retryNow()
|
||||
|
||||
@@ -6,11 +6,12 @@ import type { WslHostConnectionState } from '../../../../shared/wsl-host-types'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
import { useRetryPanesWhenWslHostReturns } from './use-wsl-host-return-retry'
|
||||
|
||||
function pane(environmentId: string | null): PtyTransport {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only these two members.
|
||||
function pane(environmentId: string | null, retrying = true): PtyTransport {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only these three members.
|
||||
return {
|
||||
getRuntimeEnvironmentId: () => environmentId,
|
||||
retryRecovery: vi.fn(() => true)
|
||||
retryRecovery: vi.fn(() => retrying),
|
||||
reopenAfterHostReturned: vi.fn(() => true)
|
||||
} as unknown as PtyTransport
|
||||
}
|
||||
|
||||
@@ -70,4 +71,32 @@ describe('a stopped WSL host coming back on its own', () => {
|
||||
rerender()
|
||||
expect(hostPane.retryRecovery).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Why: after a relaunch the restored pane's connect was refused, so no retry is armed to resume.
|
||||
it('reopens a pane the stop left idle, and clears the stop’s error, after Start', () => {
|
||||
hostStatus('distro-stopped')
|
||||
const idlePane = pane('wsl:ubuntu', false)
|
||||
const retryingPane = pane('wsl:ubuntu', true)
|
||||
const onHostReturned = vi.fn()
|
||||
const { rerender } = renderHook(() =>
|
||||
useRetryPanesWhenWslHostReturns(
|
||||
'wsl:ubuntu',
|
||||
status,
|
||||
{
|
||||
current: new Map([
|
||||
[1, idlePane],
|
||||
[2, retryingPane]
|
||||
])
|
||||
},
|
||||
onHostReturned
|
||||
)
|
||||
)
|
||||
expect(onHostReturned).not.toHaveBeenCalled()
|
||||
hostStatus('ready')
|
||||
rerender()
|
||||
expect(onHostReturned).toHaveBeenCalledOnce()
|
||||
expect(idlePane.reopenAfterHostReturned).toHaveBeenCalledOnce()
|
||||
// A pane whose own retry took over is not opened a second time.
|
||||
expect(retryingPane.reopenAfterHostReturned).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,9 @@ export function useRetryPanesWhenWslHostReturns(
|
||||
ownerEnvironmentId: string | null,
|
||||
/** The owner's WSL host state, from the caller's own subscription (no listener of its own). */
|
||||
status: WslHostConnectionState | undefined,
|
||||
paneTransports: { readonly current: ReadonlyMap<number, PtyTransport> }
|
||||
paneTransports: { readonly current: ReadonlyMap<number, PtyTransport> },
|
||||
/** Clears the stop's own error from the panes; their old refusal is no longer true. */
|
||||
onHostReturned?: () => void
|
||||
): void {
|
||||
const stoppedHost = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -22,14 +24,18 @@ export function useRetryPanesWhenWslHostReturns(
|
||||
return
|
||||
}
|
||||
stoppedHost.current = null
|
||||
onHostReturned?.()
|
||||
for (const transport of paneTransports.current.values()) {
|
||||
// The card's rule: a pane with no server yet is still the workspace's; one on another never met this stop.
|
||||
const paneEnvironmentId = transport.getRuntimeEnvironmentId
|
||||
? transport.getRuntimeEnvironmentId()
|
||||
: ownerEnvironmentId
|
||||
if (paneEnvironmentId == null || paneEnvironmentId === ownerEnvironmentId) {
|
||||
transport.retryRecovery?.()
|
||||
// Why both: a refused connect left the pane idle, with no retry armed to resume.
|
||||
if (!transport.retryRecovery?.()) {
|
||||
transport.reopenAfterHostReturned?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [status, ownerEnvironmentId, paneTransports])
|
||||
}, [status, ownerEnvironmentId, paneTransports, onHostReturned])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWslHostStoppedErrorLine,
|
||||
withoutWslHostStoppedLines,
|
||||
wslHostStoppedMessage
|
||||
} from './wsl-host-stopped-message'
|
||||
|
||||
const IPC_WRAPPER = "Error invoking remote method 'runtimeEnvironments:call': Error: "
|
||||
|
||||
describe('the stopped WSL host refusal', () => {
|
||||
it('matches the line its own message produces, with or without the IPC wrapper', () => {
|
||||
const line = wslHostStoppedMessage('Ubuntu')
|
||||
expect(isWslHostStoppedErrorLine(line, 'Ubuntu')).toBe(true)
|
||||
expect(isWslHostStoppedErrorLine(`${IPC_WRAPPER}${line}`, 'Ubuntu')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['', IPC_WRAPPER])(
|
||||
'keeps an unrelated line of a mixed error (wrapper: %j)',
|
||||
(wrapper) => {
|
||||
const error = `${wrapper}${wslHostStoppedMessage('Ubuntu')}\nPaste failed.`
|
||||
expect(withoutWslHostStoppedLines(error, 'Ubuntu')).toBe('Paste failed.')
|
||||
}
|
||||
)
|
||||
|
||||
it('clears an error that was only the refusal', () => {
|
||||
expect(withoutWslHostStoppedLines(wslHostStoppedMessage('Ubuntu'), 'Ubuntu')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps another, still-stopped distro’s refusal', () => {
|
||||
const other = wslHostStoppedMessage('Debian')
|
||||
expect(isWslHostStoppedErrorLine(other, 'Ubuntu')).toBe(false)
|
||||
expect(withoutWslHostStoppedLines(other, 'Ubuntu')).toBe(other)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// The refusal a runtime call meets when its WSL host's distro was stopped; the renderer clears it once the host returns.
|
||||
export function wslHostStoppedMessage(distro: string): string {
|
||||
return `${distro} is stopped. To start it, click Start or open a new terminal in one of its projects.`
|
||||
}
|
||||
|
||||
/** True for an error line carrying this distro's refusal, with or without an IPC wrapper around it. */
|
||||
export function isWslHostStoppedErrorLine(line: string, distro: string): boolean {
|
||||
return line.includes(wslHostStoppedMessage(distro))
|
||||
}
|
||||
|
||||
/** The error without the distro's refusal lines; null when nothing else was on it. */
|
||||
export function withoutWslHostStoppedLines(error: string, distro: string): string | null {
|
||||
const kept = error
|
||||
.split('\n')
|
||||
.filter((line) => !isWslHostStoppedErrorLine(line, distro))
|
||||
.join('\n')
|
||||
return kept.length > 0 ? kept : null
|
||||
}
|
||||
Reference in New Issue
Block a user