Skip to content
All posts

·2 min read

The FIN that never arrived

A one-in-three stall in mod_http3 under simulated congestion, and where the bug actually lived: between HTTP framing and QUIC flow control.

  • http3
  • quic
  • apache
  • debugging

During Google Summer of Code I registered mod_http3 with the QUIC Interop Runner. The runner puts a matrix of clients and servers through a set of test cases inside a network simulator, and one of those cases adds congestion. Under it, about one run in three, my server stalled. The client asked for a set of files, most arrived, and then one small response never finished. No error, no reset, no timeout on my side. The connection sat open until the runner gave up.

The first thing I did was the wrong thing. I assumed the bug was in the code I had written most recently, the listener and connection refactor. I added logging to the connection lifecycle and found nothing. The connection was healthy. So was the stream that never finished, as far as httpd could tell: the request had been handled, the response written to the output filter chain, and the chain had handed everything to my module.

The second thing was more useful: compare a stalled run with a clean one at the moment the response body ended. In the good runs, the stream's last frame carried the FIN bit and the transport acknowledged it. In the bad runs, the last data frame went out and the FIN did not. The body was complete on the wire, but the stream was never closed, so the client kept waiting. Correctly.

The cause is a mismatch between two libraries that each behave reasonably on their own. nghttp3 produces HTTP/3 frames and tells the transport, once, that a stream is finished. After that it treats the stream as written and never offers the FIN again. OpenSSL's QUIC, for its part, can refuse to send anything, even a bare FIN, when the congestion window is full. Under the simulator's congestion that refusal was common. So the FIN was offered exactly once, at exactly the moment the transport could not take it, and both sides moved on as if it had been sent. The smallest response on a busy connection was the usual victim: its data fit in the window and its FIN did not.

The fix follows from the diagnosis. The module remembers, per stream, that a FIN was offered and not accepted, and offers it again once the transport can write. mod_http3 now passes the QUIC Interop Runner HTTP/3 case.

What I took from it is less about HTTP/3 than about where bugs live in layered systems. Neither library was wrong. The bug lived in the contract between them, in an assumption nghttp3 makes about the transport that OpenSSL does not satisfy under pressure. That seam, between application framing and transport flow control, is the same one a communication runtime for distributed training has to get right, and I expect the failures there look a lot like this one: rare, quiet, and only visible when the network is busy.

The retry lives in the stream unit of mod_http3. The interop results are public on the runner.