Back to guides
Post-mortem

Two Ways Out of a QUIC Deadlock, Both Closed Years Ago

We reported a handshake that stalls forever when one packet is lost. A maintainer reproduced it, then a core developer explained it further than our report had, and his answer points at two commits from 2020 and 2021.

26 August 2026 · follow-up to nginx/nginx#1616

We stopped watching the thread

In the original write-up we described an nginx HTTP/3 handshake that deadlocks when the ServerHello spans two Initial packets and one of them is lost. The retransmission is scheduled, logged, and never written to the wire. The client waits out its handshake deadline and gives up. Nothing is logged at error level, so from the operator's side it is an unexplained failure.

That article ended with a promise: it would be updated when the upstream thread moved. It said both the issue and the candidate fix had been re-checked on 23 August and were still open. Both statements were true. They were also the wrong check.

We were reading the status field, not the conversation. The thread had moved on 4 and 5 August: a maintainer reproduced the bug, and then an nginx core developer posted a diagnosis that goes further than ours did. That sat unread for three weeks while we correctly reported "still open" twice.

Checked again for this article, on the day of writing. nginx/nginx#1616 is open, last updated 18 August. nginx/nginx#1617 is open and unmerged. No fix has landed: the most recent QUIC commits on the default branch are from 12 and 28 July and are unrelated to loss recovery. Everything below describes an open bug in current nginx.

What nginx said

On 4 August a maintainer confirmed it in one line: "I could reproduce the issue. we will look at proposed change." That is the sentence a bug report is written to produce, and the reason the report shipped with a scripted reproduction rather than only prose.

On 5 August pluknet, an nginx core developer, posted the part that matters. Our report had concluded that the congestion window was the blocker: on loss it collapses below the bytes already in flight, and nginx then emits ACK-only packets, skipping every other frame including the CRYPTO frame that the client is waiting for. His reply confirms that and then explains why the code that exists to prevent exactly this does not run:

- once we received dup crypto frames, that's a signal a client couldn't
  proceed with handshake so we need to resend crypto frames.
- we resend anything in send queue limited to ACK frames only due to congestion
- congestion window cannot be lowered enough by marking unack'ed Initial packets
  as lost in order to to resent Initial crypto frame because we sent too much on
  the Handshake level as well, and Handshake packet cannot be marked lost because
  we never receive Handshake ACKs.

Read the last clause slowly, because it is the whole bug. To reopen the window you must mark packets lost. The packets holding the window shut are Handshake packets. A packet is marked lost when a later one is acknowledged - and the client cannot acknowledge any Handshake packet, because it has no handshake keys, because the CRYPTO frame that would give it those keys is the frame being skipped. The condition that would free the window can only be met by the thing the window is blocking.

Our report had the same mechanism in different words. What we did not have was the name of the feature this was supposed to trip, or the two commits that explain why it does not.

The deadlock win 3514 < in_flight 5021 every Initial packet is ACK-only Hatch 1 - “speeding up handshake completion”, Oct 2020 resend CRYPTO when duplicate CRYPTO arrives 7bd386871 Hatch 2 - PTO resends unacknowledged data replaced by two PING frames 5d4e864e0, Feb 2021 Both shut: the handshake stalls until the client gives up, about 10 s.

Two independent ways out of this deadlock existed. Neither can fire.

The hatch that was built for this exact case

QUIC's loss recovery spec has a provision for a client stuck exactly this way. RFC 9002, under "Speeding Up Handshake Completion", says an endpoint may, a limited number of times per connection, send a packet containing unacknowledged CRYPTO data earlier than the PTO expiry. The trigger is receiving a duplicate CRYPTO frame from the peer, which is a client saying, in effect, I am still missing something.

nginx implemented it. Commit 7bd386871, October 2020:

QUIC: speeding up handshake completion.

As per quic-recovery draft, section-6.2.3: resend CRYPTO frames
when receiving an Initial packet containing duplicate CRYPTO data.

In our captures the client does its part correctly. It repeats its ClientHello, which is duplicate CRYPTO data, which is the trigger. nginx reaches the resend path and logs resend packet pnum:1. The frame is requeued. And then the output loop drops it, because the congestion window is smaller than the bytes in flight and the frame is not an ACK.

So the feature is not missing and it is not un-triggered. It runs, and its output is discarded one layer down. That is a worse failure mode than an unimplemented feature, because every log line says the right thing happened.

The other hatch was removed on purpose

A probe timeout is the second way unacknowledged CRYPTO would get back on the wire. RFC 9002 says previously sent data may be sent on PTO expiration. nginx used to do that. Commit 5d4e864e0, February 2021, changed it. The relevant part of the diff is one removed call:

QUIC: send PING frames on PTO expiration.
Two PING frames are sent per level that generate two UDP datagrams.

-        ngx_quic_resend_frames(c, ctx);
...
+        f->type = NGX_QUIC_FT_PING;
+        f->flush = 1;
+        ngx_quic_queue_frame(qc, f);

Before that commit, a PTO resent the actual outstanding frames - which, in our failure, would have carried the missing CRYPTO frame and ended the deadlock. After it, a PTO sends two PING frames. A PING elicits an acknowledgement; it carries no CRYPTO. And PINGs escape the congestion filter that blocks the CRYPTO frame, because they are queued with ignore_congestion set, which is why our captures show probe after probe leaving the server while the one frame that matters never does.

This is not a mistake in that commit. Sending PINGs on PTO is normal, standard-conforming behaviour, and it is what most QUIC stacks do. It simply removed the redundancy that was covering for the first hatch being blocked. As pluknet put it: "It worked this way by sending unack'ed data before it was changed in 5d4e864e0. Maybe it needs to be rethought."

Neither commit is a bug on its own. The 2020 commit adds a correct feature. The 2021 commit makes a defensible change. The defect only exists in the overlap, where the first is silently disabled and the second no longer compensates. Bugs that need two reasonable changes to line up are the ones that survive review, because there is nothing wrong to see in either diff.

Five and a half years, then a bigger ServerHello

February 2021 to August 2026 is five and a half years. The deadlock needs a ServerHello that does not fit in one Initial packet, and for most of that period almost nothing produced one. A classical ServerHello is around 120 bytes.

Post-quantum key exchange changed that. X25519MLKEM768 carries an ML-KEM key share of 1088 bytes, which pushes the ServerHello to roughly 1178 and splits it across two Initial packets - in our traces, CRYPTO off=0 len=1124 followed by off=1124 len=54. Lose the first and the client holds 54 bytes of a message it cannot parse. The same migration that made ssh start warning about classical key exchange is what made this reachable.

It also needs a second condition, and this is the part that nearly sank our original report: a certificate flight large enough to span several Handshake packets, which is what keeps unacknowledgeable bytes in flight. A single small self-signed certificate did not reproduce it in 150 attempts. A 3913-byte three-certificate RSA-4096 chain does. Both conditions have to hold, which is a fair description of a modern TLS server and a poor description of a test rig.

The measurements from the original report stand unchanged, since nothing in the diagnosis affects them: about 5% of handshakes fail at 2% packet loss, on stock nginx 1.31.3 as well as our build, with two independent client stacks, and 0 out of 100 with ssl_ecdh_curve X25519 forced.

What it means for the patch we proposed

Our change was two hunks that let lost handshake CRYPTO bypass congestion control: mark the frame ignore_congestion when it is requeued below the application encryption level, and honour that flag in the main output loop, where it was previously ignored. It measured 0 failures in 200 against 3 in 75 unpatched.

pluknet's framing narrows it. His suggestion is to signal ngx_quic_resend_frames() that this is specifically the "speeding up handshake completion" case, where bypassing congestion is what the specification already contemplates, rather than granting a blanket exemption to all handshake CRYPTO. That is the better shape: it restores the intent of the 2020 commit instead of widening a hole around it, and it keeps the exemption tied to a condition the RFC bounds - a limited number of times per connection.

This is the second time on this bug that the upstream instinct has been narrower than ours, and correct. The first was #1617, which stops the window collapsing so far in the first place by deriving the new threshold from the window before the reduction rather than from what remained in flight after it, citing RFC 9438. It is worth being explicit that the person who found a bug is usually not the person best placed to fix it, and that submitting a patch is a way of describing the problem precisely, not a claim to have solved it.

Where it stands

Checked on 26 August 2026, the day this was written:

Item State Last movement
nginx/nginx#1616, the report open 18 August 2026
nginx/nginx#1617, CUBIC reduction open, not merged 4 August 2026
A fix in the default branch none latest QUIC commits unrelated

If you run nginx with HTTP/3 and a post-quantum key exchange, this is present in the version you have. There is no configuration that fixes it. Forcing a classical curve avoids it and costs you post-quantum protection, which is a bad trade for a fault that shows up in a few percent of handshakes on a lossy path.

The general lesson

An escape hatch nobody has watched fire is not a feature, it is an assumption. The 2020 code path ran on every one of our failed handshakes, logged that it was doing the right thing, and produced nothing. Had anything asserted that a CRYPTO frame actually left the socket after that path ran, the defect would have surfaced in 2021 rather than 2026. The test that would have caught it is not "does the feature exist" but "does its output reach the wire".

The other lesson is ours and cheaper to learn. We promised to update an article when upstream moved, then checked the field that says open instead of reading the thread, and missed a core developer's diagnosis for three weeks. A status field tells you whether someone closed something. It does not tell you whether someone answered you.