From 42f75d60b57ceef81986d2f5dcf6e0f8cca48546 Mon Sep 17 00:00:00 2001 From: Lars van der Zande Date: Sat, 18 Apr 2026 12:38:14 -0500 Subject: [PATCH] fix(QUICStream): handle peers that start with zero stream credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes in `QUICStream` for peers that advertise `initial_max_streams_uni: 0` (or any already-exhausted count) and grant stream credit post-handshake via `MAX_STREAMS` frames. Problem ------- The constructor eagerly primes every new stream with `streamSend(streamId, new Uint8Array(0), false)` to make local stream state symmetric with closing behavior. When quiche returns `StreamLimit` on that prime call, the constructor throws `ErrorQUICStreamLimit`. But the stream ID has already been consumed by the local allocator, and quiche has no record of the stream — so the next `newStream('uni')` hits `ErrorQUICUndefinedBehaviour: We should never repeat streamIds when creating streams`, permanently breaking outbound stream creation on that connection. Encountered in the wild against Solana's Agave TPU-QUIC server: Agave advertises 0 initial uni streams to unstaked clients and drip-feeds MAX_STREAMS frames under its stake-weighted QoS rate limiter. The eager-prime races ahead of the first credit grant, and every stream attempt on the connection fails from that point. Fix --- 1. `createQUICStream`: if the eager-prime returns `StreamLimit`, swallow it instead of throwing. The stream object is still constructed locally; the caller gets a live stream it can write to. Quiche's internal state is untouched by the failed zero-length prime (it only records a stream once real bytes flow), so the stream ID is free to be used later when `writableWrite` retries. 2. `writableWrite`: bounded retry on `StreamLimit` — up to 20 attempts with 50 ms backoff (total ~1 s budget). Lets the connection's receive loop process incoming MAX_STREAMS frames before we fail the write. If no credit arrives within the budget, we fall through to the existing `ErrorQUICStreamInternal` path. Behavior for peers that advertise non-zero initial credit is unchanged: the eager-prime succeeds, the retry loop never fires. Verified -------- - Integration test against `solana-test-validator` (Agave 3.1.11 TPU): transaction successfully submitted via TPU-QUIC and landed at `processed` commitment, where previously every attempt returned `StreamLimit` before any bytes were written. - Live mainnet-beta probe against 3 Agave 3.1.13 + 3 Frankendancer 0.820.30113 nodes: all reachable nodes now accept a test write on a client-initiated uni stream. Pre-fix: 0/3 Agave sends succeeded. Post-fix: 2/3 Agave succeed (1 was an unrelated network timeout), 3/3 Frankendancer succeed (unchanged — they already worked). Downstream context ------------------ Discovered while building a Solana TPU client in TypeScript. Upstream patch request so the downstream project can drop its `patch-package` shim. --- src/QUICStream.ts | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/QUICStream.ts b/src/QUICStream.ts index 5d842137..ea7c4095 100644 --- a/src/QUICStream.ts +++ b/src/QUICStream.ts @@ -291,19 +291,25 @@ class QUICStream implements ReadableWritablePair { // ignore. if (utils.isStreamStopped(e) === false) { if (e.message === 'StreamLimit') { - const limit = - this.type === 'bidi' - ? config.initialMaxStreamsBidi - : config.initialMaxStreamsUni; - throw new errors.ErrorQUICStreamLimit( - `Stream limit of ${limit} has been reached`, + // Some peers advertise `initial_max_streams_uni: 0` (or an + // exhausted count) and rely on MAX_STREAMS frames post-handshake + // to grant stream credit. This is used for flow-controlled or + // stake-weighted QoS on the server side (e.g. Solana's Agave + // TPU-QUIC server). The eager-prime `streamSend(0-length)` races + // ahead of any credit grant. Swallow the StreamLimit here so the + // stream object is still constructed locally; the first real + // `writableWrite` will retry `streamSend` (see retry block there) + // and succeed once MAX_STREAMS has arrived. + // Throwing here both broke the consumer's view of stream creation + // (returning a stream id that was then rejected) AND left quiche's + // internal state such that subsequent `newStream` calls hit + // `ErrorQUICUndefinedBehaviour: We should never repeat streamIds`. + } else { + throw new errors.ErrorQUICStreamInternal( + `Failed to prime local stream state with a 0-length message: ${e.message}`, { cause: e }, ); } - throw new errors.ErrorQUICStreamInternal( - `Failed to prime local stream state with a 0-length message: ${e.message}`, - { cause: e }, - ); } } } @@ -660,6 +666,16 @@ class QUICStream implements ReadableWritablePair { return; } let sentLength: number; + // Bounded retry on StreamLimit. Peers that advertise + // `initial_max_streams_uni: 0` (e.g. Solana's Agave TPU-QUIC for + // unstaked clients) issue MAX_STREAMS frames post-handshake rather + // than at handshake time. `streamSend` can therefore fail with + // `StreamLimit` on the first write attempt; a short backoff lets the + // connection's receive loop process incoming MAX_STREAMS frames + // before we fail the caller. Total wait is bounded at ~1 s. + let streamLimitRetries = 0; + const STREAM_LIMIT_MAX_RETRIES = 20; + const STREAM_LIMIT_BACKOFF_MS = 50; while (true) { try { const result = this.connection.conn.streamSend( @@ -691,6 +707,15 @@ class QUICStream implements ReadableWritablePair { }), ); return; + } else if ( + (e as { message?: string }).message === 'StreamLimit' && + streamLimitRetries < STREAM_LIMIT_MAX_RETRIES + ) { + streamLimitRetries++; + await new Promise((resolve) => + setTimeout(resolve, STREAM_LIMIT_BACKOFF_MS), + ); + continue; } else { const e_ = new errors.ErrorQUICStreamInternal( 'Local stream writable could not `streamSend`',