fix(quic): C3+C4+C9 + Tier-2 robustness from review

C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  New Http3FrameReader buffers stream bytes and yields complete frames
  (DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
  drains the request stream after sending the Extended CONNECT request,
  feeds bytes through the reader, decodes the first HEADERS frame via
  QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
  any 401/404/500 yielded a "connected" session that silently dropped.

  Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
  cross-push reassembly, unknown-type passthrough, multi-frame in one push.

C9 — flow-control enforcement + receive-side crediting
  - Send: per-stream `sendCredit` is now consulted before each takeChunk;
    bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
    exposes `sentOffset` for the writer.
  - Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
    receive cursor crosses half the advertised window, and MAX_DATA at
    the connection level. Without this, peer windows close and any
    sustained transfer wedges silently.

Tier-2 cleanups (six items in one batch):
  - AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
    we receive an ACK frame. Range list no longer grows unboundedly on
    long connections.
  - ReceiveBuffer adjacency edge: pull in the prior chunk when its
    endOffset exactly equals the new chunk's start. Previously perfectly-
    sequential receives starting at offset > 0 left adjacent chunks
    unmerged, growing the chunk list and overcounting bufferedAhead.
  - RetryPacket integrity-tag verify: constant-time compare instead of
    contentEquals.
  - ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
    (we send empty); reject non-empty as a downgrade signal.
  - TLS state machine handles post-handshake NewSessionTicket and
    KeyUpdate at Application level — silently drop instead of throwing
    "unexpected post-handshake type" and tearing down the connection.

Tier-3 perf: SendBuffer chunked queue
  Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
  + headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
  head chunk. Memory is bounded by the sum of outstanding writes instead
  of (sum)². For sustained MoQ stream writes of small chunks this drops
  from O(N²) memcpy to O(N).

All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.

Remaining items deferred:
  Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
          over <10 KB), TLS HelloRetryRequest detection (we never send
          incompatible ClientHello today, server won't HRR).
  Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
          codec triple-allocation. None block live interop; revisit if
          measured RTT or CPU surfaces them.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 22:04:39 +00:00
parent e250b76272
commit 368b8dd432
11 changed files with 406 additions and 17 deletions
@@ -23,8 +23,11 @@ package com.vitorpamplona.nestsclient.transport
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.connection.QuicConnectionConfig
import com.vitorpamplona.quic.connection.QuicConnectionDriver
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.http3.buildClientWebTransportSettings
import com.vitorpamplona.quic.qpack.QpackDecoder
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.tls.CertificateValidator
import com.vitorpamplona.quic.tls.JdkCertificateValidator
@@ -114,11 +117,57 @@ class QuicWebTransportFactory(
val requestStream = conn.openBidiStream()
val headers = buildExtendedConnectHeaders(authority, path, bearerToken)
requestStream.send.enqueue(encodeHeadersFrame(headers))
driver.wakeup()
// Wait for the response HEADERS and verify :status is 2xx before
// declaring the WebTransport session open. Per RFC 9220 a non-2xx
// status means the server rejected the upgrade.
val responseStatus = readResponseStatus(requestStream)
if (responseStatus !in 200..299) {
driver.close()
throw WebTransportException(
kind = WebTransportException.Kind.ConnectRejected,
message = "WebTransport CONNECT returned :status=$responseStatus",
)
}
val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId)
return QuicWebTransportSession(state)
}
/**
* Drain bytes from [requestStream] through an [Http3FrameReader] until a
* HEADERS frame arrives, then decode the QPACK field section and pull the
* `:status` pseudo-header.
*
* Returns 0 if the stream closes without a HEADERS frame (the caller treats
* that as a connect rejection).
*/
private suspend fun readResponseStatus(requestStream: QuicStream): Int {
val reader = Http3FrameReader()
val incoming = requestStream.incoming
try {
incoming.collect { chunk ->
reader.push(chunk)
while (true) {
val frame = reader.next() ?: break
if (frame is Http3Frame.Headers) {
val pairs = QpackDecoder().decodeFieldSection(frame.qpackPayload)
val status = pairs.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0
throw HeadersReceived(status)
}
}
}
} catch (e: HeadersReceived) {
return e.status
}
return 0
}
private class HeadersReceived(
val status: Int,
) : RuntimeException()
private fun splitAuthority(authority: String): Pair<String, Int> {
val idx = authority.lastIndexOf(':')
if (idx <= 0) return authority to 443