Skip to content

Commit 457fffc

Browse files
ccie18643claude
andcommitted
fix(socket): accept sub-second float SO_RCVTIMEO / SO_SNDTIMEO
PyTCP documents SO_RCVTIMEO / SO_SNDTIMEO as "float seconds", but setsockopt gated the SOL_SOCKET dispatch on isinstance(value, int) so a sub-second float was rejected with ENOPROTOOPT, and getsockopt int()-truncated the stored timeout to whole seconds. A persistent sub-second timeout was therefore unsettable via the public API — including the R3 blocking-send SO_SNDTIMEO. Widen the setsockopt 'value' to int | float | bytes across the four concrete socket classes and route a float FIRST: a float is only valid for the SOL_SOCKET float-seconds timeouts, so it goes straight to _sol_socket_setsockopt (which already does float(value)) and the int / bytes option handlers below never receive one (keeping their signatures unchanged). getsockopt now returns the stored float verbatim (int | float | bytes) instead of truncating. Tests-first: TestUdpSocketTimeoutFloat — SO_RCVTIMEO=0.5 / SO_SNDTIMEO=0.25 round-trip through getsockopt, and an integer value is still accepted (regression). Verified red (ENOPROTOOPT) before the widening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 61f4c91 commit 457fffc

7 files changed

Lines changed: 146 additions & 22 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -311,12 +311,23 @@ self._signal_readable()
311311
all three datagram flavours. Tests: `TestRawSocketSoSndbuf` (2 —
312312
non-blocking EAGAIN, completion-hook release), `TestPingSocketSoSndbuf`
313313
(1 — charged-during / released-after balance).
314-
- **Still scoped out (follow-ons):** a sub-second `SO_SNDTIMEO` /
315-
`SO_RCVTIMEO` via `setsockopt` (blocked today by the SOL_SOCKET int-guard
316-
on the datagram sockets — float timeouts flow through `settimeout()`
317-
instead); TCP `SO_SNDBUF` (TCP has its own send buffering / retransmit
318-
queue, released on ACK, not on wire-write — a separate model, wrong to
319-
fold into this counter); `SO_RCVBUF`-on-TCP (receive-window derivation).
314+
- **Shipped (sub-second timeout via setsockopt):** `SO_RCVTIMEO` /
315+
`SO_SNDTIMEO` now accept and report a sub-second **float** value via
316+
`setsockopt` / `getsockopt`, matching PyTCP's documented "float seconds"
317+
surface. Previously the SOL_SOCKET dispatch int-guard rejected a float
318+
and getsockopt `int()`-truncated the stored value. The setsockopt `value`
319+
widened to `int | float | bytes`; a float is routed first to
320+
`_sol_socket_setsockopt` (the only float-valid path) so the int / bytes
321+
option handlers never receive one; getsockopt returns the float verbatim.
322+
Tests: `TestUdpSocketTimeoutFloat` (3 — float `SO_RCVTIMEO` / `SO_SNDTIMEO`
323+
round-trip + integer-still-accepted regression). This makes the R3
324+
blocking-send `SO_SNDTIMEO` fully usable at sub-second granularity via
325+
the public API.
326+
- **Still scoped out (follow-ons):** TCP `SO_SNDBUF` (TCP has its own send
327+
buffering / retransmit queue, released on ACK, not on wire-write — a
328+
separate model, wrong to fold into this counter); `SO_RCVBUF`-on-TCP
329+
(receive-window derivation); the `X3` `listen()`-on-unbound → `EINVAL`
330+
breaking change (updates `examples/`).
320331

321332
### R4 — IPv6 per-socket source filters = MLDv2 SSM track — DONE (P1-P5 shipped)
322333

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ def _so_linger_get(self) -> bytes:
790790
l_onoff, l_linger = self._so_linger or (0, 0)
791791
return struct.pack("@ii", l_onoff, l_linger)
792792

793-
def _sol_socket_setsockopt(self, optname: int, value: int, /) -> bool:
793+
def _sol_socket_setsockopt(self, optname: int, value: int | float, /) -> bool:
794794
"""
795795
Apply a SOL_SOCKET-level setsockopt option; return True if
796796
handled or False if the optname is not a base-class option
@@ -1556,7 +1556,7 @@ def _effective_ip4_options(self) -> Ip4Options | None:
15561556
return None
15571557
return Ip4Options.from_buffer(self._ip_options)
15581558

1559-
def _sol_socket_getsockopt(self, optname: int, /) -> int | bytes | None:
1559+
def _sol_socket_getsockopt(self, optname: int, /) -> int | float | bytes | None:
15601560
"""
15611561
Get a SOL_SOCKET-level option's stored value, or 'None' if
15621562
the option is not a base-class option. Most options return an
@@ -1577,9 +1577,9 @@ def _sol_socket_getsockopt(self, optname: int, /) -> int | bytes | None:
15771577
case _ if optname == SO_RCVBUF:
15781578
return self._so_rcvbuf or 0
15791579
case _ if optname == SO_RCVTIMEO:
1580-
return int(self._so_rcvtimeo) if self._so_rcvtimeo else 0
1580+
return self._so_rcvtimeo or 0.0
15811581
case _ if optname == SO_SNDTIMEO:
1582-
return int(self._so_sndtimeo) if self._so_sndtimeo else 0
1582+
return self._so_sndtimeo or 0.0
15831583
case _ if optname == SO_OOBINLINE:
15841584
# Always 1 — PyTCP's RFC 6093 §6 universal-inline
15851585
# design (see the setsockopt comment above).
@@ -2114,7 +2114,7 @@ def recv__mv(
21142114

21152115
raise NotImplementedError
21162116

2117-
def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /) -> None:
2117+
def setsockopt(self, level: int | IpProto, optname: int, value: int | float | bytes, /) -> None:
21182118
"""
21192119
The 'setsockopt()' socket API method placeholder. Each concrete
21202120
IP socket implements the SOL_SOCKET / IPPROTO_* option surface;
@@ -2123,7 +2123,7 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
21232123

21242124
raise NotImplementedError
21252125

2126-
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
2126+
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | float | bytes:
21272127
"""
21282128
The 'getsockopt()' socket API method placeholder. Symmetric to
21292129
'setsockopt'.

packages/pytcp/pytcp/runtime/socket/ping__socket.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,20 +220,31 @@ def getpeername(self) -> tuple[str, int]:
220220
return str(self._remote_ip_address), 0
221221

222222
@override
223-
def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /) -> None:
223+
def setsockopt(self, level: int | IpProto, optname: int, value: int | float | bytes, /) -> None:
224224
"""
225225
Set a socket option. Supports 'IP_RECVTTL' / 'IPV6_RECVHOPLIMIT',
226226
which make 'recvmsg' surface the reply's TTL / Hop Limit cmsg.
227227
"""
228228

229+
# A float value is only valid for the SOL_SOCKET float-seconds
230+
# timeouts (SO_RCVTIMEO / SO_SNDTIMEO); route it there so the
231+
# int / bytes option paths below never receive a float.
232+
if isinstance(value, float):
233+
if level == SOL_SOCKET and self._sol_socket_setsockopt(optname, value):
234+
return
235+
raise OSError(
236+
errno.ENOPROTOOPT,
237+
f"setsockopt: unsupported (level, optname) pair for a float value: "
238+
f"level={level!r}, optname={optname!r}",
239+
)
229240
if optname in (IP_RECVTTL, IPV6_RECVHOPLIMIT):
230241
self._recv_ttl = bool(value)
231242
return
232243
if isinstance(value, int) and level == SOL_SOCKET and self._sol_socket_setsockopt(optname, value):
233244
return
234245

235246
@override
236-
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
247+
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | float | bytes:
237248
"""
238249
Get a socket option.
239250
"""

packages/pytcp/pytcp/runtime/socket/raw__socket.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,25 @@ def _get_ip_addresses(
156156
return local_ip_address, remote_ip_address # type: ignore[return-value]
157157

158158
@override
159-
def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /) -> None:
159+
def setsockopt(self, level: int | IpProto, optname: int, value: int | float | bytes, /) -> None:
160160
"""
161161
Set a socket option per the BSD 'setsockopt' API. RAW
162162
sockets honor SOL_SOCKET / IPPROTO_IP / IPPROTO_IPV6
163163
options through the base-class helpers. 'value' is 'int'
164164
for scalar options and 'bytes' for IP_OPTIONS.
165165
"""
166166

167+
# A float value is only valid for the SOL_SOCKET float-seconds
168+
# timeouts (SO_RCVTIMEO / SO_SNDTIMEO); route it there so the
169+
# int / bytes option paths below never receive a float.
170+
if isinstance(value, float):
171+
if level == SOL_SOCKET and self._sol_socket_setsockopt(optname, value):
172+
return
173+
raise OSError(
174+
errno.ENOPROTOOPT,
175+
f"setsockopt: unsupported (level, optname) pair for a float value: "
176+
f"level={level!r}, optname={optname!r}",
177+
)
167178
if optname in (IP_RECVTTL, IPV6_RECVHOPLIMIT):
168179
# Make 'recvmsg' surface the received TTL / Hop Limit as an
169180
# 'IP_TTL' / 'IPV6_HOPLIMIT' cmsg — the only way to read the
@@ -187,7 +198,7 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
187198
)
188199

189200
@override
190-
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
201+
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | float | bytes:
191202
"""
192203
Get a socket option per the BSD 'getsockopt' API.
193204
Symmetric to 'setsockopt': 'int' for scalar options,
@@ -196,7 +207,7 @@ def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
196207

197208
if optname in (IP_RECVTTL, IPV6_RECVHOPLIMIT):
198209
return int(self._recv_ttl)
199-
value: int | bytes | None
210+
value: int | float | bytes | None
200211
if level == SOL_SOCKET and (value := self._sol_socket_getsockopt(optname)) is not None:
201212
return value
202213
if level == IPPROTO_IP and (value := self._ipproto_ip_getsockopt(optname)) is not None:

packages/pytcp/pytcp/runtime/socket/tcp__socket.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ def status(self) -> TcpStatus:
335335
)
336336

337337
@override
338-
def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /) -> None:
338+
def setsockopt(self, level: int | IpProto, optname: int, value: int | float | bytes, /) -> None:
339339
"""
340340
Set a socket option per the BSD 'setsockopt' API.
341341
@@ -356,6 +356,17 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
356356
# Connection now has RFC 1122 §4.2.3.6 keep-alive enabled.
357357
"""
358358

359+
# A float value is only valid for the SOL_SOCKET float-seconds
360+
# timeouts (SO_RCVTIMEO / SO_SNDTIMEO); route it there so the
361+
# int / bytes option paths below never receive a float.
362+
if isinstance(value, float):
363+
if level == SOL_SOCKET and self._sol_socket_setsockopt(optname, value):
364+
return
365+
raise OSError(
366+
errno.ENOPROTOOPT,
367+
f"setsockopt: unsupported (level, optname) pair for a float value: "
368+
f"level={level!r}, optname={optname!r}",
369+
)
359370
if isinstance(value, int) and level == SOL_SOCKET and optname == SO_KEEPALIVE:
360371
self._so_keepalive = bool(value)
361372
return
@@ -443,7 +454,7 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
443454
)
444455

445456
@override
446-
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
457+
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | float | bytes:
447458
"""
448459
Get a socket option per the BSD 'getsockopt' API.
449460

packages/pytcp/pytcp/runtime/socket/udp__socket.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def udp_no_check6_rx(self) -> bool:
167167
return self._udp_no_check6_rx
168168

169169
@override
170-
def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /) -> None:
170+
def setsockopt(self, level: int | IpProto, optname: int, value: int | float | bytes, /) -> None:
171171
"""
172172
Set a socket option per the BSD 'setsockopt' API. UDP
173173
sockets honor SOL_SOCKET / IPPROTO_IP / IPPROTO_IPV6 /
@@ -176,6 +176,17 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
176176
'bytes' for IP_OPTIONS (RFC 1122 §4.1.3.2 raw options block).
177177
"""
178178

179+
# A float value is only valid for the SOL_SOCKET float-seconds
180+
# timeouts (SO_RCVTIMEO / SO_SNDTIMEO); route it there so the
181+
# int / bytes option paths below never receive a float.
182+
if isinstance(value, float):
183+
if level == SOL_SOCKET and self._sol_socket_setsockopt(optname, value):
184+
return
185+
raise OSError(
186+
errno.ENOPROTOOPT,
187+
f"setsockopt: unsupported (level, optname) pair for a float value: "
188+
f"level={level!r}, optname={optname!r}",
189+
)
179190
if level == SOL_SOCKET and optname == SO_BINDTODEVICE:
180191
self._so_bindtodevice(value)
181192
return
@@ -198,14 +209,14 @@ def setsockopt(self, level: int | IpProto, optname: int, value: int | bytes, /)
198209
)
199210

200211
@override
201-
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | bytes:
212+
def getsockopt(self, level: int | IpProto, optname: int, /) -> int | float | bytes:
202213
"""
203214
Get a socket option per the BSD 'getsockopt' API.
204215
Symmetric to 'setsockopt': 'int' for scalar options,
205216
'bytes' for IP_OPTIONS.
206217
"""
207218

208-
value: int | bytes | None
219+
value: int | float | bytes | None
209220
if level == SOL_SOCKET and optname == SO_BINDTODEVICE:
210221
return self._bound_interface_name.encode() if self._bound_interface_name else bytes()
211222
if level == SOL_SOCKET and (value := self._sol_socket_getsockopt(optname)) is not None:

packages/pytcp/pytcp/tests/unit/runtime/socket/test__runtime__socket__udp__socket.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3331,3 +3331,72 @@ def test__so_sndbuf__unset_default_never_blocks_normal_send(self) -> None:
33313331

33323332
for _ in range(50):
33333333
self.assertEqual(s.send(b"x" * 1000), 1000, msg="A default-SO_SNDBUF send must not block.")
3334+
3335+
3336+
class TestUdpSocketTimeoutFloat(_UdpSocketTestCase):
3337+
"""
3338+
SO_RCVTIMEO / SO_SNDTIMEO accept and report sub-second float
3339+
seconds via setsockopt / getsockopt, matching PyTCP's documented
3340+
'float seconds' surface. The setsockopt SOL_SOCKET int-guard
3341+
previously rejected a float value and getsockopt truncated the
3342+
stored timeout to whole seconds.
3343+
"""
3344+
3345+
def test__so_rcvtimeo__accepts_and_reports_subsecond_float(self) -> None:
3346+
"""
3347+
Ensure setsockopt(SO_RCVTIMEO, 0.5) stores the sub-second
3348+
timeout and getsockopt reports it back verbatim.
3349+
3350+
Reference: Linux SO_RCVTIMEO (struct timeval; PyTCP exposes
3351+
float seconds).
3352+
"""
3353+
3354+
from pytcp.runtime.socket import SO_RCVTIMEO, SOL_SOCKET
3355+
3356+
s = UdpSocket(family=AddressFamily.INET4)
3357+
3358+
s.setsockopt(SOL_SOCKET, SO_RCVTIMEO, 0.5)
3359+
self.assertEqual(
3360+
s.getsockopt(SOL_SOCKET, SO_RCVTIMEO),
3361+
0.5,
3362+
msg="SO_RCVTIMEO must round-trip a sub-second float.",
3363+
)
3364+
3365+
def test__so_sndtimeo__accepts_and_reports_subsecond_float(self) -> None:
3366+
"""
3367+
Ensure setsockopt(SO_SNDTIMEO, 0.25) stores the sub-second
3368+
timeout and getsockopt reports it back verbatim.
3369+
3370+
Reference: Linux SO_SNDTIMEO (struct timeval; PyTCP exposes
3371+
float seconds).
3372+
"""
3373+
3374+
from pytcp.runtime.socket import SO_SNDTIMEO, SOL_SOCKET
3375+
3376+
s = UdpSocket(family=AddressFamily.INET4)
3377+
3378+
s.setsockopt(SOL_SOCKET, SO_SNDTIMEO, 0.25)
3379+
self.assertEqual(
3380+
s.getsockopt(SOL_SOCKET, SO_SNDTIMEO),
3381+
0.25,
3382+
msg="SO_SNDTIMEO must round-trip a sub-second float.",
3383+
)
3384+
3385+
def test__so_rcvtimeo__integer_seconds_still_accepted(self) -> None:
3386+
"""
3387+
Ensure an integer SO_RCVTIMEO still works — regression pin
3388+
for the float widening.
3389+
3390+
Reference: Linux SO_RCVTIMEO (integer-second value accepted).
3391+
"""
3392+
3393+
from pytcp.runtime.socket import SO_RCVTIMEO, SOL_SOCKET
3394+
3395+
s = UdpSocket(family=AddressFamily.INET4)
3396+
3397+
s.setsockopt(SOL_SOCKET, SO_RCVTIMEO, 3)
3398+
self.assertEqual(
3399+
s.getsockopt(SOL_SOCKET, SO_RCVTIMEO),
3400+
3,
3401+
msg="SO_RCVTIMEO must still accept an integer-second value.",
3402+
)

0 commit comments

Comments
 (0)