Files
orca/src/main/ssh/ssh-pty-targeted-reattach-queue.ts
T
Jinjing 9fb4dbe8eb fix(ssh): handle rejected PTY deliveries with targeted recovery (#12746)
Add targeted recovery for rejected PTY source frames instead of
terminating the relay channel. Classify rejection reasons (malformed,
generation mismatch, range invalid) and attempt recovery based on the
rejection type. Implement admission control at publication time to ensure
frames aren't delivered after ownership changes. Bound recovery attempts
and retry with backoff to prevent exhaustion. Diagnose and log rejection
reasons to aid debugging.
2026-08-05 10:30:04 -07:00

58 lines
1.8 KiB
TypeScript

type TargetedReattach = Readonly<{ start: () => void }>
// Why bounded: every rejected frame asks for its own attach round trip, so a relay that starts
// rejecting across many PTYs at once fans out one concurrent reattach per PTY. The bulk reconnect
// path caps the identical work, and each attach also costs a lease read and a lease write.
export class SshPtyTargetedReattachQueue {
private readonly running = new Map<string, TargetedReattach>()
private readonly waiting: TargetedReattach[] = []
private active = 0
constructor(private readonly maxConcurrency: number) {}
has(key: string): boolean {
return this.running.has(key)
}
run(key: string, task: () => Promise<boolean>): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
const entry: TargetedReattach = {
start: () => {
this.active++
task().then(
(recovered) => {
this.settle(key, entry)
resolve(recovered)
},
(error: unknown) => {
this.settle(key, entry)
reject(error instanceof Error ? error : new Error(String(error)))
}
)
}
}
this.running.set(key, entry)
if (this.active < this.maxConcurrency) {
entry.start()
return
}
this.waiting.push(entry)
})
}
// Why queued entries are dropped rather than started: each one is keyed to the provider generation
// the teardown just ended, so running it would attach onto a mux that is already gone.
clear(): void {
this.waiting.length = 0
this.running.clear()
}
private settle(key: string, entry: TargetedReattach): void {
if (this.running.get(key) === entry) {
this.running.delete(key)
}
this.active--
this.waiting.shift()?.start()
}
}