fix(transport): re-acknowledge retransmitted packets so streams stop stalling - #5276
fix(transport): re-acknowledge retransmitted packets so streams stop stalling#5276sanity wants to merge 2 commits into
Conversation
…stalling A duplicate packet was silently dropped without queueing a receipt, even though ReportResult::AlreadyReceived documents that the packet will be re-acknowledged. Receipts ride on noop/piggybacked packets that are themselves never retransmitted, so a lost receipt was unrecoverable: the sender retransmitted, the receiver stayed silent, and the packet's bytes stayed in the sender's flight accounting with no remaining way to clear them. With a full congestion window that is fatal long before the 12-retransmit budget runs out. The sender blocks in cwnd wait and CWND_WAIT_TIMEOUT (3s) aborts the stream, which the receiver sees as a transfer that simply stopped mid-stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
|
This is not directly relevant enough to pursue further — the comment's claim about the chunker is not part of the diff's functional code and doesn't need deep verification for a rule-compliance review. The diff itself is well-scoped, well-tested, and matches the transport module's bounded-collection and testing requirements. Rule Review: No issues foundRules checked: git-workflow.md, code-style.md, testing.md, transport.md The change re-acknowledges retransmitted packets in the No rule violations detected. Rule review against |
Review found that the previous commit removed the only bound on pending_receipts: the Occupied arm pushed without the length check that the Vacant arm enforces via QueueFull. That cap is load-bearing rather than advisory, because send_packet's receipt chunker splits an oversized list only once (split_off returns the tail), so a list longer than twice the ~289 receipts that fit in a packet serializes past MAX_DATA_SIZE. That error is not a transient send failure, so it tears down the connection -- a peer replaying old packet ids could have killed it. Also corrects two claims that did not survive checking. Noop packets carrying receipts ARE tracked and retransmitted, so a lost receipt is normally recoverable; the genuinely unrecoverable case is narrower, and is now stated accurately: the recv loop's flush sites have already mem::taken the pending list into noop() when the send fails, so their "will retry" has nothing left to retry with. And AlreadyReceived does not by itself guarantee the caller skips the payload -- its (_, true) arm wins when the receipt timer trips. The bound test is written against distinct replayed ids, not repeats of one id: dedup alone caps the list at the number of distinct ids, so a repeat-based test passes with the bound removed and pins nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Review round 1 — findings and what changedRan two independent blind reviewers (skeptical, code-first) per the Full-tier rule for transport changes. Both independently found the same three problems, one of them blocking. Pushed Blocking, fixed. The first commit removed the only bound on My stated mechanism was wrong, corrected. I claimed receipts are never retransmitted, so a lost one is unrecoverable. Not true — Weak evidence withdrawn. I cited A test that pinned nothing. My first bound test replayed one id repeatedly and passed with the bound removed — dedup alone caps the list at the number of distinct ids. Rewritten against 600 distinct ids; it now fails with Not folded in, filed instead — both pre-existing, both in the recv loop rather than this tracker: #5277 ( Confirmed clean by both reviewers and re-verified: no double-release of flight size. One reviewer suggestion I did not take, flagging it as a judgement call: fixing #5277's arm ordering here. It is a separate defect with a different failure mode, and it touches the recv loop, so it deserves its own diff and its own review rather than riding along. Still open and worth a reviewer's opinion: whether this should land at all without the counter that would let us tell in production whether it helped. [AI-assisted - Claude] |
Problem
A retransmitted packet was never acknowledged, so a receipt that is destroyed outright could never be repaired by the retransmit it caused.
ReportResult::AlreadyReceiveddocuments that the packet "will be re-acknowledged but should otherwise be ignored" (received_packet_tracker.rs:141-143). The implementation did not do this: theOccupiedbranch returned the variant without queueing anything (:67), and the only caller logs attrace!andcontinues (peer_connection.rs:1119-1126).pending_receipts.pushexisted at exactly one site — theVacantbranch — so nothing could re-acknowledge.A lost receipt is normally recoverable, and an earlier version of this description wrongly said otherwise. Receipts ride on noop packets, but those go through
packet_sendingand are tracked and retransmitted like any other, so a single lost noop is repaired by its own resend.The hole is narrower than that, and real. Both flush sites in the recv loop call
get_receipts()— amem::take— and move the list intonoop():peer_connection.rs:1104-1115and:1477-1493. On a transient send failure those receipts are already gone — there is nothing left to retry with, and the log message says otherwise. Nothing regenerates them. The same applies when all 12 retransmits of the receipt-carrying noop are lost.When that happens the sender retransmits into silence and its packet stays pinned in flight. If the congestion window fills before the retransmit budget runs out, the stream aborts at
CWND_WAIT_TIMEOUT(3s) viadrop_stream, which purges the stream's packets from the resend queue — and the receiver sees a transfer that simply stopped. Answering the retransmit is the only signal that can repair it.(The stranding is bounded by the
Abandonpath from #4345, so "permanent" — also in an earlier version of this description — was wrong too. The harm is an aborted stream, not a permanently pinned window.)Evidence, and how far it actually goes
The receiver-side signature is real and common. From vega, 03:00–05:00 UTC across August: 1441 stream-assembly inactivity timeouts and 145 cwnd-wait aborts, e.g.
Stall points across those 1441 are spread throughout the transfer (2.4% at ≤2 fragments, 5.8% at 3–10, 21.7% at 11–60, 69.0% mid-to-late, 1.1% near-complete), which fits an any-time receipt-loss event and does not fit a fixed window cap.
What this evidence does not establish: an earlier version cited
flightsize == cwndon every cwnd-wait abort as the signature of a window that cannot drain. That is close to tautological — the log atoutbound_stream.rs:199-209only fires from inside a loop entered whenflightsize + packet_size > cwnd, so it prints those two values whatever the cause. It does not discriminate this mechanism from a slow peer or a congested reverse path.So: this closes a genuine hole that produces exactly this failure signature. It is not established that it is the dominant contributor to those 1441 timeouts. Nothing currently counts duplicate re-acks, so that cannot be measured today — a counter paired with the existing
record_stream_send_abort_cwnd()would settle it, and is worth doing before this is credited with any netcheck improvement.Approach
Queue the receipt in the
Occupiedbranch — what the enum's documented contract already promised.MAX_PENDING_RECEIPTS. That cap is load-bearing, not advisory:send_packet's receipt chunker splits an oversized list only once (split_offreturns the tail,peer_connection.rs:2390-2412), so a list longer than ~2×289 receipts serializes pastMAX_DATA_SIZE, and that error is not a transient send failure, so it kills the connection. TheVacantarm holds this line withQueueFull; this arm must not route around it, or a peer replaying old ids becomes a remote connection-kill. Dropping the re-ack at capacity is safe — the list is about to flush, and an unrepaired receipt draws another retransmit.AlreadyReceived, notQueueFull, so the caller can tell a duplicate from a fresh packet. Note this does not by itself guarantee the payload is skipped — the caller's(_, true)arm is matched before(AlreadyReceived, _)and wins whenever the 600ms receipt timer trips. That is pre-existing; an earlier version of this description asserted the opposite. Filed separately as fix(transport): duplicate packet's payload is re-processed when the receipt timer trips, wedging legacy stream reassembly #5277.Delivery is guaranteed by the unconditional drain on the background ACK tick (
peer_connection.rs:1476-1493), so worst-case latency is one tick rather than unbounded.Verified no double-release of flight size:
report_received_receiptsonly emits an ack-info tuple when the id is still inpending_receipts(sent_packet_tracker.rs:538), so a re-ack for an already-released packet produces nothing and cannot double-decrement. This is the invariant.claude/rules/transport.md:86-110governs and was the highest-risk interaction.Testing
Three tests, each verified by mutation to fail without the specific line it pins.
retransmitted_packet_is_reacknowledged_after_its_receipt_was_sent— the production sequence. The drain between the two reports is what makes it discriminating: without it the first report's receipt is still queued and the assertion passes regardless. That drain is the "receipt went out and was lost" step.repeated_retransmits_queue_one_receipt_per_flush— pins the dedup.reacknowledging_duplicates_cannot_grow_the_list_past_capacity— pins the bound. Written against 600 distinct replayed ids, deliberately: dedup alone caps the list at the number of distinct ids, so an earlier version of this test that replayed one id repeatedly passed with the bound removed and pinned nothing. With 600 it fails atgrew the receipt list to 600, past the 20 cap.Revert-and-run: removing
pending_receipts.pushfails exactly the first two (6 others pass); removing the length check fails exactly the third (8 others pass). So each test discriminates its own line rather than blanket-failing.transport::received_packet_tracker: 9 passedtransport::suite: 697 passed, 0 failedcargo fmt --checkclean;cargo clippyadds no new warningstest_report_receipt_already_receivedstill assertspending_receipts.len() == 1after a duplicate — the duplicate must not queue a second copy of an unsent receipt — with a comment saying which of the two things it pins.Not fixed here
Found while reviewing this, filed rather than folded in, since both are pre-existing and in the recv loop rather than this tracker:
(AlreadyReceived, true)falls through toprocess_inbound, re-delivering a duplicate payload, which can permanently wedge legacyInboundStreamreassembly.Scope
One contributing cause, not the whole netcheck red. That check's largest failure class is separate and not a transport problem at all: the network answers
NotFoundand the harness discards it, then reports a 120s timeout. Details on #5256 and #5271.[AI-assisted - Claude]