fix(transport): release flight-size on stream abort (#4345)#4393
Conversation
|
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #4393
Summary
- PR: fix(transport): release flight size on stream abort (#4345)
- Type: fix (transport / flight-size accounting / retransmit path)
- Review tier: Full (high-risk surface: concurrency + transport)
- Reviewers run:
freenet:code-first,freenet:testing,freenet:skeptical,freenet:big-picture+ external Codex (non-Claude) - CI: deterministic checks green; heavy jobs (NAT/Windows) were pending at review time — gate merge on green at the current HEAD.
This is a strong, well-tested implementation that faithfully follows the #4345 blueprint and is careful about lock ordering and the double-decrement hazard. The unit suite for drop_stream is genuinely exhaustive, and both headline regression tests were empirically verified fails-without/passes-with (a reviewer reverted each fix in isolation). It is not mergeable as-is because of one real concurrency defect that an external model and two perspectives independently found.
Must Fix (Blocking)
1. Resend re-registration can resurrect a packet that drop_stream already removed → double-release of flight size.
crates/core/src/transport/peer_connection.rs:1361 (and :1376) call report_sent_packet(idx, packet) after the tracker lock is released across send_to().await. report_sent_packet_inner (sent_packet_tracker.rs:~312) falls through to the insert branch when the id is absent and re-inserts the packet tagged Control. Interleaving (both tasks share the same Arc<Mutex<SentPacketTracker>>; the abort runs in the spawned send_stream/pipe_stream task, the resend in the recv loop):
- recv loop
get_resend()→Resend(idx,…), keeps the entry, drops the lock,awaitssend_to. - abort task
drop_stream(stream_id)removesidxandrelease_flightsizes its bytes. - recv loop resumes →
report_sent_packet(idx)→ insert branch → re-insertsidxas aControlzombie. - Later ACK/abandon of the zombie releases its bytes a second time (flight-size under-count) and violates the PR's own "in
pending_receiptsiff in flight" invariant.
This is new to this PR (drop_stream didn't exist before, so the insert-branch couldn't collide with a concurrent removal). Direction is safe (under-count → admits more sends, never re-pins; saturating_sub floors at 0), which is why it's not a stall regression — but it re-opens the exact double-decrement class the design is built to prevent, and the "released exactly once" comments at peer_connection.rs:1354-1360 are stated unconditionally and are false in the race.
Found independently by: external Codex [P1], skeptical [Med], big-picture [Med], and pre-review.
Fix: add a resurrection-safe refresh_sent_packet(idx, payload, token) for the recv-loop resend path that updates only if the entry is still present and is a no-op (drops the payload) if drop_stream/Abandon already removed it. Do not change report_sent_packet_with_token globally — control-packet first sends legitimately need the insert path. Add a regression test for the get_resend → drop_stream → report_sent_packet ordering (the mirror of the existing test_drop_stream_releases_packet_out_for_resend, which stops at the await and never models the re-registration step).
Should Fix (Important)
- Test the other 5 abort sites' release. Only the
send_streamcwnd-wait site assertsflightsize() == 0(outbound_stream.rs:1342). Thepipe_streamcwnd-wait/inactivity/upstream-error and both mid-send sites are tested for error-type only — deleting any of theirrelease_aborted_stream_flightsizecalls would fail no test. The controllers are already constructed in those tests; addingassert_eq!(controller.flightsize(), 0)is nearly free. - Guard the mid-send double-release invariant.
outbound_stream.rs:355-360/:724-729pairdrop_streamwith an explicitrelease_flightsize(packet_size), correct only becauseconfirm_receipt = vec![]forces the single-packet path where a failed send never registers. A future change attaching receipts to fragment sends (the existingsend_fragmentmulti-packet path) would silently create a double-release. Add adebug_assert/load-bearing comment that the failed fragment is absent from the tracker at the release site, plus a mid-send-failure test assertingflightsize() == 0exactly once. - Update the docs in this PR (repo rule: fix stale docs in the same PR).
.claude/rules/transport.md:86-104(flight-size release invariant) anddocs/architecture/transport/README.md:247-250list only ACK + abandon as release paths; add the newdrop_stream-on-stream-abort path so a future agent doesn't reintroduce a double-decrement. - Rebase before merge. Branch is 1 commit behind
origin/main; the "−608 lines"nat_subscription*deletion in the diff is a stale-base artifact (added to main by #4390 after the branch point), not a real removal. Per the per-content review rule, re-confirm CI green after rebase.
Consider
- Add a 2-stream simulation/unit test for the actual user-visible symptom (A aborts on cwnd-wait → B transfers on the same connection because flight size was released). The mechanism is unit-covered; this would encode the bug's purpose end-to-end.
- Rustdoc on
drop_stream: note the plaintext-in / on-wire-out flight-size skew is intentional and matches the ACK path (verified pre-existing + consistent), so nobody "fixes" one path and desyncs them. - One line at the abort sites:
release_flightsizedeliberately does not clearloss_pause_cwnd(only an ACK does); the symptom is still closed because flight size drops to ~0. StreamId100k-per-thread block overflow now also affects flight-size release (extends blast radius; practically unreachable).
Verified correct (skeptical + code-first)
on_timeout fires exactly once per RTO (distinct tracker vs controller methods); Karn's mark_retransmitted preserved; no duplicate resend_queue entries; total_packets_sent semantics change is benign (only a >0 gate); drop_stream sweeps all structures with ACK-after-drop / abandon-after-drop pinned as no-ops; lock never held across an await or the controller call; encrypted-vs-plaintext accounting is pre-existing and consistent; the modified test_packet_lost is a legitimate (stronger) semantics update, not a weakened assertion.
Verdict
Needs Changes — Re-review Required After Fix. One blocking concurrency defect (resurrection double-release, finding 1) plus Should-Fix test/doc gaps. The mechanism is otherwise sound and well-verified. HEAD reviewed: a3b2153e. Re-run the review on the fixed + rebased HEAD before merge (transport / concurrency = high-risk).
[AI-assisted - Claude]
a3b2153 to
6af7b9c
Compare
Stage-2 review fixes pushed (HEAD
|
Flight size is a single connection-wide counter, but SentPacketTracker's pending receipts are keyed only by PacketId with opaque encrypted payloads — nothing maps an in-flight packet's bytes back to the stream that owns them. So when an outbound stream aborts (e.g. a cwnd-wait timeout), its in-flight fragments keep pinning flight size until each one is ACKed or ages out via MAX_PACKET_RETRANSMITS (~6s). On a lossy/aborted stream that stranded credit starves every subsequent stream on the connection (issue #4345). This is stage 1 of the fix: a pure, isolated MECHANISM with no behaviour change and no wiring into the abort paths yet. - Tag each tracked packet with an owning stream via a new PacketStream enum (Stream(StreamId) | Control sentinel). A new packet_streams map holds the tag and, unlike pending_receipts, survives the get_resend pop so resend re-registration preserves a fragment's stream. The tag is dropped in lockstep with pending_receipts on ACK and on abandon. - Add report_sent_stream_packet for first sends of StreamFragments; report_sent_packet[_with_token] preserve an existing tag (resend re-registration) and otherwise default to Control. - Add SentPacketTracker::drop_stream(stream_id) -> u64 that removes a stream's packets from ALL tracking structures and returns the total bytes removed. Because the packets are gone from pending_receipts, no later ACK or abandon can release their bytes again — drop_stream is double-decrement-safe. - Thread the owning stream through packet_sending: the four StreamFragment send sites pass Stream(id); noop / short-message / handshake sites pass Control. Resend re-registration and handshake sends keep their existing Control-default entry points. drop_stream is #[allow(dead_code)] in this stage; it is wired into the outbound-stream abort paths in stage 2, after the double-decrement safety is reviewed in isolation. Refs #4345 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin the drop_stream mechanism and, above all, its no-double-decrement invariant, in isolation before any abort-path wiring: - exact byte total of the dropped stream's packets - ACK after drop_stream is a no-op (no second flight-size release) - abandon path after drop_stream is a no-op (dropped packets never resend) - interleaved streams A/B: drop_stream(A) leaves B's accounting intact - control (non-stream) packets are untouched by drop_stream - unknown / already-drained stream id -> 0, idempotent, no panic - boundaries: single packet, 500 packets, zero-length packet - sweeps a packet that lives in BOTH pending and the resend machinery (and asserts resend re-registration preserves the stream tag) - an already-abandoned packet is not re-released by a later drop_stream All run on VirtualTime via the existing mock_sent_packet_tracker harness. Refs #4345 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 2 of the #4345 flight-size fix: wire drop_stream into the outbound stream abort paths so an aborted stream's in-flight bytes are released immediately, and close the resend-gap race that wiring exposed. THE PROBLEM Flight size is a single connection-wide counter. When an outbound stream aborts (cwnd-wait timeout, upstream stall/error, mid-send failure) its already-sent, unacked fragments stay counted in flight size until each one is ACKed or ages out via MAX_PACKET_RETRANSMITS (~6s). FixedRate's loss-pause caps cwnd at the frozen flight size, so a single aborted stream starves every subsequent stream on the connection — the #4345 "cwnd wait timeout / no fragments received" failure for large multi-fragment GETs on lossy paths. THE RESEND-GAP RACE (the crux of stage-2 correctness) send_stream / pipe_stream run in spawned tasks and share the Arc<Mutex<SentPacketTracker>> with the per-connection recv loop. The recv loop's resend cycle released the tracker lock across the UDP send_to().await: get_resend removed the packet from pending_receipts, and the caller re-registered it only after the await. A drop_stream landing in that window saw no pending_receipts entry — released 0 bytes for a genuinely in-flight packet AND stripped its stream tag — leaving the fragment pinned in flight size (a partial defeat of the fix; safe direction, not a double-decrement). THE FIX - get_resend now KEEPS a resent packet in pending_receipts (clones the payload for the caller, refreshes its send-time in place, re-queues it) — the same keep-the-entry shape the TLP branch already used. The invariant becomes total: a packet is in pending_receipts iff it is in flight, so a concurrent drop_stream always sees and releases it. This does NOT touch flight size and does not reintroduce the forbidden decrement-on-RTO + re-add-on-resend pair (the congestion controller is untouched; this is pure tracker bookkeeping). - Resend re-registration via report_sent_packet is now an idempotent in-place refresh for an already-tracked packet (no duplicate resend-queue entry, no total_packets_sent bump, stream tag preserved). The recv loop is otherwise unchanged. - New release_aborted_stream_flightsize() helper calls drop_stream under the tracker lock, drops the lock, then release_flightsize(returned) on the congestion controller — mirroring the ACK path's lock ordering (tracker lock never held across the controller call, never across an await). Wired into all six stream-failing return sites in send_stream and pipe_stream. The two mid-send-failure sites additionally release the current fragment's on_send bytes, which were added to flight size just before a send that then failed so the packet was never tracked. - drop_stream loses its stage-1 #[allow(dead_code)] now that it is called. Drop_stream returns the encrypted tracker-payload byte sum, matching exactly what an ACK or Abandon for those same packets would have released — so the abort substitutes cleanly for the ACKs that will never come, consistent with the existing release accounting. TESTS - cwnd_wait_timeout_releases_stranded_flightsize: reproduces the core symptom — fragments fill flight size to ~cwnd, no ACKs, the next fragment's cwnd wait times out; asserts flight size returns to 0 on abort. Verified to FAIL without the wiring (flight size pinned at 2260B). - cwnd_wait_timeout_zero_inflight_is_noop: abort with nothing in flight → no-op. - test_drop_stream_releases_packet_out_for_resend: the exact resend-gap interleaving — Resend handed out, NOT re-registered, then drop_stream must still release the packet. - test_packet_lost updated to the new keep-the-entry-on-resend semantics. Part of #4345 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase (#4345) Addresses the stage-2 multi-model review. MUST-FIX (blocking) — resend re-registration could resurrect a drop_stream'd packet, double-releasing its flight-size bytes: 1. recv loop get_resend() returns Resend(idx,…), keeps the entry, releases the tracker lock, awaits send_to(). 2. the spawned abort task runs drop_stream(stream_id), removing idx and release_flightsize-ing its bytes. 3. recv loop resumes → report_sent_packet(idx) → report_sent_packet_inner finds idx ABSENT → INSERT branch → re-inserts idx tagged Control (a zombie). 4. a later ACK/abandon of the zombie releases its bytes a SECOND time (flight-size under-count) and breaks "in pending_receipts iff in flight". Fix: add SentPacketTracker::refresh_sent_packet(idx, payload, token) -> bool, which updates the entry ONLY IF still present and is a no-op (drops the payload, returns false) if drop_stream/Abandon already removed it. The recv loop's two resend re-registration sites now use it instead of report_sent_packet. The insert-capable report_sent_packet* are unchanged (control-packet first sends and test re-registration legitimately need insert). Corrected the now-false "released exactly once / flight size unchanged" recv-loop comments to "only when the packet is still tracked". Regression tests: - test_refresh_after_drop_stream_does_not_resurrect: the MIRROR of test_drop_stream_releases_packet_out_for_resend — re-register AFTER drop_stream and assert (a) idx is NOT resurrected, (b) a later ACK and a later abandon each release ZERO. Verified to FAIL with a resurrect-on-absent revert. - test_report_sent_packet_resurrects_dropped_packet_footgun: pins WHY the recv loop must not use report_sent_packet (documents the insert-resurrect footgun). SHOULD-FIX: - Added flightsize()==0 asserts after the task returns to the other 5 abort-site tests (pipe_stream cwnd-wait / inactivity / inbound-error + send_stream and pipe_stream mid-send failure), so deleting any release call now fails a test. Added pipe_stream_mid_send_failure_releases_flightsize. - The two mid-send double-release sites are correct only because confirm_receipt is empty (single-packet path → a failed send never registers). Added a debug_assert (via SentPacketTracker::contains_packet) at both sites that the failed fragment is absent from the tracker, guarding a future multi-packet wiring from silently creating a double-release. - Documented the drop_stream-on-stream-abort release path in .claude/rules/transport.md (flight-size release invariant) and docs/architecture/transport/README.md — both previously listed only ACK + abandon. Added the refresh_sent_packet-not-report_sent_packet rule. Part of #4345 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4345) Final stage-2 review polish (no behavior change): - drop_stream rustdoc: state explicitly that summing the ENCRYPTED stored payload length (not plaintext on_send bytes) is INTENTIONAL and matches the ACK and Abandon paths — all three release the same encrypted byte count for a given packet, so drop_stream releases exactly what those packets' ACK/Abandon would have. An external reviewer read this as an over-release bug; it is not. Note that the plaintext-add/encrypted-release asymmetry is a pre-existing convention across all three paths (tracked in #4402, out of scope here) and that a future change must not "fix" one path in isolation. - recv-loop refresh_sent_packet sites: make the discarded bool explicit. The success site now binds it and trace-logs the deliberate no-op-on-drop (a resurrection-safe path, not an error); the error site uses `let _ =` with a comment. Makes the intent obvious to readers. Part of #4345 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6af7b9c to
de2689f
Compare
Final review polish pushed (HEAD
|
|
I have all the information I need. Here is my review: Rule Review: No blocking issues; two minor style notesRules checked: transport.md, testing.md, code-style.md, git-workflow.md WarningsNone. Info
Summary: The fix correctly implements the three-release-path flight-size invariant (ACK / abandon / Rule review against |
…an scaffolding streaming-infrastructure.md had drifted from main on several counts and was framed as a phase-by-phase IMPLEMENTATION PLAN even though streaming has shipped and is active. This: - Removes the stale plan scaffolding: the Implementation Status (Phase 1-6) table and the Phase 5 (Capability Negotiation) / Phase 6 (Rollout) NOT-STARTED sections. Streaming is live and threshold-gated, so a capability-negotiation + shadow-rollout plan no longer reflects reality. De-'Phase N's the component headings so the file reads as a design reference, not a roadmap. - Drops the obsolete `streaming_enabled: bool` config (removed from production code); the sole gate is `streaming_threshold` (default 64KB) via `operations::should_use_streaming`. Documents that. - Corrects the constants (ORPHAN_STREAM_TIMEOUT / STREAM_CLAIM_TIMEOUT 30s/10s -> 60s), the buffer type (OnceLock<Bytes> -> AtomicPtr<Bytes>), and adds the flight-size-release-on-abort (#4345/#4393) and OutboundStreamFailed (#4374) sections, with Phase-4 handler citations pointing at the real op_ctx_task.rs drivers (not the enum-def modules). - README: drops the stale '(Phase 1)' tag from the streaming row. Documents already-merged #4345 / #4393 / #4374 / #1454 / #4307. No behavior change.
Problem
Large multi-fragment GETs on lossy paths intermittently fail (
stream assembly: no fragments received within inactivity timeout); the contract never caches anda follow-up SUBSCRIBE is then rejected (issue #4345).
The remaining root cause this PR fixes: flight-size accounting strands credit
when a stream aborts. Flight size is a single connection-wide counter. When an
outbound stream aborts (cwnd-wait timeout, upstream stall/error, or a mid-send
failure) its already-sent, unacked fragments stay counted in flight size until
each one is ACKed or ages out via
MAX_PACKET_RETRANSMITS(~6s). FixedRate'sloss-pause caps cwnd at the frozen flight size, so a single lossy/aborted stream
starves every subsequent stream on that connection.
Already merged (not redone here): #4353 (abandon never-ACKed packets), #4367
(op-layer retry), #4374 (a stalled stream fails the stream, not the connection).
This PR adds the atomic flight-size release on stream abort.
Approach
Built and reviewed in two stages on this branch; merged as one PR (shipping the
per-packet stream index without the abort wiring would add hot-path cost for no
benefit).
Stage 1 — mechanism (commits 1–2): tag each tracked packet with its owning
stream (
PacketStream::Stream(id)|Controlsentinel) via apacket_streamsmap, and add
SentPacketTracker::drop_stream(stream_id) -> u64that removes thestream's packets from ALL tracking structures and returns the exact byte total.
Removal from
pending_receiptsis what makes it double-decrement-safe: no laterACK or abandon can release those bytes again.
Stage 2 — wiring + the resend-gap race (commit 3):
send_stream/pipe_streamrun in spawned tasks andshare the
Arc<Mutex<SentPacketTracker>>with the per-connection recv loop.The recv loop's resend cycle released the tracker lock across the UDP
send_to().await:get_resendremoved the packet frompending_receiptsandthe caller re-registered it only after the await. A
drop_streamlanding inthat window saw no
pending_receiptsentry — released 0 bytes for a genuinelyin-flight packet and stripped its stream tag — leaving the fragment pinned
(partial defeat of the fix; safe direction, not a double-decrement).
Fix:
get_resendnow KEEPS a resent packet inpending_receipts(clonesthe payload, refreshes its send-time in place, re-queues it) — the same
keep-the-entry shape the TLP branch already used. The invariant becomes total:
a packet is in
pending_receiptsiff it is in flight, so a concurrentdrop_streamalways sees and releases it. This does NOT touch flight size anddoes not reintroduce the forbidden decrement-on-RTO + re-add-on-resend pair —
it is pure tracker bookkeeping. Resend re-registration becomes an idempotent
in-place refresh; the recv loop is otherwise unchanged.
release_aborted_stream_flightsize()helper callsdrop_streamunder the tracker lock, drops the lock, thenrelease_flightsize(returned)on the congestion controller — mirroring theACK path's lock ordering (tracker lock never held across the controller call
or across an await). Wired into all six stream-failing return sites in
send_stream/pipe_stream. The two mid-send-failure sites additionallyrelease the current fragment's
on_sendbytes (added just before a send thatthen failed, so the packet was never tracked).
drop_streamreturns the encrypted tracker-payload byte sum — exactly what anACK or Abandon for those same packets would have released — so the abort
substitutes cleanly for the ACKs that never come, consistent with the existing
release accounting.
Testing
cwnd_wait_timeout_releases_stranded_flightsize(integration): reproduces thecore symptom — fragments fill flight size to ~cwnd, no ACKs ever arrive, the
next fragment's cwnd wait times out; asserts flight size returns to 0 on
abort. Verified to FAIL without the wiring (flight size pinned at 2260B).
cwnd_wait_timeout_zero_inflight_is_noop(integration): abort with nothing inflight is a clean no-op.
test_drop_stream_releases_packet_out_for_resend(unit): the exact resend-gapinterleaving — Resend handed out, NOT re-registered, then
drop_streammuststill release the packet.
invariant (ACK / abandon / interleaved streams / control packets / boundaries).
test_packet_lostupdated to the new keep-the-entry-on-resend semantics.cargo fmt,cargo clippy -p freenet --tests -- -D warnings, andcargo test -p freenet --liball green (3035 lib tests pass; 654 transporttests pass; 0 failures, no new ignores).
Part of #4345 (the flight-size angle; #4345 tracks the multi-fragment-GET
symptom across several fixes, so this does not fully close it).
[AI-assisted - Claude]