a73x

b817dc93

docs: the transient had two causes, and neither story alone ever fit

a73x   2026-08-08 18:22

Commit message
docs: the transient had two causes, and neither story alone ever fit

The record said the re-entrancy defect causing the 171/172 transient was
strong inference rather than proof. That was the honest state of knowledge
and it stays on the page, because what settled it is an answer no
single-cause explanation could have reached.

Two independent defects, each explaining half the evidence. The transient
was drainPending giving up on the first quiet wakeup — reproduced on HEAD at
25 failures in 40 runs under contention and driven to 0 in 40 by the fix,
observed rather than inferred. The leaks were the ngtcp2 abort, but reaching
the suite through other concurrent processes sharing /tmp rather than
through the run that showed the failure; the HEAD failures produced zero
leaks, because an assertion failure runs its defers and only a signal kills
a process before they can.

So the leaked temp directory was a true clue pointing at a real defect, and
the failure it was found beside had a different cause entirely. Both
findings were right. The mistake available was assuming one story.

The new drainPending section carries the contention matrix, including that
the two halves of that fix are not both required — the stall bound alone
closes it, the poll half alone halves the rate without closing it — and
states plainly that the end-to-end defect is not pinnable in-suite, only
the two decisions inside it are.

Worth noting what "strong inference, not proof" bought: this correction is
an addition underneath it, not a retraction of it. Nothing above had to be
taken back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -1096,11 +1096,39 @@ depending on whether a datagram carried the frame ordering that turns it
1096 fatal. Every QUIC session before this fix was corrupting loss-detection 1096 fatal. Every QUIC session before this fix was corrupting loss-detection
1097 state silently. 1097 state silently.
1098 1098
1099 **Honesty about the causal link.** The original 171/172 transient was never 1099 **The causal link, finally settled — and it was two defects, not one.**
1100 reproduced and its test name was never captured, so "this defect caused that 1100
1101 failure" is **strong inference, not proof**. What is proven: the defect was 1101 The paragraph that stood here said the re-entrancy defect causing the
1102 real, constant, and capable of producing exactly that signature. What is 1102 171/172 transient was strong inference rather than proof: the transient had
1103 not: that it produced *that* one. 1103 never been reproduced and its test name had never been captured. That was
1104 the honest state of knowledge at the time and it is left on the record as
1105 such, because the answer turned out to be one no single-cause explanation
1106 could have reached.
1107
1108 There were **two independent defects**, and the reason nothing ever quite
1109 fit is that each explained half the evidence:
1110
1111 - **The transient was `drainPending`.** It gives up the first time a wakeup
1112 retires nothing, which misreads a coalesced or flow-control-only
1113 acknowledgement as a finished peer. Reproduced on HEAD under four-way
1114 contention on one core at **25 failures in 40 runs**, always the same
1115 test, and driven to **0 in 40** by the fix. Observed, not inferred.
1116 - **The leaks were the ngtcp2 abort** — but reaching the test suite through
1117 *other concurrent processes sharing `/tmp`*, not through the run that
1118 showed the failure. That is why the two never correlated: the 21 HEAD
1119 failures produced zero leaks, because an assertion failure runs its
1120 defers and only a signal kills a process before they can run.
1121
1122 So the leaked temp directory was a true clue pointing at a real defect, and
1123 the failure it was found next to had a different cause entirely. Neither
1124 finding was wrong; the mistake available here was to assume one story.
1125
1126 **What the earlier inference got right and wrong.** Right: the re-entrancy
1127 was real, constant, and capable of producing exactly the signature seen.
1128 Wrong: nothing — it never claimed more than that, which is why the
1129 correction is an addition rather than a retraction. The value of having
1130 written "strong inference, not proof" is precisely that this paragraph could
1131 be added underneath it without anything above needing to be taken back.
1104 1132
1105 Two things queue-only broke that had to be fixed with it, neither in the 1133 Two things queue-only broke that had to be fixed with it, neither in the
1106 audit's list — latency (a reply would have waited for the next poll cycle, 1134 audit's list — latency (a reply would have waited for the next poll cycle,
@@ -1135,6 +1163,45 @@ made the ordering a property of one small function, which a unit test pins
1135 directly (`wrote=4, n=-1` must account the four and then stop) without 1163 directly (`wrote=4, n=-1` must account the four and then stop) without
1136 needing to make ngtcp2 fail on demand. 1164 needing to make ngtcp2 fail on demand.
1137 1165
1166 ### The second defect: drainPending gave up too easily
1167
1168 `drainPending` is the shutdown path's bounded wait — the one that gets the
1169 shell's exit status to a client before the daemon goes. It concluded a peer
1170 was finished the first time a wakeup retired no bytes. A quiet wakeup means
1171 none of those things: an acknowledgement can arrive coalesced with others,
1172 re-acknowledge packets already acknowledged, or carry only a flow-control
1173 update. So the loop abandoned data the peer was still going to take, and the
1174 exit status was lost rather than delayed.
1175
1176 Fixed by bounding a **run** of unproductive wakeups (`max_drain_stalls`,
1177 reset by any progress) rather than acting on one, and by bounding the poll
1178 slice with ngtcp2's own next deadline, floored at 1ms.
1179
1180 **Both halves were handed over as required; measurement says otherwise, and
1181 the measurement is what goes in the record.** Four-way contention on one
1182 core, 40 runs each:
1183
1184 | | failures |
1185 |---|---|
1186 | unpatched | 25/40 |
1187 | poll half only | 13/40 |
1188 | stall bound only | 0/40 |
1189 | both halves | 0/40 |
1190
1191 The stall bound alone closes it. The poll half alone roughly halves the rate
1192 without closing it — evidence that it touches the same mechanism rather than
1193 a different one. Both are kept, because the poll half stands on its own
1194 reasoning about PTO starvation (retransmission lives only in `tick()` since
1195 the re-entrancy fix, so a slice that sleeps past an expiry wedges a lossy
1196 path) and a loopback reproduction with no packet loss cannot exercise that.
1197 But "required" was not true of it and the record does not say so.
1198
1199 **What is pinned, and what is not.** The stall bound is pinned by literal
1200 count and the floor by value, both mutation-checked in both directions. The
1201 end-to-end defect is **not** pinnable in-suite: it needs contention, and
1202 nothing in `make test` defends it. That is stated rather than left for
1203 someone to discover when it regresses.
1204
1138 ### Reconcile v2: judge evidence, not arrival order 1205 ### Reconcile v2: judge evidence, not arrival order
1139 1206
1140 The first design read any cell that did not already hold the prediction as a 1207 The first design read any cell that did not already hold the prediction as a