#17377·netty

codec-native-quic: quiche flow-control double-counting can cause spurious FLOW_CONTROL_ERROR

Author: jbaldassariCreated Sep 1, 2026Updated Sep 17, 2026

Expected behavior

A Netty QUIC connection should remain open as long as the sender stays within the connection-level flow-control credit advertised by its peer, including during high-volume transfers with packet loss or reordering.

Actual behavior

During sustained transfers over Netty QUIC streams, the receiver can eventually close the entire QuicChannel with:

io.netty.handler.codec.quic.QuicException:
QuicTransportError{code=3, name='FLOW_CONTROL_ERROR'}:
QUICHE_ERR_FLOW_CONTROL

Instrumentation on both endpoints confirmed that the sender had consumed exactly the MAX_DATA credit it was granted and had not exceeded it.

This is caused by a flow-control accounting bug in the quiche revision embedded by netty-codec-native-quic. Under a rare packet-packing condition, quiche emits a zero-length non-FIN STREAM frame at a forward offset. The receiver charges that offset advancement against connection flow control but discards the empty frame without updating the stream’s recorded high-water mark. When data covering the gap subsequently arrives, those bytes are charged again.

The resulting accounting inflation persists for the lifetime of that QUIC connection. Once enough inflation accumulates and the sender approaches the advertised MAX_DATA limit, a legal STREAM frame is rejected and the entire connection is closed. Every stream on that QuicChannel is consequently reset.

Upstream quiche issue: https://github.com/cloudflare/quiche/issues/2696 Upstream quiche fix: https://github.com/cloudflare/quiche/pull/2697

This Netty issue is intended to track updating the quiche revision used by codec-native-quic once the upstream fix is available.

Steps to reproduce

The failure is probabilistic through the public Netty API because it depends on packet packing, but the following workload reproduces it:

  1. Create a Netty QUIC client and server using netty-codec-classes-quic and netty-codec-native-quic.
  2. Keep one QuicChannel open for the duration of the test.
  3. Open enough client-initiated bidirectional streams for later stream IDs to be at least 64. With normal QUIC stream-ID allocation, this means opening at least 17 client-initiated bidirectional streams during the connection’s lifetime.
  4. Continuously transfer data on several streams, allowing at least one stream with an ID of 64 or greater to advance beyond offset 2^30 (1 GiB).
  5. Introduce packet loss or reordering between the endpoints. A shaped WAN path or tc netem can be used; higher loss and packet rates make the failure appear sooner.
  6. Continuously consume received data so the receiver issues connection-level flow-control updates. A relatively small connection window, such as 16–32 MiB, helps keep the sender near the MAX_DATA edge.
  7. Continue the transfer for an extended period.

Our reproducing workload used:

  • 32 concurrent streams driven by iperf3
  • Approximately 300–700 Mb/s aggregate throughput
  • Approximately 1,148 bytes of stream payload per packet
  • A lossy/reordered WAN path
  • A 16 or 32 MiB connection flow-control window
  • An 8 MiB per-stream window

Under this workload, the connection was spuriously closed approximately 1–3 times per hour. Diagnostic captures showed the receiver’s connection-level byte count exceeding the sum of its stream high-water marks by exact whole-frame multiples.

The condition becomes possible at large offsets because quiche’s STREAM-packing block is gated by a 12-byte assumed maximum header size. A stream ID of at least 64 combined with an offset of at least 2^30 produces a 13-byte encoded header, allowing packet capacity to fit the header exactly while leaving no room for payload.

A deterministic source-level reproduction and regression tests are included in the linked upstream quiche PR.

Minimal yet complete reproducer code (or URL to code)

The public-API reproduction is the workload described above; the exact failure depends on packet-packing timing.

Deterministic quiche receiver and sender regression tests: https://github.com/cloudflare/quiche/pull/2697

If useful, I can also prepare a standalone Netty soak reproducer.

Netty version

netty-codec-classes-quic: 4.2.17.Final netty-codec-native-quic: 4.2.17.Final:linux-x86_64 Embedded quiche revision: 4f347477006bf7f928335d28f05056013f70b87e

JVM version

Observed from a GraalVM Native Image built with Java 21. The defect is in the embedded native quiche implementation and is not believed to depend on JVM execution mode.

java version "21.0.11" 2026-04-21 LTS
Java(TM) SE Runtime Environment Oracle GraalVM 21.0.11+9.1 (build 21.0.11+9-LTS-jvmci-23.1-b92)
Java HotSpot(TM) 64-Bit Server VM Oracle GraalVM 21.0.11+9.1 (build 21.0.11+9-LTS-jvmci-23.1-b92, mixed mode, sharing)

OS version

Linux x86_64

Linux james-p14s5 6.18.43-gentoo-dist #1 SMP PREEMPT_DYNAMIC Mon Aug 10 10:46:58 EDT 2026 x86_64 AMD Ryzen 7 PRO 8840HS w/ Radeon 780M Graphics AuthenticAMD GNU/Linux