Skip to content

Retransmit on a timer and close the connection at R2 - #92

Merged
SajjadPourali merged 4 commits into
narrowlink:mainfrom
IntellyCode:retransmission
Sep 17, 2026
Merged

SajjadPourali merged 4 commits into
narrowlink:mainfrom
IntellyCode:retransmission

Conversation

@IntellyCode

Copy link
Copy Markdown
Contributor

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.rs driving 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 in tcb.rs: the backoff collapsing only on an acknowledgement of a segment sent exactly once.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_count is incremented solely inside collect_timed_out_inflight_packets. The existing PacketType::RetransmissionRequest path 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.

Comment thread src/stream/tcb.rs Outdated
Comment on lines +339 to +343
self.inflight_packets
.values()
.map(|p| p.send_time)
.min()
.map(|t| t + self.current_rto)
Comment thread src/stream/tcb.rs Outdated
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
@IntellyCode

Copy link
Copy Markdown
Contributor Author

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.
Comment thread src/stream/tcb.rs
@@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that we can omit this one in favor of MIN_RTO.

Comment thread src/stream/tcb.rs
.map(|p| p.send_time)
.min()
.and_then(|t| t.checked_add(self.current_rto))
}

@SajjadPourali SajjadPourali Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
}

@SajjadPourali
SajjadPourali merged commit 7cf41a6 into narrowlink:main Sep 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants