Files
orca/src/main/ipc/ssh/ssh-shutdown-drain.ts
T
NeilandClaude 56143a9a5e feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked (#31716)
* feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked

A refused or unserved host fails the whole connect, so the user had no way to
reach their work there. The blocked-move banner and the SSH host row now offer
an unmanaged, unpersisted ssh login in a non-modal window. Main builds the argv
from the saved target (no -T, BatchMode or Orca multiplexing; the config alias
stays bare so ssh_config applies) and spawns ssh with an argv array through the
local PTY spawn primitive.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): claim a host before awaiting, wait for ssh on quit, and share the preview terminal's keys

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): give the plain SSH terminal Orca's Unicode widths, kitty flags and spawn-helper repair

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): one standalone terminal for the agent preview and plain SSH

Extracts the preview's xterm setup (Unicode widths, links, IME bridge, kitty
flags, copy/paste and shortcut policy, user-input routing) into
openStandaloneTerminal, so the plain SSH terminal reuses it instead of a second
copy. Plain SSH output is now acked once rendered, and main pauses ssh while too
much is unrendered.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): plain SSH answers terminal queries, keeps early exits, and ends a paused pty on Windows

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): plain SSH applies theme changes live and never refits while minimized

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): a native call on a just-exited plain SSH pty never crashes main

Co-Authored-By: Claude <noreply@anthropic.com>

* test(ssh): give the plain SSH test's ResizeObserver stub unobserve, which Tooltip needs

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-21 19:35:32 -07:00

145 lines
5.2 KiB
TypeScript

import {
connectInFlight,
invalidateConnectAttempt,
testConnectionProbes
} from './ssh-connect-attempt-registry'
import { connectionManager, portForwardManager } from './ssh-ipc-context'
import { shutdownOrcadManagedTunnels } from '../../managed-server/lifecycle/orcad-managed-tunnel'
import { closeAllPlainSshTerminals } from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions'
// Why one budget for the whole sequence rather than one per phase: an invalidated connect only
// observes its cancellation at the next checkpoint, and one blocked in the transport handshake can
// sit there for the whole SSH timeout. A per-phase timeout lets any single phase consume the global
// quit deadline; a shared absolute deadline cannot.
export const SSH_SHUTDOWN_BUDGET_MS = 6_000
export type SshShutdownPhase = 'drain' | 'in-flight-join' | 'final-drain'
export type SshShutdownUnfinished = { targetId: string; phase: SshShutdownPhase }
export type SshShutdownResult = {
unfinished: readonly SshShutdownUnfinished[]
errors: readonly unknown[]
}
type SshShutdownTask = { targetId: string; promise: Promise<unknown> }
let sshShutdownDrain: Promise<SshShutdownResult> | null = null
export function resetSshShutdownDrain(): void {
sshShutdownDrain = null
}
async function settleTasksWithinMs(
tasks: readonly SshShutdownTask[],
timeoutMs: number
): Promise<{ timedOut: SshShutdownTask[]; errors: unknown[] }> {
const pending = new Set(tasks)
const errors: unknown[] = []
if (tasks.length === 0) {
return { timedOut: [], errors }
}
const tracked = tasks.map(async (task) => {
try {
await task.promise
} catch (error) {
errors.push(error)
}
pending.delete(task)
})
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
Promise.all(tracked),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
return { timedOut: [...pending], errors }
}
function sshShutdownTasks(): SshShutdownTask[] {
return [
{
targetId: '*transports',
promise: connectionManager?.disconnectAll() ?? Promise.resolve()
},
{
// Why: a system-ssh forward is a separate `ssh -L` child that survives Orca's exit and keeps
// its local port, so the next launch cannot bind a managed tunnel's fixed port.
targetId: '*forwards',
promise: Promise.all([portForwardManager?.closeAll(), shutdownOrcadManagedTunnels()])
},
{
// Why: nothing restores a plain SSH terminal, so none may outlive Orca.
targetId: '*plain-ssh',
promise: closeAllPlainSshTerminals()
}
]
}
async function drainSshShutdown(inFlight: readonly SshShutdownTask[]): Promise<SshShutdownResult> {
const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS
const unfinished: SshShutdownUnfinished[] = []
const errors: unknown[] = []
const runPhase = async (
phase: SshShutdownPhase,
tasks: readonly SshShutdownTask[]
): Promise<boolean> => {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
unfinished.push(...tasks.map((task) => ({ targetId: task.targetId, phase })))
return false
}
const settled = await settleTasksWithinMs(tasks, remainingMs)
errors.push(...settled.errors)
unfinished.push(...settled.timedOut.map((task) => ({ targetId: task.targetId, phase })))
return settled.timedOut.length === 0
}
await runPhase('drain', sshShutdownTasks())
// Why a second drain after the join: a connect can open a transport before it reaches the
// cancellation checkpoint, so the first drain can miss it.
if (await runPhase('in-flight-join', inFlight)) {
await runPhase('final-drain', sshShutdownTasks())
}
if (errors.length > 0 || unfinished.length > 0) {
// Why one aggregate line: per-target logging on the quit path competes with the final flush for
// the little time that remains.
console.warn(
`[ssh] Shutdown drain finished with ${errors.length} error(s); unfinished: ${
unfinished.map((entry) => `${entry.targetId}/${entry.phase}`).join(', ') || 'none'
}`
)
}
return { unfinished, errors }
}
// Why one entry point that returns rather than awaits: every in-memory transition the final store
// flush must snapshot happens synchronously here, before this returns, so the caller can start that
// flush with no await in between. Idempotent — a later call joins the same drain and repeats no
// state transition.
//
// Why no fence latch here: the committed quit path sets it before calling this, so it is already on
// for the snapshot below. Called without that gate (tests), this degrades to a plain drain.
export function beginSshShutdown(): Promise<SshShutdownResult> {
if (sshShutdownDrain) {
return sshShutdownDrain
}
const inFlight: SshShutdownTask[] = [
...[...connectInFlight.entries()].map(([targetId, attempt]) => ({
targetId,
promise: attempt.promise
})),
...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise }))
]
for (const targetId of Array.from(connectInFlight.keys())) {
invalidateConnectAttempt(targetId)
}
sshShutdownDrain = drainSshShutdown(inFlight)
return sshShutdownDrain
}