fix(browser): bound CDP output for stalled clients (#20949)

* fix(browser): bound CDP output for stalled clients

* fix(browser): log CDP outbound overflow before terminating the client

The outbound queue terminated the automation client silently on overflow, so
the client saw a socket close indistinguishable from a crash. Surface the cap
that tripped and the backlog held when it did.

The queue dropped its backlog before invoking onOverflow, so the counters were
already zero at the callback. Snapshot them first and pass them through.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
OrcaWin
2026-09-19 17:24:33 -07:00
committed by GitHub
co-authored by m4air Neil
parent 4d82149fe5
commit a445abadd4
8 changed files with 389 additions and 9 deletions
+27 -7
View File
@@ -10,6 +10,16 @@
// Generic over the frame type so it serves both the text reply path (encrypted
// base64 strings) and the binary send path (Uint8Array frames).
/** Which hard bound tripped, plus the backlog held when it did. */
export type WsOutboundOverflowEvidence = {
cap: 'maxQueuedBytes' | 'maxQueuedFrames' | 'maxFrameBytes' | 'claimQueuedBytes' | 'sendFailed'
queuedBytes: number
queuedFrames: number
maxQueuedBytes: number
maxQueuedFrames: number
maxFrameBytes: number
}
export type WsOutboundBackpressureQueueOptions<TFrame> = {
/** Send a frame on the wire. Called only when under the soft cap. */
send: (frame: TFrame) => void
@@ -25,8 +35,9 @@ export type WsOutboundBackpressureQueueOptions<TFrame> = {
* Called once when queued bytes exceed maxQueuedBytes — the link is wedged.
* The caller should tear the connection down so a fresh subscription can
* replay an authoritative snapshot. The queue drops its backlog afterward.
* Receives the backlog measured before that drop, so callers can report it.
*/
onOverflow: () => void
onOverflow: (evidence: WsOutboundOverflowEvidence) => void
/** Soft cap: stop draining onto the wire while bufferedAmount is above this. */
softCapBytes?: number
/** Hard cap on bytes held in this queue before onOverflow fires. */
@@ -135,13 +146,22 @@ export function createWsOutboundBackpressureQueue<TFrame>(
stopTimer()
}
const failOverflow = (): void => {
const failOverflow = (cap: WsOutboundOverflowEvidence['cap']): void => {
if (disposed || overflowed) {
return
}
overflowed = true
// Snapshot before dropBacklog() zeroes the counters.
const evidence: WsOutboundOverflowEvidence = {
cap,
queuedBytes: queued,
queuedFrames,
maxQueuedBytes,
maxQueuedFrames,
maxFrameBytes
}
dropBacklog()
options.onOverflow()
options.onOverflow(evidence)
}
const sendFrame = (frame: TFrame): boolean => {
@@ -149,7 +169,7 @@ export function createWsOutboundBackpressureQueue<TFrame>(
options.send(frame)
return true
} catch {
failOverflow()
failOverflow('sendFailed')
return false
}
}
@@ -243,7 +263,7 @@ export function createWsOutboundBackpressureQueue<TFrame>(
}
const bytes = options.byteLengthOf(frame)
if (!Number.isFinite(bytes) || bytes < 0 || bytes > maxFrameBytes) {
failOverflow()
failOverflow('maxFrameBytes')
return { accepted: false, queued: false, cancel: () => false }
}
// Fast path: nothing parked and the wire is under the cap — send directly.
@@ -261,7 +281,7 @@ export function createWsOutboundBackpressureQueue<TFrame>(
}
const queuedBytesClaim = options.claimQueuedBytes?.(bytes)
if (options.claimQueuedBytes && !queuedBytesClaim) {
failOverflow()
failOverflow('claimQueuedBytes')
return { accepted: false, queued: false, cancel: () => false }
}
const entry: QueueEntry = {
@@ -274,7 +294,7 @@ export function createWsOutboundBackpressureQueue<TFrame>(
queued += bytes
queuedFrames += 1
if (queued > maxQueuedBytes || queuedFrames > maxQueuedFrames) {
failOverflow()
failOverflow(queued > maxQueuedBytes ? 'maxQueuedBytes' : 'maxQueuedFrames')
return { accepted: false, queued: false, cancel: () => false }
}
if (timer === null) {