From: Lynxes Exe <lynxes.exe@gmail.com>
To: passt-dev@passt.top
Subject: tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs
Date: Wed, 28 Oct 2026 14:15:59 +0100 [thread overview]
Message-ID: <CAJPNJUuUYSkW776P0hKmweHnKOqXtsXqwm4HXP-+jorXrSfFFg@mail.gmail.com> (raw)
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
reply other threads:[~2026-10-28 13:16 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=CAJPNJUuUYSkW776P0hKmweHnKOqXtsXqwm4HXP-+jorXrSfFFg@mail.gmail.com \
--to=lynxes.exe@gmail.com \
--cc=passt-dev@passt.top \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
Code repositories for project(s) associated with this public inbox
https://passt.top/passt
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for IMAP folder(s).