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
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quic.http3
import com.vitorpamplona.quic.webtransport.encodeHeadersFrame
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Http3FrameReaderTest {
@Test
fun parses_settings_frame_round_trip() {
val settings = buildClientWebTransportSettings()
val r = Http3FrameReader()
r.push(settings.encodeFrame())
val first = r.next()
assertTrue(first is Http3Frame.Settings)
assertEquals(1L, first.settings.settings[Http3SettingsId.ENABLE_CONNECT_PROTOCOL])
assertNull(r.next())
}
@Test
fun parses_headers_frame_round_trip() {
val headers = listOf(":status" to "200", "server" to "nests")
val frame = encodeHeadersFrame(headers)
val r = Http3FrameReader()
r.push(frame)
val first = r.next()
assertTrue(first is Http3Frame.Headers)
}
@Test
fun split_frame_across_pushes_reassembles() {
val frame = encodeHeadersFrame(listOf(":status" to "200"))
val r = Http3FrameReader()
r.push(frame.copyOfRange(0, 1))
assertNull(r.next(), "no full frame yet")
r.push(frame.copyOfRange(1, frame.size))
assertTrue(r.next() is Http3Frame.Headers)
}
@Test
fun unknown_frame_type_is_surfaced_for_caller_to_skip() {
// Build a frame with type 0x21 (reserved/unknown) and a 3-byte body.
val r = Http3FrameReader()
r.push(byteArrayOf(0x21, 0x03, 0x01, 0x02, 0x03))
val first = r.next()
assertTrue(first is Http3Frame.Unknown)
assertEquals(0x21L, first.type)
assertContentEquals(byteArrayOf(0x01, 0x02, 0x03), first.body)
}
@Test
fun multiple_frames_in_one_push() {
val a = encodeHeadersFrame(listOf(":status" to "200"))
val b =
run {
val w = com.vitorpamplona.quic.QuicWriter()
w.writeVarint(Http3FrameType.DATA)
w.writeVarint(4L)
w.writeBytes(byteArrayOf(0x01, 0x02, 0x03, 0x04))
w.toByteArray()
}
val r = Http3FrameReader()
r.push(a + b)
val first = r.next()
assertTrue(first is Http3Frame.Headers)
val second = r.next()
assertTrue(second is Http3Frame.Data)
assertContentEquals(byteArrayOf(0x01, 0x02, 0x03, 0x04), second.body)
assertNull(r.next())
}
}