Skip to content

Commit cc74c38

Browse files
ccie18643claude
andcommitted
feat(tcp): receiver-side RTT estimator for DRS (autotune step 3 / R1)
Third phase of the Tier-3 buffer auto-tuning plan (docs/refactor/tcp_buffer_autotuning.md §4 R1 / §10 step 3): add the receiver-side RTT estimator that receive-buffer Dynamic Right-Sizing (Track R, still to land in step 4) needs for its per-RTT measurement cadence. Why a new estimator: the RFC 6298 sender SRTT only gets samples when our own data is acked, so on a pure download (we send only ACKs) it stays None and DRS could never fire. Linux solves this with a separate rcv_rtt_est fed by tcp_rcv_rtt_measure_ts: an inbound data segment whose TSecr echoes a timestamp we sent one round trip ago yields rtt = now - TSecr, valid even for a pure receiver. - New RcvRttState (state/tcp__state__rcv_rtt.py): rtt_ms (alpha = 1/8 EWMA, None until first sample) + last_tsecr for the within-RTT dedup. observe() seeds on the first sample, folds via (7*old + sample)//8 after, and no-ops on a repeated TSecr so a burst within one round trip contributes a single sample (Linux dedups on rcv_rtt_last_tsecr). - Instantiated as self._rcv_rtt in TcpSession.__init__. - Hook in tcp__session__ack.py phase 5: on a new-data segment with send_ts and a truthy TSecr, sample (now_ms - TSecr) & 0xFFFFFFFF (the same formula the phase-3 sender sample uses) and fold it in. Timestamps-gated for the first pass; the no-TS tcp_rcv_rtt_measure fallback stays deferred (plan §7). No consumer yet — step 4 (R2-R4) reads _rcv_rtt.rtt_ms to gate the DRS grow cadence. No behaviour change. Tests-first: test__tcp__state__rcv_rtt.py (unit — defaults, first-sample seed, EWMA fold, TSecr dedup) + test__tcp__session__rcv_rtt.py (integration — RX-path seed/fold/dedup, no-TS leaves it unset). RED pre-impl: ModuleNotFoundError + AttributeError (_rcv_rtt absent) → 8 green post-impl. Plan §4 / §10 step 3 marked done. Reference: RFC 7323 §4 (RTTM via TSecr). Reference: Linux tcp_rcv_rtt_measure_ts / rcv_rtt_est. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 98758d2 commit cc74c38

6 files changed

Lines changed: 395 additions & 1 deletion

File tree

docs/refactor/tcp_buffer_autotuning.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ once per receiver-RTT, when the app has just drained the rx buffer:
166166

167167
### R1 — receiver-side RTT estimator (the prerequisite, medium)
168168

169+
**DONE.** `RcvRttState` (`state/tcp__state__rcv_rtt.py`, `@dataclass(slots=True)`):
170+
`rtt_ms: int | None` (α=1/8 EWMA, `None` until first sample) + `last_tsecr`
171+
for the within-RTT dedup; `observe(sample_ms, tsecr)` seeds on the first
172+
sample, folds via `(7*old + sample)//8` after, and no-ops on a repeated
173+
TSecr. Instantiated `self._rcv_rtt` in `TcpSession.__init__`. Hook: phase 5
174+
of `tcp__session__ack.py`, on a new-data segment with `send_ts` and a
175+
truthy `tcp__tsecr`, sample `(stack.timer.now_ms - tcp__tsecr) & 0xFFFF_FFFF`
176+
— the same formula the phase-3 *sender* sample uses, but fired on inbound
177+
data so a pure receiver (ACK-only) still gets an RTT. Timestamps-gated
178+
(the no-TS `tcp_rcv_rtt_measure` fallback stays deferred, §7). Tests:
179+
`test__tcp__state__rcv_rtt.py` (unit — EWMA/dedup) +
180+
`test__tcp__session__rcv_rtt.py` (integration — RX-path seed/fold/dedup +
181+
no-TS leaves it unset). The DRS reader (R3/R4) consumes `_rcv_rtt.rtt_ms`.
182+
183+
Original plan text:
184+
169185
**Why it's needed:** DRS gates its once-per-RTT cadence on a *receiver*
170186
RTT. On a bulk download PyTCP only sends bare ACKs, so `_rto_state.srtt_ms`
171187
never gets a sample (no acked data of ours) — it stays `None` and DRS
@@ -493,7 +509,9 @@ CLAUDE.md "Linux as tiebreaker" precedence.
493509
fully observable once step 5 lands the small default. Tests:
494510
`test__tcp__session__sndbuf_autotune.py`.
495511
3. **R1** (receiver RTT estimator) — the DRS prerequisite; self-contained,
496-
unit-testable in isolation.
512+
unit-testable in isolation. **DONE**`RcvRttState` +
513+
`tcp__session__ack.py` phase-5 TSecr hook; timestamps-gated. Tests:
514+
`test__tcp__state__rcv_rtt.py`, `test__tcp__session__rcv_rtt.py`.
497515
4. **R2 + R3 + R4** (DRS struct, trigger, grow policy) — the large piece;
498516
lands on top of R1.
499517
5. **The small-default switch** (§2 / §7) — flip the unset `SO_SNDBUF`

packages/pytcp/pytcp/protocols/tcp/session/tcp__session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from pytcp.protocols.tcp.state.tcp__state__keepalive import KeepaliveState
6363
from pytcp.protocols.tcp.state.tcp__state__persist import PersistState
6464
from pytcp.protocols.tcp.state.tcp__state__rack_tlp import RackTlpState
65+
from pytcp.protocols.tcp.state.tcp__state__rcv_rtt import RcvRttState
6566
from pytcp.protocols.tcp.state.tcp__state__recv_seq import RecvSeqState
6667
from pytcp.protocols.tcp.state.tcp__state__rtt_sample import RttSampleState
6768
from pytcp.protocols.tcp.state.tcp__state__send_seq import SendSeqState
@@ -339,6 +340,10 @@ def __init__(
339340
# §5.7 idle-baseline 'last_send_time_ms'. See
340341
# 'state/tcp__state__rtt_sample.py'.
341342
self._rtt: RttSampleState = RttSampleState()
343+
# RFC 7323 §4 receiver-side RTT estimator (Tier-3 Track R DRS
344+
# cadence). Fed from the inbound TSecr echo, so it produces an
345+
# RTT even on a pure receiver. See 'state/tcp__state__rcv_rtt.py'.
346+
self._rcv_rtt: RcvRttState = RcvRttState()
342347
self._retransmit_count: int = 0
343348
# RFC 6298 §5.7 second-clause SYN-retransmit counter.
344349
# Decoupled from '_retransmit_count' (which

packages/pytcp/pytcp/protocols/tcp/session/tcp__session__ack.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,19 @@ def _phase5_consume_segment_and_postprocess(self, packet_rx_md: TcpMetadata) ->
715715
if packet_rx_md.tcp__data and overlap_prefix < len(packet_rx_md.tcp__data):
716716
new_data = packet_rx_md.tcp__data[overlap_prefix:]
717717
session._enqueue_rx_buffer(new_data)
718+
# RFC 7323 §4 / Linux 'tcp_rcv_rtt_measure_ts': sample a
719+
# RECEIVER-side RTT from the echoed TSecr on this new-data
720+
# segment. Unlike the phase-3 sender sample (which needs our
721+
# data to be acked), this fires on a pure download where we
722+
# only send ACKs — the DRS cadence gate (Tier-3 Track R)
723+
# needs it. Deduped on TSecr inside 'observe' so a burst
724+
# within one RTT yields a single sample. A truthy 'tcp__tsecr'
725+
# covers both the None and the no-echo 0 cases.
726+
if session._ts.send_ts and packet_rx_md.tcp__tsecr:
727+
session._rcv_rtt.observe(
728+
sample_ms=(stack.timer.now_ms - packet_rx_md.tcp__tsecr) & 0xFFFF_FFFF,
729+
tsecr=packet_rx_md.tcp__tsecr,
730+
)
718731
__debug__ and log(
719732
"tcp-ss",
720733
f"[{session}] - Enqueued {len(new_data)} bytes starting at "
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
################################################################################
2+
## ##
3+
## PyTCP - Python TCP/IP stack ##
4+
## Copyright (C) 2020-present Sebastian Majewski ##
5+
## ##
6+
## This program is free software: you can redistribute it and/or modify ##
7+
## it under the terms of the GNU General Public License as published by ##
8+
## the Free Software Foundation, either version 3 of the License, or ##
9+
## (at your option) any later version. ##
10+
## ##
11+
## This program is distributed in the hope that it will be useful, ##
12+
## but WITHOUT ANY WARRANTY; without even the implied warranty of ##
13+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ##
14+
## GNU General Public License for more details. ##
15+
## ##
16+
## You should have received a copy of the GNU General Public License ##
17+
## along with this program. If not, see <https://www.gnu.org/licenses/>. ##
18+
## ##
19+
## Author's email: ccie18643@gmail.com ##
20+
## Github repository: https://github.com/ccie18643/PyTCP ##
21+
## ##
22+
################################################################################
23+
24+
25+
"""
26+
This module contains the per-session receiver-side RTT estimator state
27+
container. Unlike the RFC 6298 sender SRTT (which needs our data to be
28+
acked), this is fed from the RFC 7323 TSecr echo on inbound data
29+
segments, so it produces an RTT measurement even on a pure receiver
30+
that only sends ACKs — the round-trip cadence the Tier-3 receive-buffer
31+
Dynamic Right-Sizing (Track R) gate depends on (Linux 'rcv_rtt_est' /
32+
'tcp_rcv_rtt_measure_ts').
33+
34+
pytcp/protocols/tcp/state/tcp__state__rcv_rtt.py
35+
36+
ver 3.0.8
37+
"""
38+
39+
from dataclasses import dataclass
40+
41+
42+
@dataclass(slots=True)
43+
class RcvRttState:
44+
"""
45+
Per-session receiver-side RTT estimator. Owned by 'TcpSession';
46+
written on the RX/ACK thread from the inbound-data TSecr echo and
47+
read by the DRS measurement (app thread). Timestamps-gated: only
48+
fed while bilateral RFC 7323 TSopt is active.
49+
"""
50+
51+
# Smoothed receiver RTT (ms), 'None' until the first sample —
52+
# DRS treats 'None' as "no cadence yet" and skips its adjust.
53+
rtt_ms: int | None = None
54+
55+
# The most recently sampled TSecr. A segment echoing the same
56+
# TSecr is a within-RTT duplicate and contributes no new sample,
57+
# so a burst does not overweight the estimate (Linux dedups on
58+
# 'rcv_rtt_last_tsecr').
59+
last_tsecr: int | None = None
60+
61+
def observe(self, *, sample_ms: int, tsecr: int) -> None:
62+
"""
63+
Fold a receiver RTT sample ('now_ms - TSecr') into the
64+
smoothed estimate. Deduplicated on 'tsecr' so at most one
65+
sample lands per round trip. The first sample seeds the
66+
estimate directly; subsequent samples fold via the
67+
alpha = 1/8 EWMA '(7 * old + sample) // 8' (the RFC 6298 SRTT
68+
smoothing, matching Linux 'tcp_rcv_rtt_update' win_dep=0).
69+
"""
70+
71+
if tsecr == self.last_tsecr:
72+
return
73+
self.last_tsecr = tsecr
74+
if self.rtt_ms is None:
75+
self.rtt_ms = sample_ms
76+
else:
77+
self.rtt_ms = (7 * self.rtt_ms + sample_ms) // 8
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
################################################################################
2+
## ##
3+
## PyTCP - Python TCP/IP stack ##
4+
## Copyright (C) 2020-present Sebastian Majewski ##
5+
## ##
6+
## This program is free software: you can redistribute it and/or modify ##
7+
## it under the terms of the GNU General Public License as published by ##
8+
## the Free Software Foundation, either version 3 of the License, or ##
9+
## (at your option) any later version. ##
10+
## ##
11+
## This program is distributed in the hope that it will be useful, ##
12+
## but WITHOUT ANY WARRANTY; without even the implied warranty of ##
13+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ##
14+
## GNU General Public License for more details. ##
15+
## ##
16+
## You should have received a copy of the GNU General Public License ##
17+
## along with this program. If not, see <https://www.gnu.org/licenses/>. ##
18+
## ##
19+
## Author's email: ccie18643@gmail.com ##
20+
## Github repository: https://github.com/ccie18643/PyTCP ##
21+
## ##
22+
################################################################################
23+
24+
25+
# pylint: disable=protected-access
26+
# pyright: reportPrivateUsage=false
27+
28+
29+
"""
30+
Integration tests for the receiver-side RTT estimator (Tier-3 Track R,
31+
R1). On an inbound new-data segment carrying a valid RFC 7323 TSecr, the
32+
session folds 'now_ms - TSecr' into '_rcv_rtt' — the RTT measurement DRS
33+
needs even on a pure receiver (which only sends ACKs, so the sender SRTT
34+
never gets a sample). Timestamps-gated for the first pass.
35+
36+
pytcp/tests/integration/protocols/tcp/test__tcp__session__rcv_rtt.py
37+
38+
ver 3.0.8
39+
"""
40+
41+
from pytcp.protocols.tcp.session import TcpSession
42+
from pytcp.tests.lib.tcp_segment_factory import build_tcp4
43+
from pytcp.tests.lib.tcp_testcase import TcpTestCase
44+
45+
_LOCAL_PORT = 12345
46+
_REMOTE_PORT = 80
47+
_PEER_ISS = 5000
48+
_LOCAL_ISS = 1000
49+
50+
51+
class TestTcpSessionRcvRtt(TcpTestCase):
52+
"""
53+
The receiver-side RTT estimator RX-path tests.
54+
"""
55+
56+
def _establish_with_tsopt(self) -> TcpSession:
57+
"""
58+
Drive an active-open handshake with bilateral timestamps and
59+
advance the virtual clock past the sampled RTTs so 'now_ms -
60+
TSecr' stays positive.
61+
"""
62+
63+
session = self._drive_handshake_to_established(
64+
iss=_LOCAL_ISS,
65+
peer_iss=_PEER_ISS,
66+
peer_tsval=7000,
67+
peer_tsecr=0,
68+
)
69+
assert session._ts.send_ts, "Setup: bilateral TSopt negotiation must succeed."
70+
self._advance(ms=200)
71+
return session
72+
73+
def _peer_data(self, *, seq: int, tsval: int, tsecr: int, payload: bytes) -> bytes:
74+
"""
75+
Build an inbound data segment carrying the supplied TSval /
76+
TSecr and payload.
77+
"""
78+
79+
return build_tcp4(
80+
sport=_REMOTE_PORT,
81+
dport=_LOCAL_PORT,
82+
seq=seq,
83+
ack=_LOCAL_ISS + 1,
84+
flags=("ACK",),
85+
win=64240,
86+
tsval=tsval,
87+
tsecr=tsecr,
88+
payload=payload,
89+
)
90+
91+
def test__rcv_rtt__first_data_segment_seeds_estimate(self) -> None:
92+
"""
93+
Ensure an inbound new-data segment whose TSecr was sent one
94+
round trip ago seeds the receiver RTT estimate with
95+
'now_ms - TSecr'.
96+
97+
Reference: RFC 7323 §4 (RTTM via TSecr).
98+
"""
99+
100+
session = self._establish_with_tsopt()
101+
now = self._timer.now_ms
102+
self._drive_rx(frame=self._peer_data(seq=_PEER_ISS + 1, tsval=7001, tsecr=now - 40, payload=b"X" * 100))
103+
104+
self.assertEqual(
105+
session._rcv_rtt.rtt_ms,
106+
40,
107+
msg="The first data segment must seed the receiver RTT with now_ms - TSecr.",
108+
)
109+
110+
def test__rcv_rtt__second_segment_folds_ewma(self) -> None:
111+
"""
112+
Ensure a second new-data segment with a fresh TSecr folds into
113+
the receiver RTT via the alpha = 1/8 EWMA.
114+
115+
Reference: RFC 7323 §4 (RTTM via TSecr).
116+
"""
117+
118+
session = self._establish_with_tsopt()
119+
now = self._timer.now_ms
120+
self._drive_rx(frame=self._peer_data(seq=_PEER_ISS + 1, tsval=7001, tsecr=now - 40, payload=b"X" * 100))
121+
self._drive_rx(frame=self._peer_data(seq=_PEER_ISS + 101, tsval=7002, tsecr=now - 80, payload=b"Y" * 100))
122+
123+
# (7 * 40 + 80) // 8 == 45.
124+
self.assertEqual(
125+
session._rcv_rtt.rtt_ms,
126+
45,
127+
msg="A second fresh-TSecr sample must fold via the 1/8 EWMA.",
128+
)
129+
130+
def test__rcv_rtt__no_timestamps_leaves_estimate_unset(self) -> None:
131+
"""
132+
Ensure a connection without bilateral timestamps never
133+
populates the receiver RTT estimate — the first pass is
134+
timestamps-gated.
135+
136+
Reference: RFC 7323 §4 (RTTM via TSecr).
137+
"""
138+
139+
session = self._drive_handshake_to_established(iss=_LOCAL_ISS, peer_iss=_PEER_ISS)
140+
assert not session._ts.send_ts, "Setup: no bilateral TSopt without peer TSval."
141+
self._advance(ms=200)
142+
self._drive_rx(
143+
frame=build_tcp4(
144+
sport=_REMOTE_PORT,
145+
dport=_LOCAL_PORT,
146+
seq=_PEER_ISS + 1,
147+
ack=_LOCAL_ISS + 1,
148+
flags=("ACK",),
149+
win=64240,
150+
payload=b"X" * 100,
151+
)
152+
)
153+
154+
self.assertIsNone(
155+
session._rcv_rtt.rtt_ms,
156+
msg="Without timestamps the receiver RTT estimate must stay unset.",
157+
)
158+
159+
def test__rcv_rtt__duplicate_tsecr_yields_single_sample(self) -> None:
160+
"""
161+
Ensure two new-data segments echoing the same TSecr contribute
162+
only one RTT sample, so a within-RTT burst does not overweight
163+
the estimate.
164+
165+
Reference: RFC 7323 §4 (RTTM via TSecr).
166+
"""
167+
168+
session = self._establish_with_tsopt()
169+
now = self._timer.now_ms
170+
self._drive_rx(frame=self._peer_data(seq=_PEER_ISS + 1, tsval=7001, tsecr=now - 40, payload=b"X" * 100))
171+
# Same TSecr, fresh data — the second sample must be ignored.
172+
self._drive_rx(frame=self._peer_data(seq=_PEER_ISS + 101, tsval=7002, tsecr=now - 40, payload=b"Y" * 100))
173+
174+
self.assertEqual(
175+
session._rcv_rtt.rtt_ms,
176+
40,
177+
msg="A repeated TSecr must not contribute a second sample.",
178+
)

0 commit comments

Comments
 (0)