Skip to content

Commit 98758d2

Browse files
ccie18643claude
andcommitted
feat(tcp): send-buffer auto-tuning grows with cwnd (autotune step 2 / Track S)
Second phase of the Tier-3 buffer auto-tuning plan (docs/refactor/tcp_buffer_autotuning.md §5 / §10 step 2): grow the send buffer with the congestion window, mirroring Linux tcp_sndbuf_expand. S1 (socket base, runtime/socket/__init__.py): - New grow-only '_sndbuf_auto' bound + '_grow_sndbuf_auto(target)' named mutator (single-writer, no lock — only the owning session's RX/ACK thread writes it). - '_effective_sndbuf()' now returns the explicit SO_SNDBUF when set (SOCK_SNDBUF_LOCK — pin wins), else max(static default, _sndbuf_auto). Datagram sockets keep _sndbuf_auto == 0, so their bound is unchanged. S2 (session): - 'TcpSession._maybe_expand_sndbuf()' computes 2 * max(RFC 6928 IW, cwnd) with per_mss == snd_mss (the locked no-2x / no-overhead Tier-1 deviation; the Linux reordering+1 term is IW-dominated and omitted), clamps at the live 'tcp.wmem.max' (qualified-module read), and calls _grow_sndbuf_auto. No-op under SOCK_SNDBUF_LOCK or before MSS is known. - Triggered from the phase-5 ACK path (tcp__session__ack.py) right after _wake_sndbuf_waiters(); cwnd is already grown in phase 1, so the bound tracks the current window. The Tier-1 wake admits any blocked writer against the widened bound with no new gate wiring. The auto bound only becomes the *effective* value once it exceeds the 208 KiB static default (cwnd whose 2x tops it); it is fully observable once step 5 lands the small default. No behaviour change for a socket with an explicit SO_SNDBUF or for datagram sockets. Tests-first: test__tcp__session__sndbuf_autotune.py (5 cases — ACK grows the bound, _effective_sndbuf reflects it, tcp.wmem.max clamp, grow-only, explicit SO_SNDBUF freezes it). RED pre-impl: AttributeError (_maybe_expand_sndbuf / _sndbuf_auto absent) → green post-impl. The lock test was corrected from an initial wrong assumption (auto-tuning correctly fires on the handshake ACK, so the lock freezes an already-grown bound, not a zero one). Plan §5 / §10 marked done. Reference: RFC 9293 §3.9 (SEND — send-buffer flow control). Reference: Linux tcp_sndbuf_expand / net.ipv4.tcp_wmem. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 066f543 commit 98758d2

5 files changed

Lines changed: 312 additions & 8 deletions

File tree

docs/refactor/tcp_buffer_autotuning.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ on ACK processing, when SO_SNDBUF unset and cwnd is growing:
267267

268268
### S1 — the auto bound + `_effective_sndbuf` integration (small)
269269

270+
**DONE.** `_sndbuf_auto: int` on the socket base
271+
(`runtime/socket/__init__.py`, init 0), grown grow-only via the named
272+
mutator `_grow_sndbuf_auto(target)` (single-writer, no lock).
273+
`_effective_sndbuf()` returns `_so_sndbuf` when set, else
274+
`max(SOCKET__SO_SNDBUF__DEFAULT, _sndbuf_auto)` — datagram sockets keep
275+
`_sndbuf_auto == 0` so their bound is unchanged.
276+
277+
Original plan text:
278+
270279
- Add `_sndbuf_auto: int = 0` on the socket base
271280
(`runtime/socket/__init__.py`, near `_so_sndbuf`), a grow-only value
272281
written by the RX/ACK thread and read by the app thread.
@@ -278,6 +287,23 @@ on ACK processing, when SO_SNDBUF unset and cwnd is growing:
278287

279288
### S2 — the expand policy + trigger (small–medium, the meat)
280289

290+
**DONE.** `TcpSession._maybe_expand_sndbuf()` computes
291+
`2 * max(INITIAL_WINDOW_FACTOR * snd_mss, cwnd)` (RFC 6928 IW floor,
292+
`per_mss = snd_mss` per the locked no-overhead deviation, `reordering+1`
293+
term omitted as IW-dominated), clamps at live `tcp__constants.TCP__WMEM__MAX`
294+
(qualified-module read = current sysctl value), and calls
295+
`_grow_sndbuf_auto`; a no-op when `_so_sndbuf is not None`
296+
(SOCK_SNDBUF_LOCK) or `snd_mss <= 0`. Triggered from
297+
`tcp__session__ack.py` phase 5, right after `_wake_sndbuf_waiters()`
298+
cwnd is already grown in phase 1, so the bound tracks the current window.
299+
Tests: `test__tcp__session__sndbuf_autotune.py` (ACK grows the bound,
300+
`_effective_sndbuf` reflects it, `tcp.wmem.max` clamp, grow-only, explicit
301+
SO_SNDBUF freezes it). Note: auto-tuning correctly fires on the handshake
302+
ACK too, so a mid-connection `SO_SNDBUF` freezes the *already-grown* bound
303+
rather than a zero one.
304+
305+
Original plan text:
306+
281307
- New `TcpSession._maybe_expand_sndbuf()`: implement §5's target; write
282308
`self._socket._sndbuf_auto = max(self._socket._sndbuf_auto,
283309
min(target, tcp.wmem.max))` when `self._socket._so_sndbuf is None`
@@ -460,7 +486,12 @@ CLAUDE.md "Linux as tiebreaker" precedence.
460486
consumer yet. Tests: `test__tcp__constants.py`.
461487
2. **S1 + S2** (send auto-tuning) — smaller track, exercises the sysctl
462488
clamp and the ACK-path trigger with low risk (grow-only widening of an
463-
existing gate).
489+
existing gate). **DONE**`_sndbuf_auto` + `_grow_sndbuf_auto` on the
490+
socket, `_effective_sndbuf` widened, `TcpSession._maybe_expand_sndbuf`
491+
fired from the phase-5 ACK path. Observable now via `_sndbuf_auto` and
492+
(for cwnd whose 2× exceeds the 208 KiB default) `_effective_sndbuf`;
493+
fully observable once step 5 lands the small default. Tests:
494+
`test__tcp__session__sndbuf_autotune.py`.
464495
3. **R1** (receiver RTT estimator) — the DRS prerequisite; self-contained,
465496
unit-testable in isolation.
466497
4. **R2 + R3 + R4** (DRS struct, trigger, grow policy) — the large piece;

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
from pytcp.protocols.tcp.state.tcp__state__timestamps import TimestampsState
7070
from pytcp.protocols.tcp.state.tcp__state__tx_buffer import TxBufferState
7171
from pytcp.protocols.tcp.state.tcp__state__window import WindowState
72-
from pytcp.protocols.tcp.tcp__cwnd import compute_ecn_event_ssthresh
72+
from pytcp.protocols.tcp.tcp__cwnd import INITIAL_WINDOW_FACTOR, compute_ecn_event_ssthresh
7373
from pytcp.protocols.tcp.tcp__enums import (
7474
CcMode,
7575
ConnError,
@@ -1246,6 +1246,33 @@ def _charge_tx_buffer(self, data: bytes, /) -> int:
12461246
if not cond.wait(timeout=socket._so_sndtimeo):
12471247
raise BlockingIOError(errno.EAGAIN, os.strerror(errno.EAGAIN))
12481248

1249+
def _maybe_expand_sndbuf(self) -> None:
1250+
"""
1251+
Grow the auto-tuning send-buffer bound to track the congestion
1252+
window (Tier-3 Track S, mirroring Linux 'tcp_sndbuf_expand').
1253+
Called from the inbound-ACK path after cwnd growth: the target
1254+
is two windows of data — '2 * max(IW, cwnd)' — floored at the
1255+
RFC 6928 initial window so a small-cwnd connection still queues
1256+
usefully, and clamped at 'tcp.wmem.max'. 'per_mss' is the bare
1257+
MSS (no skb-truesize overhead — the same no-2x / no-overhead
1258+
deviation locked for Tier-1); the Linux 'reordering + 1' term is
1259+
omitted (dominated by IW = 10 in practice).
1260+
1261+
A no-op when the application set SO_SNDBUF explicitly
1262+
(SOCK_SNDBUF_LOCK disables auto-tuning) or before the MSS is
1263+
known. The grow-only store lives on the socket; only the RX/ACK
1264+
thread writes it, so no lock is taken.
1265+
"""
1266+
1267+
socket = self._socket
1268+
if socket._so_sndbuf is not None:
1269+
return
1270+
smss = self._win.snd_mss
1271+
if smss <= 0:
1272+
return
1273+
target = 2 * max(INITIAL_WINDOW_FACTOR * smss, self._cc.cwnd)
1274+
socket._grow_sndbuf_auto(min(target, tcp__constants.TCP__WMEM__MAX))
1275+
12491276
def receive(self, *, byte_count: int | None = None, timeout: float | None = None) -> bytes:
12501277
"""
12511278
The 'RECEIVE' syscall.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,10 @@ def _phase5_consume_segment_and_postprocess(self, packet_rx_md: TcpMetadata) ->
745745
# '_lock__tx_buffer' (both released here), so it cannot invert
746746
# the lock order.
747747
session._socket._wake_sndbuf_waiters()
748+
# Linux 'tcp_sndbuf_expand': phase 1 may have grown cwnd, so
749+
# grow the auto-tuning send-buffer bound to track it (Tier-3
750+
# Track S). No-op when SO_SNDBUF is set (SOCK_SNDBUF_LOCK).
751+
session._maybe_expand_sndbuf()
748752
__debug__ and log(
749753
"tcp-ss",
750754
f"[{session}] - Purged TX buffer up to SEQ {session._snd_seq.una}",

packages/pytcp/pytcp/runtime/socket/__init__.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,11 @@ class socket(ABC):
599599
# datagram's TX completes.
600600
_snd_outstanding: int
601601
_snd_buf_cond: threading.Condition
602+
# TCP send-buffer auto-tuning bound (Tier-3 Track S). Grow-only;
603+
# written by the owning TcpSession on the RX/ACK thread, read by the
604+
# app thread via '_effective_sndbuf()'. Stays 0 for datagram sockets
605+
# (no auto-tuning), so their effective bound is the static default.
606+
_sndbuf_auto: int
602607
_ip_ttl: int | None
603608
_ip_multicast_ttl: int | None
604609
_ip_multicast_loop: bool
@@ -673,6 +678,7 @@ def __init__(
673678
self._so_sndtimeo = None
674679
self._snd_outstanding = 0
675680
self._snd_buf_cond = threading.Condition()
681+
self._sndbuf_auto = 0
676682
self._ip_ttl = None
677683
self._ip_multicast_ttl = None
678684
# Linux IP_MULTICAST_LOOP defaults on; PyTCP has no local
@@ -1443,14 +1449,29 @@ def _multicast_send_suppressed(self, remote_ip_address: Ip4Address | Ip6Address,
14431449

14441450
def _effective_sndbuf(self) -> int:
14451451
"""
1446-
Get the SO_SNDBUF send-buffer bound: the value the
1447-
application set, else the 'SOCKET__SO_SNDBUF__DEFAULT'
1448-
stand-in for Linux 'net.core.wmem_default'. PyTCP does not
1449-
apply Linux's 2x doubling of the requested value — the bound
1450-
is the value as set.
1452+
Get the SO_SNDBUF send-buffer bound. An explicit application
1453+
value (SOCK_SNDBUF_LOCK) always wins and pins the bound. When
1454+
unset, the bound is the larger of the 'SOCKET__SO_SNDBUF__DEFAULT'
1455+
stand-in (Linux 'net.core.wmem_default') and the TCP
1456+
auto-tuning bound '_sndbuf_auto' (Tier-3 Track S; 0 for
1457+
datagram sockets, so their bound is the static default). PyTCP
1458+
does not apply Linux's 2x doubling of the requested value.
14511459
"""
14521460

1453-
return self._so_sndbuf if self._so_sndbuf is not None else SOCKET__SO_SNDBUF__DEFAULT
1461+
if self._so_sndbuf is not None:
1462+
return self._so_sndbuf
1463+
return max(SOCKET__SO_SNDBUF__DEFAULT, self._sndbuf_auto)
1464+
1465+
def _grow_sndbuf_auto(self, target: int, /) -> None:
1466+
"""
1467+
Raise the auto-tuning send-buffer bound to 'target', grow-only
1468+
(a lower target is a no-op). Called by the owning TcpSession's
1469+
send-buffer expand policy on the RX/ACK thread; the app thread
1470+
only reads '_sndbuf_auto' via '_effective_sndbuf()', so the
1471+
single-writer 'max()' needs no lock.
1472+
"""
1473+
1474+
self._sndbuf_auto = max(self._sndbuf_auto, target)
14541475

14551476
def _effective_rcvbuf(self) -> int:
14561477
"""
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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 TCP send-buffer auto-tuning (Tier-3 Track S,
31+
docs/refactor/tcp_buffer_autotuning.md §5). On ACK processing the session
32+
grows a grow-only auto send-buffer bound toward '2 * max(IW, cwnd) *
33+
per_mss', clamped at 'tcp.wmem.max', mirroring Linux 'tcp_sndbuf_expand'.
34+
An explicit SO_SNDBUF (SOCK_SNDBUF_LOCK) disables auto-tuning. The auto
35+
bound feeds the Tier-1 SO_SNDBUF gate via '_effective_sndbuf()'.
36+
37+
pytcp/tests/integration/protocols/tcp/test__tcp__session__sndbuf_autotune.py
38+
39+
ver 3.0.8
40+
"""
41+
42+
from typing import override
43+
44+
from pytcp.protocols.tcp import tcp__constants
45+
from pytcp.protocols.tcp.session import TcpSession
46+
from pytcp.protocols.tcp.tcp__cwnd import INITIAL_WINDOW_FACTOR
47+
from pytcp.runtime.socket import (
48+
SO_SNDBUF,
49+
SOCKET__SO_SNDBUF__DEFAULT,
50+
SOL_SOCKET,
51+
)
52+
from pytcp.stack import sysctl as sysctl_module
53+
from pytcp.tests.lib.tcp_segment_factory import build_tcp4
54+
from pytcp.tests.lib.tcp_testcase import TcpTestCase
55+
56+
_LOCAL_PORT = 12345
57+
_REMOTE_PORT = 80
58+
59+
60+
class TestTcpSessionSndbufAutotune(TcpTestCase):
61+
"""
62+
The send-buffer auto-tuning grow-policy tests.
63+
"""
64+
65+
@override
66+
def tearDown(self) -> None:
67+
"""
68+
Restore any sysctl overridden by a clamp test so the mutated
69+
'tcp.wmem.max' cannot leak into a sibling test.
70+
"""
71+
72+
sysctl_module.reset_to_defaults()
73+
super().tearDown()
74+
75+
def _expected_auto(self, session: TcpSession) -> int:
76+
"""
77+
Compute the expected auto send-buffer bound for the session's
78+
current cwnd / snd_mss, clamped at 'tcp.wmem.max' — the policy
79+
the implementation must reproduce.
80+
"""
81+
82+
iw_floor = INITIAL_WINDOW_FACTOR * session._win.snd_mss
83+
target = 2 * max(iw_floor, session._cc.cwnd)
84+
return min(target, tcp__constants.TCP__WMEM__MAX)
85+
86+
def test__sndbuf_autotune__ack_grows_auto_bound(self) -> None:
87+
"""
88+
Ensure processing a cumulative ACK grows the auto send-buffer
89+
bound to '2 * max(IW, cwnd) * per_mss' for the current cwnd, so
90+
the send buffer tracks the congestion window.
91+
92+
Reference: RFC 9293 §3.9 (SEND — send-buffer flow control).
93+
Reference: Linux tcp_sndbuf_expand (send buffer grows with cwnd).
94+
"""
95+
96+
session = self._drive_handshake_to_established(iss=1000, peer_iss=5000)
97+
session.send(data=b"A" * 1460)
98+
self._advance(ms=1)
99+
100+
peer_ack = build_tcp4(
101+
sport=_REMOTE_PORT,
102+
dport=_LOCAL_PORT,
103+
seq=5001,
104+
ack=1000 + 1 + 1460,
105+
flags=("ACK",),
106+
win=64240,
107+
)
108+
self._drive_rx(frame=peer_ack)
109+
110+
self.assertEqual(
111+
session._socket._sndbuf_auto,
112+
self._expected_auto(session),
113+
msg="The ACK must grow the auto send-buffer bound to 2 * max(IW, cwnd).",
114+
)
115+
self.assertGreater(
116+
session._socket._sndbuf_auto,
117+
0,
118+
msg="Auto-tuning must have raised the bound above zero.",
119+
)
120+
121+
def test__sndbuf_autotune__effective_reflects_grown_bound(self) -> None:
122+
"""
123+
Ensure '_effective_sndbuf()' returns the auto bound once it
124+
exceeds the static default, so the Tier-1 send gate admits the
125+
larger queue.
126+
127+
Reference: RFC 9293 §3.9 (SEND — send-buffer flow control).
128+
Reference: Linux socket(7) SO_SNDBUF (auto-tuned buffer size).
129+
"""
130+
131+
session = self._drive_handshake_to_established(iss=1000, peer_iss=5000)
132+
# A cwnd whose 2x exceeds the 208 KiB static default, so the
133+
# grown auto bound becomes the effective value.
134+
session._cc.cwnd = 200_000
135+
session._maybe_expand_sndbuf()
136+
137+
expected = self._expected_auto(session)
138+
self.assertGreater(
139+
expected,
140+
SOCKET__SO_SNDBUF__DEFAULT,
141+
msg="Setup: the grown bound must exceed the static default to be observable.",
142+
)
143+
self.assertEqual(
144+
session._socket._effective_sndbuf(),
145+
expected,
146+
msg="'_effective_sndbuf()' must return the grown auto bound.",
147+
)
148+
149+
def test__sndbuf_autotune__clamps_at_wmem_max(self) -> None:
150+
"""
151+
Ensure the grow policy saturates at 'tcp.wmem.max' so a large
152+
cwnd cannot push the send buffer past the operator ceiling.
153+
154+
Reference: RFC 9293 §3.9 (SEND — send-buffer flow control).
155+
Reference: Linux net.ipv4.tcp_wmem (max bounds the auto buffer).
156+
"""
157+
158+
session = self._drive_handshake_to_established(iss=1000, peer_iss=5000)
159+
sysctl_module.set("tcp.wmem.max", 50_000)
160+
session._cc.cwnd = 200_000
161+
session._maybe_expand_sndbuf()
162+
163+
self.assertEqual(
164+
session._socket._sndbuf_auto,
165+
50_000,
166+
msg="The auto bound must saturate at 'tcp.wmem.max'.",
167+
)
168+
169+
def test__sndbuf_autotune__grow_only(self) -> None:
170+
"""
171+
Ensure the auto bound never shrinks — a later smaller cwnd
172+
leaves the previously-grown bound in place.
173+
174+
Reference: RFC 9293 §3.9 (SEND — send-buffer flow control).
175+
Reference: Linux tcp_sndbuf_expand (send buffer is grow-only).
176+
"""
177+
178+
session = self._drive_handshake_to_established(iss=1000, peer_iss=5000)
179+
session._cc.cwnd = 200_000
180+
session._maybe_expand_sndbuf()
181+
grown = session._socket._sndbuf_auto
182+
183+
# cwnd collapses (e.g. after loss) — the bound must not shrink.
184+
session._cc.cwnd = session._win.snd_mss
185+
session._maybe_expand_sndbuf()
186+
187+
self.assertEqual(
188+
session._socket._sndbuf_auto,
189+
grown,
190+
msg="A smaller cwnd must not shrink the auto send-buffer bound.",
191+
)
192+
193+
def test__sndbuf_autotune__explicit_sndbuf_disables_autotune(self) -> None:
194+
"""
195+
Ensure an explicit setsockopt(SO_SNDBUF) pins the send-buffer
196+
bound and disables auto-tuning (SOCK_SNDBUF_LOCK), so cwnd
197+
growth never moves the effective bound.
198+
199+
Reference: RFC 9293 §3.9 (SEND — send-buffer flow control).
200+
Reference: Linux socket(7) SO_SNDBUF (explicit size disables
201+
auto-tuning).
202+
"""
203+
204+
session = self._drive_handshake_to_established(iss=1000, peer_iss=5000)
205+
# The handshake ACK already ran the expand once, so capture the
206+
# current auto bound; the lock must freeze it from here.
207+
auto_before = session._socket._sndbuf_auto
208+
session._socket.setsockopt(SOL_SOCKET, SO_SNDBUF, 100_000)
209+
session._cc.cwnd = 200_000
210+
session._maybe_expand_sndbuf()
211+
212+
self.assertEqual(
213+
session._socket._sndbuf_auto,
214+
auto_before,
215+
msg="Explicit SO_SNDBUF must freeze the auto bound (no further growth).",
216+
)
217+
self.assertEqual(
218+
session._socket._effective_sndbuf(),
219+
100_000,
220+
msg="An explicit SO_SNDBUF must remain the effective bound across cwnd growth.",
221+
)

0 commit comments

Comments
 (0)