Files
orca/src/main/ssh/ssh-channel-multiplexer.ts
T
JinjingandOrcaWin 5f7807497e feat(ssh): bound relay PTY output end to end (#11005)
* docs: design SSH relay PTY backpressure

* fix(ssh): bound relay frame decoding

* fix(relay): bound PTY output publication

* fix(ssh): bound PTY model admission

* fix(ssh): settle closed model admissions

* feat(ssh): negotiate bounded PTY consumer sessions

* fix(ssh): fence exit on renderer settlement

* feat(ssh): track PTY source credit end to end

* fix(ssh): recover bounded PTY output across reconnect

* feat(ssh): complete relay PTY output backpressure

* fix(ssh): close final PTY source credit races

* docs(ssh): record final backpressure validation

* feat(ssh): complete relay PTY source-credit lifecycle

* test(ssh): complete provider notification fixture

* fix(ssh): preserve terminal source credit across rotation

* fix(ssh): fail closed on recovery cancellation

* fix(ssh): prioritize mux control writes after drain

* fix(ssh): retire canceled relay restore deliveries

* fix(ssh): order exit cancellation cleanup

* fix(ssh): gate provisional source activation

* test(ssh): register mux drain-priority coverage

* fix(ssh): type stale owner recovery mismatches

* fix(ssh): close projection replacement races

* fix(relay): contain streaming edge failures

* fix(ssh): secure relay endpoint credentials

* docs(ssh): reconcile final backpressure lifecycle

* fix(ssh): bound main IPC output lifecycle

* fix(ssh): close recovery ownership gaps

* docs(ssh): record exact artifact validation

* fix(ssh): reject reclaimed snapshot replacements

* fix(ssh): fence model admission across reconnect

* fix(ssh): contain migration failure per PTY

* docs(ssh): record final exact-head validation

* test(ssh): align deploy fixtures with credential publication

* feat(ssh): add per-target bounded output setting

* fix(ssh): close source recovery review gaps

* fix(ssh): latch source credit environment override

* feat(ssh): make PTY source credit the default

* docs(ssh): record always-on relay validation

* docs(ssh): bind validation to current main

* test(ssh): grant source credit in IPC fixture

* test(ssh): grant source credit in fake relay

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 17:03:15 -07:00

635 lines
20 KiB
TypeScript

/* eslint-disable max-lines -- Why: the SSH relay protocol state machine keeps
request, notification, keepalive, and cancellation semantics paired. */
import {
FrameDecoder,
MessageType,
encodeJsonRpcFrame,
encodeKeepAliveFrame,
parseJsonRpcMessage,
KEEPALIVE_SEND_MS,
TIMEOUT_MS,
type DecodedFrame,
type JsonRpcMessage,
type JsonRpcRequest,
type JsonRpcResponse,
type JsonRpcNotification
} from './relay-protocol'
import {
SshMultiplexerTransportWriter,
type MultiplexerTransport,
type MultiplexerWriteSettlement,
type MultiplexerWriterLane
} from './ssh-multiplexer-transport-writer'
export type { MultiplexerTransport, MultiplexerWriteSettlement }
type PendingRequest = {
resolve: (result: unknown) => void
reject: (error: Error) => void
beforeResolve?: (result: unknown) => void
timer: ReturnType<typeof setTimeout>
cleanup: () => void
}
export type SshMultiplexerRequestOptions = {
signal?: AbortSignal
timeoutMs?: number
beforeResolve?: (result: unknown) => void
}
export type NotificationHandler = (method: string, params: Record<string, unknown>) => void
export type MethodNotificationHandler = (params: Record<string, unknown>) => void
export type RequestHandler = (params: Record<string, unknown>) => Promise<unknown> | unknown
const REQUEST_TIMEOUT_MS = 30_000
const MAX_ORDINARY_UNACKED_TIMESTAMPS = 4095
const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1
// Why: a tick gap far beyond the interval means the process was paused
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
export class SshChannelMultiplexer {
private decoder: FrameDecoder
private transport: MultiplexerTransport
private writer: SshMultiplexerTransportWriter
private nextRequestId = 1
private nextOutgoingSeq = 1
private highestReceivedSeq = 0
private highestAckedBySelf = 0
private lastReceivedAt = Date.now()
private pendingRequests = new Map<number, PendingRequest>()
private notificationHandlers: NotificationHandler[] = []
private requestHandlers = new Map<string, RequestHandler>()
// Why: per-method dispatch map keeps streaming consumers (fs.streamChunk,
// fs.streamEnd, fs.streamError) from accreting string-match logic in the
// generic notification listener that already serves fs.changed.
private methodNotificationHandlers = new Map<string, Set<MethodNotificationHandler>>()
private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = []
private connectionHealthTimer: ReturnType<typeof setInterval> | null = null
private disposed = false
private decoderReadPaused = false
private writerSaturated = false
// Track the oldest unacked outgoing message timestamp
private unackedTimestamps = new Map<number, number>()
// Why: liveness probes (#7773) resolve on the first frame of any kind —
// a keepalive ack proves the relay round-trip without a full RPC.
private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = []
constructor(transport: MultiplexerTransport) {
this.transport = transport
this.writer = new SshMultiplexerTransportWriter(
transport,
(error) => this.handleProtocolError(error),
(saturated) => this.handleWriterSaturationChange(saturated)
)
this.decoder = new FrameDecoder(
(frame) => this.handleFrame(frame),
(err) => this.handleProtocolError(err),
{
pause: () => this.pauseDecoderReads(),
resume: () => this.resumeDecoderReads()
}
)
transport.onData((data) => {
if (this.disposed) {
return
}
this.lastReceivedAt = Date.now()
this.decoder.feed(data)
})
transport.onClose(() => {
this.dispose('connection_lost')
})
if (this.disposed) {
return
}
this.startConnectionHealthTimer()
}
onNotification(handler: NotificationHandler): () => void {
if (this.disposed) {
return () => {}
}
this.notificationHandlers.push(handler)
return () => {
const idx = this.notificationHandlers.indexOf(handler)
if (idx !== -1) {
this.notificationHandlers.splice(idx, 1)
}
}
}
onNotificationByMethod(method: string, handler: MethodNotificationHandler): () => void {
if (this.disposed) {
return () => {}
}
let set = this.methodNotificationHandlers.get(method)
if (!set) {
set = new Set()
this.methodNotificationHandlers.set(method, set)
}
set.add(handler)
return () => {
const current = this.methodNotificationHandlers.get(method)
if (!current) {
return
}
current.delete(handler)
if (current.size === 0) {
this.methodNotificationHandlers.delete(method)
}
}
}
onRequest(method: string, handler: RequestHandler): () => void {
this.requestHandlers.set(method, handler)
return () => {
if (this.requestHandlers.get(method) === handler) {
this.requestHandlers.delete(method)
}
}
}
// Why: the session needs to know when the relay channel dies so it can
// auto-reconnect. Without this, a relay channel close (e.g. --connect
// bridge exits) leaves the session in 'ready' state with a dead mux
// and no recovery path — the SSH connection stays up so onStateChange
// never fires the reconnect logic.
onDispose(handler: (reason: 'shutdown' | 'connection_lost') => void): () => void {
if (this.disposed) {
return () => {}
}
this.disposeHandlers.push(handler)
return () => {
const idx = this.disposeHandlers.indexOf(handler)
if (idx !== -1) {
this.disposeHandlers.splice(idx, 1)
}
}
}
/**
* Send a JSON-RPC request and wait for the response.
*/
async request(
method: string,
params?: Record<string, unknown>,
options?: SshMultiplexerRequestOptions
): Promise<unknown> {
if (this.disposed) {
throw new Error('Multiplexer disposed')
}
if (options?.signal?.aborted) {
const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string }
error.name = 'AbortError'
throw error
}
const id = this.nextRequestId++
const msg: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
...(params !== undefined ? { params } : {})
}
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout>
const cleanup = (): void => {
clearTimeout(timer)
if (options?.signal) {
options.signal.removeEventListener('abort', onAbort)
}
}
const onAbort = (): void => {
const pending = this.pendingRequests.get(id)
if (!pending) {
return
}
pending.cleanup()
this.pendingRequests.delete(id)
// Why: Space scans can run long on SSH hosts. Let the relay stop its
// local filesystem work instead of only dropping the client promise.
this.notify('rpc.cancel', { id })
const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string }
error.name = 'AbortError'
pending.reject(error)
}
timer = setTimeout(() => {
const pending = this.pendingRequests.get(id)
if (pending) {
pending.cleanup()
// Why: request timeouts should stop relay-side long-running work,
// not just detach the client from the eventual response.
this.notify('rpc.cancel', { id })
}
this.pendingRequests.delete(id)
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
}, timeoutMs)
if (options?.signal) {
options.signal.addEventListener('abort', onAbort, { once: true })
}
this.pendingRequests.set(id, {
resolve,
reject,
beforeResolve: options?.beforeResolve,
timer,
cleanup
})
this.sendMessage(msg)
})
}
/**
* Send a JSON-RPC notification (no response expected).
*/
notify(method: string, params?: Record<string, unknown>): void {
if (this.disposed) {
return
}
const msg: JsonRpcNotification = {
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
}
this.sendMessage(msg)
}
notifyWithSettlement(
method: string,
params: Record<string, unknown> | undefined,
onSettled: (result: { ok: true } | { ok: false; error: Error }) => void
): void {
if (this.disposed) {
onSettled({ ok: false, error: new Error('Multiplexer disposed') })
return
}
this.sendMessage(
{
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
},
onSettled
)
}
/**
* Send a fresh keepalive and resolve true when any frame arrives before the
* timeout. Used on system resume to distinguish a link that survived sleep
* from a dead one before tearing the session down (#7773).
*/
probeLiveness(timeoutMs: number): Promise<boolean> {
if (this.disposed) {
return Promise.resolve(false)
}
return new Promise<boolean>((resolve) => {
const settle = (alive: boolean): void => {
clearTimeout(timer)
const idx = this.livenessProbeWaiters.indexOf(waiter)
if (idx !== -1) {
this.livenessProbeWaiters.splice(idx, 1)
}
resolve(alive)
}
const waiter = { succeed: () => settle(true), fail: () => settle(false) }
const timer = setTimeout(() => settle(false), timeoutMs)
this.livenessProbeWaiters.push(waiter)
this.sendKeepAlive()
})
}
dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void {
if (this.disposed) {
return
}
if (process.env.ORCA_SSH_MUX_DEBUG === '1') {
console.warn(
`[ssh-mux] Disposing multiplexer (reason: ${reason})`,
new Error('dispose trace').stack
)
}
this.disposed = true
if (this.connectionHealthTimer) {
clearInterval(this.connectionHealthTimer)
this.connectionHealthTimer = null
}
// Why: the renderer uses the error code to distinguish temporary disconnects
// (show reconnection overlay) from permanent shutdown (show error toast).
const errorMessage =
reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed'
const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.fail()
}
for (const [id, pending] of this.pendingRequests) {
pending.cleanup()
const err = new Error(errorMessage) as Error & { code: string }
err.code = errorCode
pending.reject(err)
this.pendingRequests.delete(id)
}
const writerError = new Error(errorMessage) as Error & { code: string }
writerError.code = errorCode
this.writer.dispose(writerError)
this.unackedTimestamps.clear()
// Why: relay teardown can race with late provider registration; disposed
// muxes must not retain provider/session closures through subscribers.
this.notificationHandlers.length = 0
this.methodNotificationHandlers.clear()
this.decoder.reset()
this.transport.close?.()
for (const handler of this.disposeHandlers) {
try {
handler(reason)
} catch {
// Don't let a handler error prevent other handlers from running
}
}
this.disposeHandlers.length = 0
}
isDisposed(): boolean {
return this.disposed
}
// ── Private ───────────────────────────────────────────────────────
private sendMessage(
msg: JsonRpcMessage,
onSettled?: (result: MultiplexerWriteSettlement) => void
): void {
const seq = this.nextOutgoingSeq++
const frame = encodeJsonRpcFrame(msg, seq, this.highestReceivedSeq)
this.trackOutgoingTimestamp(seq, false)
this.writer.enqueue(frame, messageLane(msg), onSettled)
}
private sendKeepAlive(): void {
if (this.disposed) {
return
}
const seq = this.nextOutgoingSeq
const frame = encodeKeepAliveFrame(seq, this.highestReceivedSeq)
if (!this.writer.enqueue(frame, 'liveness')) {
return
}
this.nextOutgoingSeq++
this.trackOutgoingTimestamp(seq, true)
}
private handleFrame(frame: DecodedFrame): void {
// Why: any decoded frame proves the relay round-trip is alive; resolve
// pending resume probes before ordinary dispatch (#7773).
for (const waiter of this.livenessProbeWaiters.splice(0)) {
waiter.succeed()
}
// Update ack tracking
if (frame.id > this.highestReceivedSeq) {
this.highestReceivedSeq = frame.id
}
// Header ACKs are untrusted uint32 values; work stays proportional to the
// bounded set of sequence keys we actually retained.
const acknowledgedSeq = Math.min(frame.ack, this.nextOutgoingSeq - 1)
if (acknowledgedSeq > this.highestAckedBySelf) {
for (const seq of this.unackedTimestamps.keys()) {
if (seq <= acknowledgedSeq) {
this.unackedTimestamps.delete(seq)
}
}
this.highestAckedBySelf = acknowledgedSeq
}
if (frame.type === MessageType.KeepAlive) {
return
}
if (frame.type === MessageType.Regular) {
try {
const msg = parseJsonRpcMessage(frame.payload)
this.handleMessage(msg)
} catch (err) {
this.handleProtocolError(err)
}
}
}
private handleMessage(msg: JsonRpcMessage): void {
if ('id' in msg && ('result' in msg || 'error' in msg)) {
this.handleResponse(msg as JsonRpcResponse)
} else if ('id' in msg && 'method' in msg) {
void this.handleRequest(msg as JsonRpcRequest)
} else if ('method' in msg && !('id' in msg)) {
this.handleNotification(msg as JsonRpcNotification)
}
}
private async handleRequest(msg: JsonRpcRequest): Promise<void> {
const handler = this.requestHandlers.get(msg.method)
if (!handler) {
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
error: { code: -32601, message: `Method not found: ${msg.method}` }
})
return
}
try {
const result = await handler(msg.params ?? {})
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
result: result ?? null
})
} catch (err) {
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
error: {
code: (err as { code?: number }).code ?? -32000,
message: err instanceof Error ? err.message : String(err)
}
})
}
}
private handleResponse(msg: JsonRpcResponse): void {
const pending = this.pendingRequests.get(msg.id)
if (!pending) {
return
}
pending.cleanup()
this.pendingRequests.delete(msg.id)
if (msg.error) {
const err = new Error(msg.error.message)
Object.defineProperty(err, 'code', { value: msg.error.code })
Object.defineProperty(err, 'data', { value: msg.error.data })
pending.reject(err)
} else {
try {
pending.beforeResolve?.(msg.result)
pending.resolve(msg.result)
} catch (error) {
pending.reject(error instanceof Error ? error : new Error(String(error)))
}
}
}
private handleNotification(msg: JsonRpcNotification): void {
const params = msg.params ?? {}
// Why: handlers may unsubscribe during iteration (via the returned disposer
// from onNotification / onNotificationByMethod), which mutates the live
// collection and skips the next handler. Iterating a snapshot prevents that.
const snapshot = Array.from(this.notificationHandlers)
for (const handler of snapshot) {
try {
handler(msg.method, params)
} catch (err) {
// Why: relay notifications arrive on the SSH stream callback; one
// bad subscriber must not escape as a main-process uncaught exception.
console.warn(
`[ssh-mux] Notification handler failed for ${msg.method}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
const methodHandlers = this.methodNotificationHandlers.get(msg.method)
if (methodHandlers && methodHandlers.size > 0) {
const methodSnapshot = Array.from(methodHandlers)
for (const handler of methodSnapshot) {
try {
handler(params)
} catch (err) {
// Why: file-stream and PTY listeners are per-method subscribers; keep
// the mux alive even if one consumer rejects a malformed notification.
console.warn(
`[ssh-mux] Method notification handler failed for ${msg.method}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
}
}
// Why: one 5s interval owns both the periodic keepalive and dead-link check,
// halving per-connection timers while preserving their send-then-check order.
private startConnectionHealthTimer(): void {
let lastTickAt = Date.now()
this.connectionHealthTimer = setInterval(() => {
const now = Date.now()
const sinceLastTick = now - lastTickAt
lastTickAt = now
// Why: after sleep/App Nap the pre-pause keepalive looks stale on the
// first post-wake tick, killing a healthy link (#7773). Reset staleness
// before this tick's fresh probe, then allow the next full window.
const resumedAfterWake = sinceLastTick > WAKE_GAP_MS
if (resumedAfterWake) {
this.rebaseHealthClocks(now)
}
this.sendKeepAlive()
if (this.disposed || resumedAfterWake || this.decoderReadPaused || this.writerSaturated) {
return
}
const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS
// Check oldest unacked message
let oldestUnacked = Infinity
for (const ts of this.unackedTimestamps.values()) {
if (ts < oldestUnacked) {
oldestUnacked = ts
}
}
const oldestUnackedStale = oldestUnacked !== Infinity && now - oldestUnacked > TIMEOUT_MS
// Connection considered dead when BOTH conditions met
if (noDataReceived && oldestUnackedStale) {
this.handleProtocolError(new Error('Connection timed out (no ack received)'))
}
}, KEEPALIVE_SEND_MS)
}
private handleProtocolError(err: unknown): void {
console.warn(`[ssh-mux] Protocol error: ${err instanceof Error ? err.message : String(err)}`)
this.dispose('connection_lost')
}
private trackOutgoingTimestamp(seq: number, liveness: boolean): void {
const limit = liveness ? MAX_UNACKED_TIMESTAMPS : MAX_ORDINARY_UNACKED_TIMESTAMPS
if (this.unackedTimestamps.size < limit) {
this.unackedTimestamps.set(seq, Date.now())
}
}
private pauseDecoderReads(): void {
if (this.disposed || this.decoderReadPaused) {
return
}
this.decoderReadPaused = true
try {
this.transport.pauseReads?.()
} catch (error) {
this.handleProtocolError(error)
}
}
private resumeDecoderReads(): void {
if (!this.decoderReadPaused) {
return
}
this.decoderReadPaused = false
if (this.disposed) {
return
}
this.rebaseHealthClocks(Date.now())
try {
this.transport.resumeReads?.()
} catch (error) {
this.handleProtocolError(error)
}
}
private handleWriterSaturationChange(saturated: boolean): void {
this.writerSaturated = saturated
if (!saturated && !this.disposed) {
this.rebaseHealthClocks(Date.now())
}
}
private rebaseHealthClocks(now: number): void {
this.lastReceivedAt = now
for (const seq of this.unackedTimestamps.keys()) {
this.unackedTimestamps.set(seq, now)
}
}
}
function messageLane(msg: JsonRpcMessage): MultiplexerWriterLane {
return 'method' in msg && msg.method === 'pty.data' ? 'ordinary' : 'control'
}