Skip to content

quic: a bare FIN counts as sent only when ngtcp2 wrote it, not on any packet - #278

Merged
MDA2AV merged 1 commit into
mainfrom
fix/quic-bare-fin-ack-only
Oct 4, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
fix/quic-bare-fin-ack-only

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 4, 2026

Copy link
Copy Markdown
Owner

With the congestion window full and an ACK due, ngtcp2 answers a stream write with a packet holding only that ACK and reports the stream frame unwritten (*pdatalen = -1, as its docs say). PumpOut counted a bare FIN as sent whenever a packet came back, so that ACK swallowed it: the stream was done as far as the replay list knew, nothing retried the FIN, and the peer waited for an end that never came. A bare FIN now counts as sent only when the write reports 0 bytes consumed.

A streamed HTTP/3 response that calls CompleteAsync after its last flush ends with a bare FIN, on both stacks. h3x saw it as requests that never complete (13-43 per run of ~1.5M). A probe counted the FINs marked sent without being written - 22, against 22 hung requests and 22 streams that never closed - and ngtcp2's own log (a diagnostic build) showed each one: cwnd limited, then a 1-RTT packet holding only an ACK. h2load never showed them: in duration mode a stream still open at the end is not a failure.

Tests

quic: a FIN parked behind a full congestion window is still sent (new): the server fills its window on one stream and resets it, so the bare FIN it then sends on another is the only thing waiting. The client sends a packet after a lost one - an immediate ACK under RFC 9000 13.2.1 - so the parked FIN is the server's next write while the window is still full.

  • main: FAIL 5 of 5 - the stream never ends
  • this PR: pass 5 of 5

QuicTestClient gains Send(data, fin, lost), PumpUntil and per-stream byte and FIN tracking.

All suites pass: E2E 232, Unit 60, Chaos 47, Http 44, Tls 151 (the 7 kTLS tests skip without sudo), File 4.

Before and after

h3x, 16 conns × 32 streams, 1 KiB streamed responses, 6 s:

sample main this PR
Http3/ManagedStreamedBoth 13-43 never completed in each of 8 runs, every run waiting out 36 s none in 8 runs, 6.1 s*
Http3/Nghttp3Response 23 and 41 in 2 runs none in 2 runs

* one run lost a whole connection (32 requests) to a handshake timeout under load, which main shows as well - the kernel caps this machine's UDP buffer at 208 KiB.

h2load, 1 KiB, 16 conns × 32 streams, 4 interleaved rounds with a second build of main as control; within-round median vs main:

sample this PR control
Http3/ManagedStreamedBoth -1.5% +0.3%
Http3/Nghttp3Response +1.7% +1.9%
Http3/ManagedBuffered -0.2% +0.8%
Http3/Nghttp3Buffered -2.0% -2.9%

… packet

With the congestion window full and an ACK due, ngtcp2 answers a stream write with a
packet holding only that ACK and reports the stream frame unwritten (pdatalen -1).
PumpOut marked a bare FIN sent on any packet, so that ACK swallowed it: the stream
was done as far as the replay list knew, nothing retried it, and the peer waited for an
end that never came. Under h3x, 22 of 1.5M streamed HTTP/3 responses hung until the
client gave up - 22 FINs counted this way, 22 streams never closed. A bare FIN is now
sent only when the write reports 0 bytes consumed, as ngtcp2 documents.
@MDA2AV
MDA2AV merged commit ff42d21 into main Oct 4, 2026
1 check passed
@MDA2AV MDA2AV mentioned this pull request Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant