Retransmit on a timer and close the connection at R2 - #92
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Three moderate retransmission and RTO issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This pull request adds timer-driven TCP retransmission, exponential RTO backoff, configurable R2 connection closure, and related tests.
Changes:
- Adds deadline-based retransmission handling.
- Retains unacknowledged segments and closes connections at R2.
- Adds paused-clock testing support.
File summaries
| File | Summary |
|---|---|
src/stream/tcp.rs |
Timer integration, R2 handling, and integration tests. Fast retransmissions are not included in R2 accounting (moderate, 1 vote). |
src/stream/tcb.rs |
Inflight tracking and RTO backoff. Shared timer state can become inconsistent, and MAX_RTO can override larger configured RTO values (moderate, 2 votes each). |
Cargo.toml |
Enables Tokio timer test utilities. |
Review details
Suppressed comments (2)
src/stream/tcb.rs:358
- This sweeps every entry whose elapsed time exceeds the shared RTO. With multiple writes outstanding, that retransmits later segments as well as the oldest one (and increments their R2 counters), whereas RFC 6298 §5.4 requires retransmitting only the earliest unacknowledged segment. Restrict expiry handling to the earliest outstanding entry/connection timer so later segments are not spuriously retransmitted.
for packet in self.inflight_packets.values_mut() {
if packet.is_timed_out(rto) {
packet.retransmit_count += 1;
packet.send_time = tokio::time::Instant::now();
exhausted |= packet.retransmit_count >= r2;
src/stream/tcp.rs:675
- The new R2 decision counts only timer-driven expiries because
retransmit_countis incremented solely insidecollect_timed_out_inflight_packets. The existingPacketType::RetransmissionRequestpath still sends an inflight segment directly without updating that count, so repeated duplicate-ACK/fast retransmissions can continue without ever reaching the R2 close condition. Route all retransmission mechanisms through shared accounting, or otherwise count fast retransmissions here as well.
let (packets, exhausted) = tcb.collect_timed_out_inflight_packets();
for packet in packets {
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| self.inflight_packets | ||
| .values() | ||
| .map(|p| p.send_time) | ||
| .min() | ||
| .map(|t| t + self.current_rto) |
| retransmit_list | ||
| } | ||
| if !retransmit_list.is_empty() { | ||
| self.current_rto = (self.current_rto * 2).min(MAX_RTO); // back off the timer, per RFC 6298 §5.5 |
|
Implemented from the Copilot review: every outstanding segment's timer restarts on expiry, and the backoff ceiling never drops below a configured timeout. |
The session loop waits on the earliest deadline its inflight queue holds and sweeps when it passes, so a lost segment with nothing behind it is retransmitted on time rather than when the peer's own timeout recovers it. A write that reopens an empty queue wakes the loop, since the deadline is read on entry and a later segment always sits behind one already waited on. The arrival path asks nothing about timeouts. The timeout belongs to the connection. It doubles on every expiry up to a minute, and an acknowledgement retiring a segment sent exactly once puts it back to the configured value: only that retirement says which transmission the acknowledgement answers. A segment stays in the inflight queue until it is acknowledged. Reaching R2 transmissions of the same segment closes the connection, per RFC 9293 §3.8.3, which at seven transmissions is 123 seconds against the at least 100 that SHLD-11 asks for.
…ng above the configured timeout An expiry restarts the timer for every outstanding segment, so no segment is retransmitted before the backed-off timeout has run from that expiry. The backoff is bounded by the larger of MAX_RTO and the configured timeout, so an expiry never shortens it.
…r the timeout at one second
e2ea921 to
41abf3a
Compare
| @@ -10,8 +10,15 @@ pub(super) const MAX_COUNT_FOR_DUP_ACK: usize = 3; // Maximum number of duplicat | |||
| /// Retransmission timeout | |||
| pub(super) const RTO: std::time::Duration = std::time::Duration::from_secs(1); | |||
There was a problem hiding this comment.
I believe that we can omit this one in favor of MIN_RTO.
| .map(|p| p.send_time) | ||
| .min() | ||
| .and_then(|t| t.checked_add(self.current_rto)) | ||
| } |
There was a problem hiding this comment.
inflight_packets is a BTreeMap ordered by SeqNum. Packets are always inserted in ascending sequence-number order with send_time = Instant::now(), so the first entry always holds the earliest send_time. A full .min() scan is unnecessary:
pub(crate) fn get_retransmission_deadline(&self) -> Option<tokio::time::Instant> {
self.inflight_packets
.values()
.next()
.and_then(|p| p.send_time.checked_add(self.current_rto))
}
Problem. A segment sent but never acknowledged is only noticed when the next packet arrives from the peer, because the timeout check sits on the arrival path. A transfer's last segments have nothing behind them by definition, so when one is dropped nothing wakes the connection and recovery waits on the peer's own timeout. Worse, after three attempts the segment is deleted from the send buffer while the connection stays open: those bytes are gone, the peer never acknowledges past them, and the connection is alive and permanently stuck.
Solution. The session loop waits on the earliest deadline its inflight queue holds and sweeps when it passes, so a lost segment is retransmitted on time with nothing arriving to prompt it. RFC 6298 §5.4 retransmits the earliest unacknowledged segment, §5.5 doubles the timeout on every expiry, and §2.5 permits a ceiling of at least 60 seconds — giving 1, 2, 4, 8, 16, 32, then 60. The timeout belongs to the connection rather than each segment, and returns to its configured value when a segment sent exactly once is acknowledged, since RFC 6298 §3 makes a sample from a retransmitted segment ambiguous.
A segment now stays in the queue until it is acknowledged. Reaching R2 transmissions closes the connection, per RFC 9293 §3.8.3(c), which at seven transmissions is 123 seconds against the at least 100 that SHLD-11 asks for. R2 is configurable through
TcpConfig, as MUST-21 requires.Tests. Five. Four in
tcp.rsdriving real handshakes on a paused clock, carrying the backoff schedule as a literal: a lost segment retransmitted with no arrival to prompt it; an unacknowledged segment closing the connection at R2 past 100 seconds; every retransmission carrying the original sequence number and payload; an acknowledgement stopping retransmission with a further segment still flowing. One intcb.rs: the backoff collapsing only on an acknowledgement of a segment sent exactly once.