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:
Neil
2026-10-31 21:38:36 -07:00
committed by GitHub
parent 6aa71261b9
commit ff0b418e31
11 changed files with 251 additions and 11 deletions
+1 -3
View File
@@ -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. */
@@ -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)
})
})
+18
View File
@@ -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
}