Skip to content

Answer out-of-order segments with a duplicate ACK, keep straddling retransmissions, and keep segments inside the peer's window - #97

Open
WYCLIFF001 wants to merge 3 commits into
narrowlink:mainfrom
WYCLIFF001:fix/out-of-order-psh-and-send-window
Open

WYCLIFF001 wants to merge 3 commits into
narrowlink:mainfrom
WYCLIFF001:fix/out-of-order-psh-and-send-window

Conversation

@WYCLIFF001

Copy link
Copy Markdown

Three receive- and send-path fixes found while running ipstack as the TUN side of a VPN client under speedtest load (Linux peers, 40 ms to 100 ms RTT, 200 Mbit/s shaped link and unshaped loopback). Each commit carries its own test, which fails on main and passes with the change (the third reuses test helpers the first adds).

1. Answer an out-of-order segment with a duplicate ACK

extract_data_n_write_upstream sends an ACK only when it hands new in-order data to the reader, or when the handoff channel is full. A segment that fills no gap (out of order, or already received) produces no ACK at all, so the peer never sees the duplicate ACKs RFC 5681 §4.2 asks for and cannot fast-retransmit. A single lost segment is then recovered only by the peer's retransmission timer, with backoff on each further loss.

Separately, in Established a segment flagged ACK|PSH is accepted only when seq == ack; out of order it is dropped silently, with no ACK. Linux sets PSH on the last segment of most writes, so this path is common.

The five data-arrival sites now go through one receive_segment, which stores the segment and sends an ACK if extraction did not already send one. The ACK|PSH arm stores out-of-order data like the plain-ACK arm. extract_data_n_write_upstream returns whether it wrote an ACK; the reader-drain path ignores it, so its behaviour is unchanged.

Test: out_of_order_segment_is_answered_with_a_duplicate_ack (both ACK and ACK|PSH). On main the out-of-order segment gets no ACK (left: [] right: [1001]).

2. Keep the new part of a retransmission that straddles the ack

add_unordered_packet drops any segment with seq < ack whole, including one that extends past ack. A peer that missed an ACK and re-segments its retransmission sends exactly that shape, and the new bytes were lost with the old. The segment is now trimmed to the part past ack (RFC 9293 §3.10.7.4); one lying entirely below ack is still dropped, with the existing warning.

Test: test_add_unordered_packet_keeps_the_new_part_of_a_straddling_segment. On main the new 50 bytes are gone (left: None).

3. Keep each segment inside the peer's window

poll_write parks only once the bytes in flight have reached the window (is_send_buffer_full), then sends a full segment. When the room left is smaller than a segment, the segment runs past the window's right edge (RFC 9293 §3.8.6.1). A segment now carries at most min(room left, MTU), via a new Tcb::send_room.

The same change stops poll_write copying the caller's entire buffer (buf.to_vec()) for every segment it sends. Only the bytes the segment carries are copied. Before, a 64 KiB write produced 45 segments, each copying the whole remaining tail: about 1.5 MB copied to send 64 KiB.

Test: segments_stay_inside_the_peers_window. On main a 4000-byte write into a 3000-byte window sends [1460, 1460, 1080].

Checks

cargo fmt --check, cargo clippy --all-targets (no warnings) and cargo test all pass: 32 existing tests plus 3 new ones.

Not included (open to discussion)

  • Duplicate ACK and window changes. check_pkt_type requires an unchanged window before counting an ACK as a duplicate, which matches RFC 5681 §2 (and Linux's own sender). I first thought this was a bug and it is not, so it is left as is.
  • MSS option by default. With TcpConfig::options == None the SYN-ACK carries no MSS, and Linux peers then send 536-byte segments (RFC 9293 §3.7.1 default) even on a 1500 or 9000 MTU device. Deriving a default MSS from the device MTU would cut the number of upload segments by about 2.5× at MTU 1400. It changes defaults, so I would rather ask first.
  • ACK coalescing. Every in-order segment is answered by its own ACK, one device write(2) each. Holding the ACK while more packets for the stream are already queued, up to every second full-sized segment as RFC 9293 §3.8.6.3 allows, measured about +50% single-stream upload throughput in our setup. It is a behaviour change, so it is a proposal rather than part of this PR.

🤖 Generated with Claude Code

A data segment is now always acknowledged. One that fills no gap, because
it arrived out of order or was already received, used to leave nothing
for the reader and so produced no ACK at all, which left the peer's
retransmission timer as the only way past a lost segment. RFC 5681 §4.2
asks for an immediate duplicate ACK instead, and three of them are what
drive the peer's fast retransmit.

A segment carrying PSH is handled like any other: out of order, it used
to be dropped without an ACK, so the peer resent it on its timer and
backed off with every loss.
A segment starting below the ack was dropped whole, even when it ran past
it. That happens when the peer re-segments a retransmission after an ACK
it missed: the bytes past the ack were then lost with the rest, and the
peer had to send them yet again. The segment is now trimmed to the part
that is new (RFC 9293 §3.10.7.4); one lying entirely below the ack is
still dropped as a duplicate.
A write was held only once the data in flight had reached the peer's
window, and was then sent as a whole segment, so the last segment before
the window filled could run up to a segment past it. RFC 9293 §3.8.6.1
keeps what is in flight within the window. A segment now carries no more
than the room the window has left.

The write also copies only the bytes the segment carries. It used to copy
the caller's whole buffer and send one segment of it, so a large write
was copied again for every segment it produced.
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.

1 participant