* tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs
@ 2026-10-28 13:15 Lynxes Exe
2026-10-29 1:11 ` David Gibson
2026-11-04 13:53 ` Stefano Brivio
0 siblings, 2 replies; 3+ messages in thread
From: Lynxes Exe @ 2026-10-28 13:15 UTC (permalink / raw)
To: passt-dev
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
^ permalink raw reply [flat|nested] 3+ messages in thread* Re: tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs
2026-10-28 13:15 tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs Lynxes Exe
@ 2026-10-29 1:11 ` David Gibson
2026-11-04 13:53 ` Stefano Brivio
1 sibling, 0 replies; 3+ messages in thread
From: David Gibson @ 2026-10-29 1:11 UTC (permalink / raw)
To: Lynxes Exe; +Cc: passt-dev
[-- Attachment #1: Type: text/plain, Size: 1080 bytes --]
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
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 833 bytes --]
^ permalink raw reply [flat|nested] 3+ messages in thread
* Re: tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs
2026-10-28 13:15 tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs Lynxes Exe
2026-10-29 1:11 ` David Gibson
@ 2026-11-04 13:53 ` Stefano Brivio
1 sibling, 0 replies; 3+ messages in thread
From: Stefano Brivio @ 2026-11-04 13:53 UTC (permalink / raw)
To: Lynxes Exe; +Cc: passt-dev
Hi Lynxes,
Thanks for the report, and sorry for the delay. Some comments inline:
On Wed, 28 Oct 2026 14:15:59 +0100
Lynxes Exe <lynxes.exe@gmail.com> 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.
At least as far as I'm concerned, in term of bugs reports, whatever
makes sense and is usable is welcome. This report has a few dubious
aspects (see below) but it might still be valuable, I think.
> Original report
> --------------------------
> I think a peer address can get stuck in pasta's low-RTT destination
> table and then lose window clamping for good.
This part wasn't clearly demonstrated.
> That caused every new
> TCP connection from our LAN router's address to stall on RTOs until
> pasta was restarted. Details below.
This looks plausible and somewhat demonstrated.
> I haven't found an existing report on Bugzilla or this list.
Correct, no known issues in this sense so far.
> 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.
Nothing clearly confirms the "ended up in it" part (yet).
> [...]
>
> 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.
...I guess the problem here is training a language model about the
difference between "inferred" and "suspected". :)
One could ptrace() as root by the way, but that wouldn't help either.
Could you make sure about this with a print in tcp_rtt_dst_check()? If
that's not the case, you might need to further trace what's going on in
tcp_update_seqack_wnd() instead.
Rebuilding and using a modified version of passt is quick (it just
depends on a C library):
git clone git://passt.top/passt && cd passt
[edit as needed]
make && sudo make install # Or make install as root
Both Podman and Docker / rootlesskit will pick up the local build under
/usr/local/bin. You can cleanly remove it with 'make uninstall' as root
once you're done.
> 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.
...maybe, yes.
> 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.
I'm not sure if we actually risk hitting 0: strictly speaking that
would mean less than 1 µs which looks unlikely (even for simple veth
pairs or local guests), but in practice there might be rounding errors
somewhere.
Do we need to skip 0? From a quick glance of the kernel paths involved
I'd say we don't.
Could you test this perhaps? Not build tested but I think you could
modify the initial early return condition to:
if (!min_rtt_cap ||
(int)tinfo->tcpi_min_rtt > LOW_RTT_THRESHOLD ||
tinfo->tcpi_min_rtt == ~0U /* not measured yet */)
return;
> 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.
Reasonable. I guess we could add a timestamp and check if the entry
expired (minutes?) where we update the RTT in
tcp_update_seqack_wnd()... or even in tcp_rtt_dst_check(). No strong
preference on my side, I didn't really think it through.
> 3. Log (at debug level) when an address enters the table. It was
> invisible here; the only visible signs were retransmissions
> inside the namespace.
Yeah, that would be good. I added this feature before we started
logging anything, I think. Feel free to send a patch for that.
> Workaround: restarting pasta clears the table.
>
> Happy to test a patch or collect more data.
Let me know if you can check those few things I suggested, thanks.
--
Stefano
^ permalink raw reply [flat|nested] 3+ messages in thread
end of thread, other threads:[~2026-11-04 13:53 UTC | newest]
Thread overview: 3+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-28 13:15 tcp: peer stuck in low_rtt_dst table loses window clamping, every new connection stalls on RTOs Lynxes Exe
2026-10-29 1:11 ` David Gibson
2026-11-04 13:53 ` Stefano Brivio
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).