Files
orca/src/shared/pty-consumer-session-contract.ts
T
Jinjing 00867f06e2 fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown

SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.

* fix(ssh): handle owner displacement and graceful shutdown

Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.

* fix(ssh): fix flaky test sync and add error code safety check

Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.

* fix(ssh): fence reset transport opens during shutdown

* fix(ssh): keep recovery leases stable across reconnects

* fix(ssh): close transports owned by cancelled connect attempts

When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.

* fix(ssh): fence old owner proofs and close superseded transports

When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.

This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.

* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling

When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
2026-08-04 01:37:25 -07:00

72 lines
2.0 KiB
TypeScript

export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1
export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000
export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041
// Why: recovery is blocked only while the incumbent owner's grant publication is still settling — a
// window bounded by one response write, so the client may retry within a short budget.
export const PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR = -32042
export const PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR = -32043
export type PtyConsumerRole = 'session-owner' | 'subscriber'
export type PtyConsumerSessionHello = {
clientInstanceId: string
requestedRole: PtyConsumerRole
resume?: {
ownerGeneration: number
ownerLease: string
}
capabilities?: {
outputFlowControl?: {
versions: number[]
requestedWindowSu: number
}
}
}
export type PtyConsumerSessionGrant = {
protocolVersion: typeof PTY_CONSUMER_SESSION_PROTOCOL_VERSION
serverBuildId: string
clientGeneration: number
role: PtyConsumerRole
ownerGeneration?: number
ownerLease?: string
capabilities?: {
outputFlowControl?: {
version: 1
windowSu: number
}
}
}
export type PtyConsumerAuthentication = {
connectionId: string
principal: string
authenticated: boolean
allowSessionOwner: boolean
}
export type PtyConsumerDisplacedOwner = {
connectionId: string
grant: Readonly<PtyConsumerSessionGrant>
}
export type PtyConsumerSessionAdmission = {
grant: Readonly<PtyConsumerSessionGrant>
// Why: set when this admission takes over a still-attached owner. The transport layer owns closing
// that connection and releasing its deliveries — do it only once the new grant has been published.
displacedOwner?: Readonly<PtyConsumerDisplacedOwner>
commitPublication: () => void
rollbackPublication: () => void
}
export type PtyConsumerSessionOptions = {
serverBuildId: string
outputFlowControl?: {
versions: readonly number[]
maxWindowSu: number
}
ownerGraceMs?: number
now?: () => number
createLease?: () => string
}