tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs
Hi, A disclaimer before anything is owed: what you're about to read was written by Claude, and is the result of a bug fixing session that occurred on my personal home infrastructure. It was not intended as a bug hunt. Since it seems like this issue has been unreported and I couldn't find anything similar to it, and since what Claude reported seems potentially plausible, I'm putting it here. If AI assisted/generated bug reports are unwanted, I apologize. Original report -------------------------- I think a peer address can get stuck in pasta's low-RTT destination table and then lose window clamping for good. That caused every new TCP connection from our LAN router's address to stall on RTOs until pasta was restarted. Details below. I haven't found an existing report on Bugzilla or this list. Version: pasta 2026_10_02.cba3570-3-g8ba4132 (the logic below is unchanged in current master) Setup: rootless Docker 29.6.1 via rootlesskit, --net=pasta, --port-driver=implicit, --mtu=65520 (pasta --tcp-ports=auto --udp-ports=auto --no-map-gw --ipv4-only ...) Kernel: 6.18.57 (Arch linux-lts), x86_64 What we think is happening -------------------------- We suspect the low-RTT destination table (low_rtt_dst[], tcp_rtt_dst_check() / tcp_rtt_dst_low()). Our LAN router's address, 10.0.0.1, ended up in it even though the router is several milliseconds away. For a peer in that table, tcp_update_seqack_wnd() advertises the host socket's tcpi_snd_wnd to the guest instead of tcp_wnd_from_sndbuf(). The guest then sends more than the host socket can queue, pasta discards the excess, and the guest only recovers through RTOs. Entries are never removed while pasta runs, so one bad insertion degraded every connection from that address for days, until pasta was restarted. Network layout (all private addresses) -------------------------------------- - LAN 10.0.0.0/8. Host: 10.0.0.181 (DHCP) and 10.0.0.80/32. Default gateway: the router, 10.0.0.1, 2-6 ms away over Wi-Fi. - An nginx container's port is published by pasta on 10.0.0.80:4444. - The router forwards WAN 443 to 10.0.0.80:4444 with NAT reflection, so LAN clients using the public name reach the host with source address 10.0.0.1. To pasta, every LAN client is the same peer, 10.0.0.1. - A desktop at 10.0.0.187 can also connect straight to 10.0.0.80:4444 (no NAT, its own address). - A monitoring container opens an HTTPS connection to https://10.0.0.1/ every minute (outbound through pasta), so pasta also sees regular outbound flows to 10.0.0.1. Symptom ------- The first large response on every new connection from 10.0.0.1 stalled for 1.5-3.2 s (3-4 RTOs with exponential backoff). Later responses on the same connection were fine. Connections from 10.0.0.187 to the same port and container never stalled. Test: one connection fetching a 520 KB static file 25 times, counting TCPTimeouts in the container's network namespace. - peer 10.0.0.1, LAN client through NAT reflection: +4 timeouts and one ~3.1 s fetch, every run - peer 10.0.0.1, the router itself, direct, no NAT: 11 of 13 runs stalled the same way - peer 10.0.0.187, desktop, direct: 0 timeouts in every run So the trigger is the peer address, not the NAT path. ss -ti on both legs during one transfer: peer 10.0.0.1 (stalls) 10.0.0.187 (fine) host socket (pasta) retransmits 0 0 tcpi_snd_wnd 252928 146432 container socket snd_wnd 251904 1280 rwnd_limited 7 % 80 % retransmits 186 (backoff 4) 1 transfer time 3.2 s 0.26 s For 10.0.0.1 the window pasta advertises to the container is the host socket's tcpi_snd_wnd, shifted by the window scale (252928 -> 251904). That is the table branch in tcp_update_seqack_wnd(). For 10.0.0.187 it is clamped to buffer space. The host-side socket never retransmits, and the tap device's drop counter does not move. The segments the container retransmits are ones pasta took off the tap and discarded. After restarting pasta (by restarting the rootless Docker daemon), the same tests from 10.0.0.1 gave: snd_wnd 1280, rwnd_limited 73 %, 0 retransmissions, 0 timeouts, about 0.06 s per fetch. Nothing else changed. We could not read the table directly (no ptrace into pasta), so "10.0.0.1 is in the table" is inferred from this behaviour and the code path. Why we think 10.0.0.1 got into the table ---------------------------------------- A real RTT of 10 us or less to the router is impossible here (measured RTT 2-6 ms). Our suspicion comes from reading the kernel source; we have not caught it on a live socket: - tcp_rtt_dst_check() tests (int)tinfo->tcpi_min_rtt > LOW_RTT_THRESHOLD. - The kernel initialises rtt_min to ~0U, both in tcp_init_sock() (net/ipv4/tcp.c) and for accepted sockets in tcp_create_openreq_child() (tcp_minisocks.c). tcpi_min_rtt stays ~0U until the first valid RTT sample. - tcp_synack_rtt_meas() takes no sample when the SYN-ACK was retransmitted (rtt_us = -1 unless !req->num_retrans). The same applies on the active side when the SYN was retransmitted (Karn). - Cast to int, ~0U becomes -1, which passes the "<= 10 us" test. - pasta runs this check in tcp_prepare_flags() when it sends the SYN (inbound) or SYN-ACK (outbound) to the guest. That is right after the host-side handshake, before any data ACK could provide a sample. We measured exactly such handshake retransmissions on this Wi-Fi/NAT path: 6 of 1,800 fresh connections took ~1.05 s, i.e. one 1 s retry. With hundreds of inbound and outbound connections to 10.0.0.1 per day, one retried handshake would be enough to insert it permanently. The night the stalls became severe, the published container was also restarted about ten times while clients kept connecting, which made unusual handshakes more likely. We cannot tell exactly when the insertion happened. Suggested fixes --------------- 1. In tcp_rtt_dst_check(), treat tcpi_min_rtt == ~0U (and 0) as "no sample" and skip the insertion. Compare as unsigned, or check tcpi_rtt / tcpi_rcv_rtt as well. 2. Don't trust a single sample forever: give entries a lifetime, or evict an address when a later connection to it shows a min RTT above the threshold. 3. Log (at debug level) when an address enters the table. It was invisible here; the only visible signs were retransmissions inside the namespace. Workaround: restarting pasta clears the table. Happy to test a patch or collect more data. Thanks, Lynxes
On Wed, Oct 28, 2026 at 02:15:59PM +0100, Lynxes Exe wrote:
Hi,
A disclaimer before anything is owed: what you're about to read was written by Claude, and is the result of a bug fixing session that occurred on my personal home infrastructure. It was not intended as a bug hunt. Since it seems like this issue has been unreported and I couldn't find anything similar to it, and since what Claude reported seems potentially plausible, I'm putting it here. If AI assisted/generated bug reports are unwanted, I apologize.
Well, I can't speak for Stefano, but I strongly suspect he'll agree that one phrased like this is much more welcome than the sort which confidently and incorrectly claims the sky is falling and implies the mythical passt security team should be right this instant be conveying the report to the CVE bunker under armed guard :). -- David Gibson (he or they) | I'll have my music baroque, and my code david AT gibson.dropbear.id.au | minimalist, thank you, not the other way | around. http://www.ozlabs.org/~dgibson
participants (2)
-
David Gibson
-
Lynxes Exe