Files
orca/src/shared/pty-consumer-session-contract.ts
Jinwoo Hong eea0bb64db fix(ssh): make PTY owner admission explicit and non-destructive (#12673)
An owner-capable `pty.openClient` had two failure modes that presented as something else.

If the relay still held an owner record but the request carried no matching resume proof, admission fell through to a SUBSCRIBER grant — a success-shaped response the client cannot use, which it then rejected as "did not grant an authenticated PTY session owner". And if the relay had forgotten the record the client named, admission threw a stale-recovery error, which the client answered by deleting its own recovery row — `clientInstanceId` included — and reopening. Two round trips, and the identity that lets it resume that target at all went with the deletion.

Now every owner grant carries a required `resumed` flag, a forgotten record mints a fresh claim in one round trip, a held claim returns one of three coded refusals, duplicate opens on one connection are rejected even when identical, and an attached-holder refusal becomes a typed error routed through the terminal-relay-error callback instead of feeding redeploy backoff a link that is working fine.

Independent review caught two regressions in the first attempt, both now fixed and both with tests that fail without them:

**A backpressure teardown could take a live owner's session.** The safety argument was that a record only becomes `disconnected` from an observed peer close — but two of the six paths there are capacity paths, where the relay destroys the client's socket itself because its lane queue filled. That is the signature of a client that is ALIVE but not draining fast enough. Demonstrated: the real owner is torn down for backpressure, a rival is granted ownership 270ms into a nominal 30s grace, and the owner's later reconnect with a valid resume proof is refused permanently, backoff cleared, no retry. Closes now carry a cause (`peer-closed` | `local`, defaulting to `local`, which only ever widens a grace), and the floor applies only to closes the transport actually observed on the peer's side. Capacity teardowns, decode faults and sink failures keep the default.

**A client's own zombie connection blocked it permanently.** Only `SshRelaySession` ever requests owner, and every endpoint-credential client shares one principal — so in a normal single-app deployment an `active` incumbent refusing you is almost always your own half-open connection the relay never saw close. That was refused as terminal, where main recovered on bounded backoff once keepalive noticed. The refusal already held both client identities; a match is now a distinct transient refusal that falls through to relay-lost backoff, restoring that recovery. A genuinely different client is still blocked.

Also: each retry deadline now starts when its own phase begins, instead of both being computed at entry where a slow first phase could leave the second with zero attempts.

Fixes STA-3365.
2026-08-05 02:56:07 -07:00

92 lines
3.6 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
// Why two codes, not one message: the dispatcher transports only code and message, and the two
// holders need opposite client behavior — an attached incumbent blocks, a disconnected one is transient.
export const PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR = -32044
export const PTY_CONSUMER_OWNER_HELD_DISCONNECTED_ERROR = -32045
// Why a third code: only `SshRelaySession` requests session-owner and every endpoint-credential socket
// shares one principal, so an attached incumbent carrying the requester's own clientInstanceId is that
// client's own half-open connection the relay never saw close — transient, not another client's claim.
export const PTY_CONSUMER_OWNER_HELD_SELF_ERROR = -32046
// Why: a disconnected incumbent keeps at most this much of its remaining grace once a different
// owner-capable client asks, so admission converges inside one bounded retry instead of the full grace.
export const PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS = 250
// Why the grace floor needs this: shortening a grace is only safe against an owner the relay has
// evidence is gone, and that evidence exists only where the transport ended on the peer's side. A
// teardown the relay itself initiated — backpressure, a decode fault — proves nothing about liveness,
// so 'local' is the default and never shortens anything.
export type PtyConsumerCloseCause = 'peer-closed' | 'local'
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
// Why: always present on a 'session-owner' grant, absent on a subscriber grant. `false` means the
// relay minted a fresh claim, so the client's checkpoints for the previous claim no longer apply.
resumed?: boolean
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
}