mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): a new SSH terminal opened before sign-in waits and starts after sign-in (#33072)
* fix(terminal): a new SSH terminal opened before sign-in waits and starts after sign-in A terminal created while its managed SSH host is still waiting for a typed password or passphrase got main's sign-in refusal from terminal.create and showed it as a fatal error. Signing in later never started it. Route that refusal into the same parked retry #32275 gave restored panes, so the post-sign-in control-ready revival replays the create. The pane shows a 'Waiting for SSH sign-in' banner with a Sign in action instead of an error. * fix(terminal): clear the sign-in wait as soon as sign-in finishes Publish the sign-in reason through one setter so clearing it always reaches the banner, and clear it when the control socket proves sign-in finished. The Sign in button retries only after a successful connect, so a rejected password cannot re-dial into a second prompt. Share one banner layout. * fix(terminal): keep a sign-in-waiting spawn pending until the replayed create lands The pane's spawn finalization (launch config registration, startup delivery) ran against an undefined result while the create waited for SSH sign-in, then never saw the terminal the post-sign-in replay made. Keep the original connect pending and settle it with the replay's result, or with nothing when the pane is closed, re-attached or a fresh connect supersedes it. * fix(terminal): a stale replay settles only its own sign-in wait * fix(terminal): settle a sign-in-waiting spawn at the recovery state, not per replay Codex and Opus found recoveries that finish outside a create replay (the stream resubscribe after the create landed, an ownership refresh) and left the original connect pending. Settle it at the two points every recovery passes: healthy (with the bound terminal) and idle or disposed (with nothing). * fix(i18n): clearer Spanish sign-in wait copy
This commit is contained in:
@@ -189,6 +189,7 @@ export function TerminalPaneRecoveryPortals({
|
||||
if (!recoveryState || recoveryState.reason === 'ownership') {
|
||||
return null
|
||||
}
|
||||
const awaitingSignIn = recoveryState.reason === 'ssh-sign-in'
|
||||
return createPortal(
|
||||
<DelayedRemoteRuntimeReconnectBanner
|
||||
// Why per pane, not per epoch: a Reconnect click opens a new epoch and must not hide the banner again.
|
||||
@@ -207,10 +208,27 @@ export function TerminalPaneRecoveryPortals({
|
||||
.finally(retry)
|
||||
return
|
||||
}
|
||||
const signInEnvironmentId = awaitingSignIn
|
||||
? paneTransportsRef.current.get(pane.id)?.getRuntimeEnvironmentId?.()
|
||||
: null
|
||||
if (signInEnvironmentId) {
|
||||
// Why no retry on failure: a rejected password must not re-dial into a second prompt;
|
||||
// the control-ready revival starts the pane once sign-in succeeds.
|
||||
void window.api.runtimeEnvironments
|
||||
.connect({ selector: signInEnvironmentId })
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
retry()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
retry()
|
||||
}}
|
||||
wslHostUnavailable={wslHostUnavailable}
|
||||
pairingRefused={pairingRefusedEnvironmentId !== null}
|
||||
awaitingSignIn={awaitingSignIn}
|
||||
/>,
|
||||
pane.container,
|
||||
`remote-runtime-reconnect-${pane.id}`
|
||||
|
||||
+23
@@ -57,6 +57,29 @@ describe('TerminalRemoteRuntimeReconnectBanner', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSH sign-in wait', () => {
|
||||
it.each(['backoff', 'disconnected'] as const)(
|
||||
'shows the sign-in wait at once during %s, never the runtime-lost copy',
|
||||
async (phase) => {
|
||||
const onReconnect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<DelayedRemoteRuntimeReconnectBanner
|
||||
epoch={1}
|
||||
phase={phase}
|
||||
onReconnect={onReconnect}
|
||||
awaitingSignIn
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Waiting for SSH sign-in')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Remote runtime disconnected|Reconnecting/)).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(onReconnect).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('DelayedRemoteRuntimeReconnectBanner', () => {
|
||||
it('keeps a brief resubscribe from flashing the retrying banner', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
+73
-41
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, ServerOff } from 'lucide-react'
|
||||
import { KeyRound, Loader2, ServerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PtyTransportRecoveryState } from './pty-transport-types'
|
||||
@@ -38,7 +38,8 @@ export function TerminalRemoteRuntimeReconnectBanner({
|
||||
phase,
|
||||
onReconnect,
|
||||
wslHostUnavailable = null,
|
||||
pairingRefused = false
|
||||
pairingRefused = false,
|
||||
awaitingSignIn = false
|
||||
}: {
|
||||
phase: VisibleRecoveryPhase
|
||||
onReconnect: () => void
|
||||
@@ -46,16 +47,77 @@ export function TerminalRemoteRuntimeReconnectBanner({
|
||||
wslHostUnavailable?: WslHostUnavailableReason | null
|
||||
/** The pane's managed server refuses this desktop and pairing again over SSH failed. */
|
||||
pairingRefused?: boolean
|
||||
/** Main refused because the SSH host is waiting for the user to sign in; it resumes after. */
|
||||
awaitingSignIn?: boolean
|
||||
}): React.JSX.Element {
|
||||
if (wslHostUnavailable) {
|
||||
return <WslHostUnavailableBanner reason={wslHostUnavailable} />
|
||||
}
|
||||
const retrying = phase !== 'disconnected' && !pairingRefused
|
||||
const signingIn = awaitingSignIn && !pairingRefused
|
||||
const retrying = phase !== 'disconnected' && !pairingRefused && !signingIn
|
||||
const reconnectLabel = translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.reconnectButton',
|
||||
'Reconnect'
|
||||
)
|
||||
const copy = pairingRefused
|
||||
? {
|
||||
bannerState: 'pairing-refused',
|
||||
title: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedTitle',
|
||||
'This host no longer accepts this computer'
|
||||
),
|
||||
body: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedBody',
|
||||
"Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again."
|
||||
),
|
||||
action: reconnectLabel
|
||||
}
|
||||
: signingIn
|
||||
? {
|
||||
bannerState: 'ssh-sign-in',
|
||||
title: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInTitle',
|
||||
'Waiting for SSH sign-in'
|
||||
),
|
||||
body: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInBody',
|
||||
'This terminal continues by itself once you sign in to the host.'
|
||||
),
|
||||
action: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.signInButton',
|
||||
'Sign in'
|
||||
)
|
||||
}
|
||||
: retrying
|
||||
? {
|
||||
bannerState: phase,
|
||||
title: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle',
|
||||
'Reconnecting to remote runtime'
|
||||
),
|
||||
body: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody',
|
||||
'Orca is retrying automatically. This terminal will resume if the connection returns.'
|
||||
),
|
||||
action: null
|
||||
}
|
||||
: {
|
||||
bannerState: phase,
|
||||
title: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle',
|
||||
'Remote runtime disconnected'
|
||||
),
|
||||
body: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody',
|
||||
'Automatic retries stopped. Reconnect to resume this terminal session.'
|
||||
),
|
||||
action: reconnectLabel
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-3 bottom-3 z-30 flex justify-center"
|
||||
data-terminal-remote-runtime-reconnect-banner={pairingRefused ? 'pairing-refused' : phase}
|
||||
data-terminal-remote-runtime-reconnect-banner={copy.bannerState}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card/95 px-3 py-3 text-card-foreground shadow-xs backdrop-blur-[1px]"
|
||||
@@ -65,50 +127,19 @@ export function TerminalRemoteRuntimeReconnectBanner({
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground">
|
||||
{retrying ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : signingIn ? (
|
||||
<KeyRound className="size-4" />
|
||||
) : (
|
||||
<ServerOff className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">
|
||||
{pairingRefused
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedTitle',
|
||||
'This host no longer accepts this computer'
|
||||
)
|
||||
: retrying
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle',
|
||||
'Reconnecting to remote runtime'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle',
|
||||
'Remote runtime disconnected'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{pairingRefused
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.pairingRefusedBody',
|
||||
"Orca couldn't pair with it again over SSH. Nothing on the host was stopped. Reconnect to try again."
|
||||
)
|
||||
: retrying
|
||||
? translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody',
|
||||
'Orca is retrying automatically. This terminal will resume if the connection returns.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody',
|
||||
'Automatic retries stopped. Reconnect to resume this terminal session.'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{copy.title}</div>
|
||||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">{copy.body}</div>
|
||||
</div>
|
||||
{!retrying ? (
|
||||
{copy.action ? (
|
||||
<Button size="sm" onClick={onReconnect}>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.reconnectButton',
|
||||
'Reconnect'
|
||||
)}
|
||||
{copy.action}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -125,6 +156,7 @@ export function DelayedRemoteRuntimeReconnectBanner({
|
||||
}): React.JSX.Element | null {
|
||||
const revealed = useRemoteRuntimeRecoveryBannerRevealed(
|
||||
props.phase === 'disconnected' ||
|
||||
Boolean(props.awaitingSignIn) ||
|
||||
Boolean(props.wslHostUnavailable) ||
|
||||
Boolean(props.pairingRefused)
|
||||
)
|
||||
|
||||
@@ -154,7 +154,7 @@ type PtyCallbacks = {
|
||||
}
|
||||
|
||||
export type PtyTransportRecoveryState = {
|
||||
reason?: 'ownership'
|
||||
reason?: 'ownership' | 'ssh-sign-in'
|
||||
phase:
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
|
||||
@@ -342,6 +342,22 @@ export function createRemoteRuntimePtyTransport(
|
||||
const disconnectedInputGrace = createRemoteRuntimeDisconnectedInputGrace()
|
||||
let previousRecoveryPhase: RemoteRuntimePtyRecoveryPhase = 'idle'
|
||||
let unregisterControlReadyReviver: (() => void) | null = null
|
||||
// Why: the pane's retry is waiting on the user's SSH sign-in, not on a lost link.
|
||||
let awaitingSshSignIn = false
|
||||
// Why: the caller's spawn finalization (launch config, startup delivery) must see the terminal
|
||||
// recovery binds after sign-in, so its connect stays pending until recovery settles either way.
|
||||
let signInConnectWait: PromiseWithResolvers<PtyConnectResult | undefined> | null = null
|
||||
function settleSignInConnectWait(result?: PtyConnectResult): void {
|
||||
const wait = signInConnectWait
|
||||
signInConnectWait = null
|
||||
wait?.resolve(result)
|
||||
}
|
||||
function setAwaitingSshSignIn(next: boolean): void {
|
||||
if (awaitingSshSignIn !== next) {
|
||||
awaitingSshSignIn = next
|
||||
emitRecoveryState()
|
||||
}
|
||||
}
|
||||
// Why registered only while recovering: detach and destroy go idle, so a discarded transport is never retained.
|
||||
function syncControlReadyReviver(): void {
|
||||
const wantsRevival = recovery.isActive || recovery.currentPhase === 'disconnected'
|
||||
@@ -349,7 +365,14 @@ export function createRemoteRuntimePtyTransport(
|
||||
// Why: a backoff advances and a latched pane replays its attach, exactly as online/resume/Reconnect would.
|
||||
unregisterControlReadyReviver = registerRemoteRuntimePtyControlReadyReviver(
|
||||
runtimeEnvironmentId,
|
||||
() => !destroyed && (recovery.retryNow() || (transport.retryRecovery?.() ?? false))
|
||||
() => {
|
||||
if (destroyed) {
|
||||
return false
|
||||
}
|
||||
// Why: a ready control socket proves sign-in finished, whatever the replay meets next.
|
||||
setAwaitingSshSignIn(false)
|
||||
return recovery.retryNow() || (transport.retryRecovery?.() ?? false)
|
||||
}
|
||||
)
|
||||
} else if (!wantsRevival && unregisterControlReadyReviver) {
|
||||
unregisterControlReadyReviver()
|
||||
@@ -384,6 +407,11 @@ export function createRemoteRuntimePtyTransport(
|
||||
subscriptionGeneration += 1
|
||||
closeMultiplexedStream()
|
||||
}
|
||||
if (recovery.currentPhase === 'idle' || recovery.currentPhase === 'disposed') {
|
||||
awaitingSshSignIn = false
|
||||
// Why: a healthy recovery settled it first with the bound terminal; anything else gave up.
|
||||
settleSignInConnectWait()
|
||||
}
|
||||
if (recovery.currentPhase === 'idle') {
|
||||
autoRecoveryWindowSpent = false
|
||||
// Why before the reset: input that outlived the grace must not release into the recovered pane.
|
||||
@@ -615,13 +643,20 @@ export function createRemoteRuntimePtyTransport(
|
||||
: connected && attachmentReady
|
||||
? 'connected'
|
||||
: 'offline'
|
||||
const ownership =
|
||||
(recovery.isActive && recovery.hasOwnerRefresh) || surfacedErrorMessages.has(ownerChanged)
|
||||
const awaitingSignIn =
|
||||
awaitingSshSignIn &&
|
||||
(phase === 'recovering' || phase === 'backoff' || phase === 'disconnected')
|
||||
return {
|
||||
phase,
|
||||
epoch: recovery.currentEpoch,
|
||||
attempt: recovery.attemptCount,
|
||||
...((recovery.isActive && recovery.hasOwnerRefresh) || surfacedErrorMessages.has(ownerChanged)
|
||||
...(ownership
|
||||
? { reason: 'ownership' as const }
|
||||
: {})
|
||||
: awaitingSignIn
|
||||
? { reason: 'ssh-sign-in' as const }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +686,20 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
|
||||
function markRecoveryHealthy(result?: PtyConnectResult): void {
|
||||
if (signInConnectWait) {
|
||||
settleSignInConnectWait(
|
||||
remotePtyId
|
||||
? {
|
||||
id: remotePtyId,
|
||||
replay: '',
|
||||
...(spawnedHandle === handle ? {} : { isReattach: true }),
|
||||
...(authoritativePtyIncarnationId
|
||||
? { incarnationId: authoritativePtyIncarnationId }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
const recoveredErrors = [...surfacedErrorMessages]
|
||||
surfacedErrorMessages.clear()
|
||||
recovery.markHealthy(result)
|
||||
@@ -1043,7 +1092,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
return true
|
||||
}
|
||||
if (lastConnectOptions) {
|
||||
void connectHoldingRestoredInput(lastConnectOptions)
|
||||
void connectHoldingRestoredInput(lastConnectOptions, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1329,6 +1378,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!isRecoverableRemoteRuntimeConnectionError(statusClientError)) {
|
||||
throw statusError
|
||||
}
|
||||
setAwaitingSshSignIn(false)
|
||||
const startsRecovery = recoveryDeadlineAt === null
|
||||
recoveryDeadlineAt ??= Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS
|
||||
if (startsRecovery && !recovery.isActive) {
|
||||
@@ -1373,6 +1423,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!isRecoverableRemoteRuntimeConnectionError(clientError)) {
|
||||
throw error
|
||||
}
|
||||
setAwaitingSshSignIn(false)
|
||||
if (kind === 'agent-session') {
|
||||
agentSessionRequiresHostAuthorityReplay = true
|
||||
} else {
|
||||
@@ -3236,6 +3287,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
// Why: no handle is bound yet, so the resubscribe path cannot own this retry; replay attach
|
||||
// instead of latching in 'connecting' with nothing armed (#18495).
|
||||
connecting = false
|
||||
setAwaitingSshSignIn(isSshSignInPendingTerminalError(runtimeTerminalErrorMessage(error)))
|
||||
scheduleConnectRetryAfterRecoverableFailure()
|
||||
if (handle && isRemoteTerminalStaleMessage(runtimeTerminalErrorMessage(error))) {
|
||||
retireIfHostRecordsExit(handle, remotePtyId ?? options.existingPtyId ?? null)
|
||||
@@ -3295,17 +3347,34 @@ export function createRemoteRuntimePtyTransport(
|
||||
|
||||
// Why: typing on a restored screen reaches its original terminal only, never a fresh create.
|
||||
async function connectHoldingRestoredInput(
|
||||
options: Parameters<PtyTransport['connect']>[0]
|
||||
options: Parameters<PtyTransport['connect']>[0],
|
||||
replay = false
|
||||
): Promise<PtyConnectResult | undefined> {
|
||||
if (!replay) {
|
||||
settleSignInConnectWait()
|
||||
}
|
||||
const expected = recoveryInputHold.expect(restoredInputEndpoint(options.sessionId))
|
||||
let result: PtyConnectResult | undefined
|
||||
try {
|
||||
return await connectPane(options)
|
||||
result = await connectPane(options)
|
||||
} finally {
|
||||
recoveryInputHold.settleExpectation(expected, handle !== null && !destroyed)
|
||||
if (handle && recoveryInputHold.isHolding()) {
|
||||
releaseHeldInput()
|
||||
}
|
||||
}
|
||||
const retryOwned = !destroyed && (recovery.isActive || recovery.currentPhase === 'disconnected')
|
||||
if (
|
||||
!replay &&
|
||||
result === undefined &&
|
||||
retryOwned &&
|
||||
awaitingSshSignIn &&
|
||||
lastConnectOptions === options
|
||||
) {
|
||||
signInConnectWait ??= Promise.withResolvers()
|
||||
return signInConnectWait.promise
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function connectPane(
|
||||
@@ -3568,7 +3637,13 @@ export function createRemoteRuntimePtyTransport(
|
||||
} else if (isRemoteTerminalGoneMessage(message)) {
|
||||
recovery.cancel()
|
||||
handleRemoteTerminalError(error)
|
||||
} else if (isSshSignInPendingTerminalError(message)) {
|
||||
// Why: main refused before dispatch, so nothing was created; the control-ready revival
|
||||
// replays this create once sign-in finishes (#31942).
|
||||
setAwaitingSshSignIn(true)
|
||||
scheduleConnectRetryAfterRecoverableFailure()
|
||||
} else if (isRecoverablePaneBindingError(error)) {
|
||||
setAwaitingSshSignIn(false)
|
||||
scheduleConnectRetryAfterRecoverableFailure()
|
||||
} else {
|
||||
recovery.cancel()
|
||||
@@ -3587,11 +3662,13 @@ export function createRemoteRuntimePtyTransport(
|
||||
connect: connectHoldingRestoredInput,
|
||||
|
||||
attach(options) {
|
||||
settleSignInConnectWait()
|
||||
recoveryInputHold.expect(null)
|
||||
attachPane(options, false)
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
settleSignInConnectWait()
|
||||
lifecycleEpoch += 1
|
||||
attachGeneration += 1
|
||||
discardPendingInput()
|
||||
@@ -3630,6 +3707,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
// Why first: the successor transport owns the PTY after detach, and the batcher flushes
|
||||
// below can throw past the census drop — a stranded gauge outlives the transport.
|
||||
outputProcessor.disposePendingSideEffectGauge()
|
||||
settleSignInConnectWait()
|
||||
lifecycleEpoch += 1
|
||||
attachGeneration += 1
|
||||
discardPendingInput()
|
||||
@@ -3793,7 +3871,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
recovery.currentPhase === 'disconnected'
|
||||
) {
|
||||
recovery.begin()
|
||||
void connectHoldingRestoredInput(lastConnectOptions)
|
||||
void connectHoldingRestoredInput(lastConnectOptions, true)
|
||||
return true
|
||||
}
|
||||
// Why: online/resume fires a parked retry; the button must not be weaker than an event (#12684).
|
||||
|
||||
+201
-2
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
import type { PtyTransport, PtyTransportRecoveryState } from './pty-transport-types'
|
||||
import {
|
||||
createRemoteRuntimeTransportMocks,
|
||||
readyHostSessionInventoryResponse,
|
||||
@@ -21,6 +21,7 @@ let transport: PtyTransport | undefined
|
||||
|
||||
const {
|
||||
runtimeCall,
|
||||
runtimeSubscribe,
|
||||
latestSubscribePayload,
|
||||
subscribedTerminalHandles,
|
||||
emitSnapshot,
|
||||
@@ -120,7 +121,7 @@ describe.each([
|
||||
)
|
||||
)('restores original-terminal I/O for $name $timing', async (failure) => {
|
||||
const { pane, onData, onError, signIn } = await mountAwaitingPane(tabId, failure)
|
||||
expect(pane.getRecoveryState?.().phase).toBe('backoff')
|
||||
expect(pane.getRecoveryState?.()).toMatchObject({ phase: 'backoff', reason: 'ssh-sign-in' })
|
||||
expect(subscribedTerminalHandles()).toEqual([])
|
||||
|
||||
if (failure.late) {
|
||||
@@ -183,3 +184,201 @@ describe.each([
|
||||
expect(runtimeCall).toHaveBeenCalledTimes(callsAfterTeardown)
|
||||
})
|
||||
})
|
||||
|
||||
async function mountNewPaneAwaitingSignIn(failure: { message: string }, afterSignIn?: () => void) {
|
||||
let signedIn = false
|
||||
const healthyCall = runtimeCall.getMockImplementation()
|
||||
runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => {
|
||||
if (!signedIn) {
|
||||
throw new Error(failure.message)
|
||||
}
|
||||
afterSignIn?.()
|
||||
return healthyCall?.(request)
|
||||
})
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { reviveRemoteRuntimePtyRecoveriesForEnvironment } =
|
||||
await import('./remote-runtime-pty-recovery-state')
|
||||
const onError = vi.fn()
|
||||
const onPtySpawn = vi.fn()
|
||||
const publishedStates: PtyTransportRecoveryState[] = []
|
||||
const pane = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-new',
|
||||
leafId: 'pane:1',
|
||||
onPtySpawn
|
||||
})
|
||||
transport = pane
|
||||
let connectSettled = false
|
||||
const connected = Promise.resolve(
|
||||
pane.connect({
|
||||
url: '',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: { onError, onRecoveryStateChange: (state) => publishedStates.push(state) }
|
||||
})
|
||||
).finally(() => {
|
||||
connectSettled = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
return {
|
||||
connected,
|
||||
connectSettled: () => connectSettled,
|
||||
pane,
|
||||
lastPublishedState: () => publishedStates.at(-1),
|
||||
onError,
|
||||
onPtySpawn,
|
||||
createCalls: () =>
|
||||
runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.create'),
|
||||
signIn: () => {
|
||||
signedIn = true
|
||||
return reviveRemoteRuntimePtyRecoveriesForEnvironment('env-1')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSH sign-in for a terminal created while the host waits for sign-in', () => {
|
||||
it.each(
|
||||
pendingErrors.flatMap((failure) =>
|
||||
[false, true].map((late) => ({
|
||||
...failure,
|
||||
late,
|
||||
timing: late ? 'after the recovery deadline' : 'before the recovery deadline'
|
||||
}))
|
||||
)
|
||||
)('waits for sign-in and then starts for $name $timing', async (failure) => {
|
||||
const { pane, onError, onPtySpawn, signIn, connected, connectSettled } =
|
||||
await mountNewPaneAwaitingSignIn(failure)
|
||||
expect(pane.getRecoveryState?.()).toMatchObject({
|
||||
phase: 'backoff',
|
||||
reason: 'ssh-sign-in'
|
||||
})
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(connectSettled()).toBe(false)
|
||||
|
||||
if (failure.late) {
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1)
|
||||
expect(pane.getRecoveryState?.()).toMatchObject({
|
||||
phase: 'disconnected',
|
||||
reason: 'ssh-sign-in'
|
||||
})
|
||||
}
|
||||
|
||||
expect(signIn()).toBe(1)
|
||||
await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1'))
|
||||
const { streamId } = latestSubscribePayload()
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'subscribed', streamId }
|
||||
})
|
||||
emitSnapshot(streamId, 'fresh prompt$ ')
|
||||
|
||||
expect(onPtySpawn).toHaveBeenCalledTimes(1)
|
||||
expect(onPtySpawn).toHaveBeenCalledWith('remote:env-1@@terminal-1')
|
||||
// The original spawn settles with the post-sign-in create, so its finalization still runs.
|
||||
await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' })
|
||||
expect(pane.isConnected()).toBe(true)
|
||||
expect(pane.getRecoveryState?.().reason).toBeUndefined()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts after a sign-in that was cancelled and completed later', async () => {
|
||||
const failure = { message: pendingErrors[0].message }
|
||||
const pending = await mountNewPaneAwaitingSignIn(failure)
|
||||
failure.message = pendingErrors[2].message
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1)
|
||||
expect(pending.pane.getRecoveryState?.()).toMatchObject({
|
||||
phase: 'disconnected',
|
||||
reason: 'ssh-sign-in'
|
||||
})
|
||||
|
||||
expect(pending.signIn()).toBe(1)
|
||||
await vi.waitFor(() => expect(pending.onPtySpawn).toHaveBeenCalledTimes(1))
|
||||
expect(pending.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles the original spawn when the stream, not the create, waited for sign-in', async () => {
|
||||
resolvedPaneHandle = 'terminal-1'
|
||||
let signedIn = false
|
||||
const healthySubscribe = runtimeSubscribe.getMockImplementation()
|
||||
runtimeSubscribe.mockImplementation(async (...args: unknown[]) => {
|
||||
if (!signedIn) {
|
||||
throw new Error(pendingErrors[0].message)
|
||||
}
|
||||
return healthySubscribe?.(...args)
|
||||
})
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { reviveRemoteRuntimePtyRecoveriesForEnvironment } =
|
||||
await import('./remote-runtime-pty-recovery-state')
|
||||
const onPtySpawn = vi.fn()
|
||||
const pane = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-new',
|
||||
leafId: 'pane:1',
|
||||
onPtySpawn
|
||||
})
|
||||
transport = pane
|
||||
const connected = Promise.resolve(pane.connect({ url: '', cols: 80, rows: 24, callbacks: {} }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(pane.getRecoveryState?.().reason).toBe('ssh-sign-in')
|
||||
|
||||
signedIn = true
|
||||
expect(reviveRemoteRuntimePtyRecoveriesForEnvironment('env-1')).toBe(1)
|
||||
await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1'))
|
||||
const { streamId } = latestSubscribePayload()
|
||||
subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
|
||||
emitSnapshot(streamId, 'fresh prompt$ ')
|
||||
|
||||
await expect(connected).resolves.toMatchObject({ id: 'remote:env-1@@terminal-1' })
|
||||
expect(onPtySpawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops showing the sign-in wait once sign-in finishes, even while the create still retries', async () => {
|
||||
const { pane, lastPublishedState, signIn } = await mountNewPaneAwaitingSignIn(
|
||||
pendingErrors[0],
|
||||
() => {
|
||||
throw Object.assign(new Error('Could not connect to the remote Orca runtime.'), {
|
||||
code: 'remote_runtime_unavailable'
|
||||
})
|
||||
}
|
||||
)
|
||||
expect(lastPublishedState()?.reason).toBe('ssh-sign-in')
|
||||
|
||||
signIn()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(pane.getRecoveryState?.().phase).not.toBe('idle')
|
||||
expect(lastPublishedState()?.reason).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['destroy', 'disconnect'] as const)(
|
||||
'spawns nothing after sign-in once the pending terminal is closed (%s)',
|
||||
async (teardown) => {
|
||||
const { pane, onPtySpawn, signIn, createCalls, connected } = await mountNewPaneAwaitingSignIn(
|
||||
pendingErrors[0]
|
||||
)
|
||||
const createsBeforeClose = createCalls().length
|
||||
pane[teardown]?.()
|
||||
await expect(connected).resolves.toBeUndefined()
|
||||
|
||||
expect(signIn()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1)
|
||||
|
||||
expect(createCalls()).toHaveLength(createsBeforeClose)
|
||||
expect(onPtySpawn).not.toHaveBeenCalled()
|
||||
expect(subscribedTerminalHandles()).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('still reports rejected credentials instead of waiting', async () => {
|
||||
const { pane, onError, signIn, createCalls } = await mountNewPaneAwaitingSignIn({
|
||||
message: 'All configured authentication methods failed'
|
||||
})
|
||||
expect(onError).toHaveBeenCalledWith('All configured authentication methods failed')
|
||||
expect(pane.getRecoveryState?.().reason).toBeUndefined()
|
||||
const createsAfterFailure = createCalls().length
|
||||
|
||||
expect(signIn()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 1)
|
||||
expect(createCalls()).toHaveLength(createsAfterFailure)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3829,7 +3829,10 @@
|
||||
"disconnectedTitle": "Remote runtime disconnected",
|
||||
"retryingBody": "Orca is retrying automatically. This terminal will resume if the connection returns.",
|
||||
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
|
||||
"reconnectButton": "Reconnect"
|
||||
"reconnectButton": "Reconnect",
|
||||
"signInTitle": "Waiting for SSH sign-in",
|
||||
"signInBody": "This terminal continues by itself once you sign in to the host.",
|
||||
"signInButton": "Sign in"
|
||||
},
|
||||
"TerminalQuickCommandsSubmenu": {
|
||||
"3ccc7981bb": "Host unavailable",
|
||||
|
||||
@@ -3526,7 +3526,10 @@
|
||||
"disconnectedTitle": "Remote runtime disconnected",
|
||||
"retryingBody": "Orca está reintentando automáticamente. Este terminal se reanudará si vuelve la conexión.",
|
||||
"disconnectedBody": "Se detuvieron los reintentos automáticos. Vuelva a conectarse para reanudar esta sesión de terminal.",
|
||||
"reconnectButton": "Reconnect"
|
||||
"reconnectButton": "Reconnect",
|
||||
"signInTitle": "Esperando el inicio de sesión SSH",
|
||||
"signInBody": "Este terminal continuará por sí solo cuando inicie sesión en el host.",
|
||||
"signInButton": "Iniciar sesión"
|
||||
},
|
||||
"TerminalRestoringPlaceholder": {
|
||||
"restoring": "Restaurando terminal…"
|
||||
|
||||
@@ -3695,7 +3695,10 @@
|
||||
"disconnectedTitle": "Runtime distant déconnecté",
|
||||
"retryingBody": "Orca réessaie automatiquement. Ce terminal reprendra si la connexion revient.",
|
||||
"disconnectedBody": "Les tentatives automatiques sont interrompues. Reconnectez-vous pour reprendre cette session de terminal.",
|
||||
"reconnectButton": "Reconnecter"
|
||||
"reconnectButton": "Reconnecter",
|
||||
"signInTitle": "En attente de la connexion SSH",
|
||||
"signInBody": "Ce terminal reprend tout seul dès que vous vous connectez à l’hôte.",
|
||||
"signInButton": "Se connecter"
|
||||
},
|
||||
"TerminalQuickCommandsSubmenu": {
|
||||
"3ccc7981bb": "Hôte indisponible",
|
||||
|
||||
@@ -3511,7 +3511,10 @@
|
||||
"disconnectedTitle": "リモートランタイムが切断されました",
|
||||
"retryingBody": "Orca は自動的に再試行します。接続が復元されると、このターミナルが再開されます。",
|
||||
"disconnectedBody": "自動再試行が停止しました。このターミナルセッションを再開するには再接続してください。",
|
||||
"reconnectButton": "再接続"
|
||||
"reconnectButton": "再接続",
|
||||
"signInTitle": "SSH のサインインを待っています",
|
||||
"signInBody": "ホストにサインインすると、このターミナルは自動的に続行します。",
|
||||
"signInButton": "サインイン"
|
||||
},
|
||||
"TerminalLinkActionPopover": {
|
||||
"openLink": "リンクを開く",
|
||||
|
||||
@@ -3511,7 +3511,10 @@
|
||||
"disconnectedTitle": "원격 런타임 연결 끊김",
|
||||
"retryingBody": "Orca가 자동으로 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.",
|
||||
"disconnectedBody": "자동 재시도가 중지되었습니다. 이 터미널 세션을 재개하려면 다시 연결하세요.",
|
||||
"reconnectButton": "다시 연결"
|
||||
"reconnectButton": "다시 연결",
|
||||
"signInTitle": "SSH 로그인 대기 중",
|
||||
"signInBody": "호스트에 로그인하면 이 터미널이 자동으로 계속됩니다.",
|
||||
"signInButton": "로그인"
|
||||
},
|
||||
"TerminalLinkActionPopover": {
|
||||
"openLink": "링크 열기",
|
||||
|
||||
@@ -15652,7 +15652,10 @@
|
||||
"disconnectedBody": "As tentativas automáticas foram interrompidas. Reconecte para retomar esta sessão de terminal.",
|
||||
"reconnectButton": "Reconectar",
|
||||
"pairingRefusedTitle": "Este host não aceita mais este computador",
|
||||
"pairingRefusedBody": "O Orca não conseguiu parear com ele novamente via SSH. Nada no host foi interrompido. Reconecte para tentar novamente."
|
||||
"pairingRefusedBody": "O Orca não conseguiu parear com ele novamente via SSH. Nada no host foi interrompido. Reconecte para tentar novamente.",
|
||||
"signInTitle": "Aguardando login SSH",
|
||||
"signInBody": "Este terminal continua sozinho quando você entrar no host.",
|
||||
"signInButton": "Entrar"
|
||||
},
|
||||
"TerminalPane": {
|
||||
"ac112e9036": "Remover título",
|
||||
|
||||
@@ -3511,7 +3511,10 @@
|
||||
"disconnectedTitle": "远程运行时已断开连接",
|
||||
"retryingBody": "Orca 正在自动重试。如果连接恢复,此终端将恢复。",
|
||||
"disconnectedBody": "自动重试已停止。重新连接以恢复此终端会话。",
|
||||
"reconnectButton": "重新连接"
|
||||
"reconnectButton": "重新连接",
|
||||
"signInTitle": "正在等待 SSH 登录",
|
||||
"signInBody": "登录到主机后,此终端会自动继续。",
|
||||
"signInButton": "登录"
|
||||
},
|
||||
"TerminalLinkActionPopover": {
|
||||
"openLink": "打开链接",
|
||||
|
||||
Reference in New Issue
Block a user