b817dc93
docs: the transient had two causes, and neither story alone ever fit
a73x 2026-08-08 18:22
Commit message
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 |