Skip to content

Commit da4989d

Browse files
ccie18643claude
andcommitted
fix(socket): auto-bind an unbound TCP socket on listen() (X3)
listen() on a socket with no prior bind() built a TcpSession on the invalid local port 0 — a broken listener. The backlog planned to reject this with EINVAL, but that contradicts Linux, which auto-binds an unbound TCP socket to an ephemeral port on listen() (inet_csk_listen_start -> get_port with port 0). Per the Linux-parity north star, mirror the auto-bind instead of the deviation. TcpSocket.listen() now, when the socket is unbound (local port not in 1..65535), picks an ephemeral port via pick_local_port() and re-registers the socket before starting the listener — the same unregister/pick/register dance bind() uses. A socket already bound to a specific port keeps it. Not a breaking change: the examples all bind() before listen(), so none needed updating (the reason the EINVAL variant was flagged as example-breaking). Tests-first: listen() on an unbound socket auto-binds an ephemeral port and registers the listener; listen() on a bound socket keeps its port and does not re-pick. Verified red (port stayed 0) before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 457fffc commit da4989d

3 files changed

Lines changed: 72 additions & 6 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ self._signal_readable()
178178
asserting the behavioural effect (not just the stored value). This is a
179179
series of small red-tests-first commits — good "one by one" cadence.
180180
- **Known members of this bucket:** `SO_SNDBUF` (see R3), `SO_SNDTIMEO`
181-
(see R3), `X3` listen()-on-unbound `EINVAL` (breaks examples; land as
182-
an explicit breaking-change commit + update `examples/`).
181+
(see R3), `X3` listen()-on-unbound (DONE — shipped as Linux-parity
182+
auto-bind, not the originally-planned `EINVAL`; see the R3 section).
183183
- **Effort:** open-ended (do a few per session). **Risk:** low per fix.
184184
- **Audit (done):** swept every stored setsockopt attribute for a data-path
185185
read. Result: almost everything is HONORED. The only strictly-DEAD
@@ -274,8 +274,8 @@ self._signal_readable()
274274
is now either honoured or documented-inert; the remaining true gaps are
275275
scoped to their proper track: `IP_MULTICAST_IF`/`_IF6` → Phase-2 (egress
276276
selection), `SO_RCVBUF`-on-TCP → R3 (receive-window), `SO_SNDBUF` /
277-
`SO_SNDTIMEO` → R3 (send buffer accounting), the `X3` listen()-on-unbound
278-
`EINVAL` breaking-change remains an explicit opt-in item.
277+
`SO_SNDTIMEO` → R3 (send buffer accounting; DONE), and `X3`
278+
listen()-on-unbound (DONE — Linux-parity auto-bind, see R3).
279279

280280
### R3 — `SO_SNDBUF` accounting + `SO_SNDTIMEO` (UDP) — DONE
281281

@@ -323,11 +323,20 @@ self._signal_readable()
323323
round-trip + integer-still-accepted regression). This makes the R3
324324
blocking-send `SO_SNDTIMEO` fully usable at sub-second granularity via
325325
the public API.
326+
- **Shipped (X3 — listen() on an unbound socket):** the backlog originally
327+
planned `EINVAL` here, but that **contradicts Linux**, which auto-binds an
328+
unbound TCP socket to an ephemeral port on `listen()`
329+
(`inet_csk_listen_start``get_port`). Per the Linux-parity north star we
330+
shipped the auto-bind: `TcpSocket.listen()` on a socket with no prior
331+
`bind()` now picks an ephemeral port (`pick_local_port`) and registers the
332+
listener, instead of building the previously-broken port-0 listener. Not a
333+
breaking change (the examples already bind first), so no `examples/` update
334+
was needed. Tests: `test__tcp_socket__listen_unbound_autobinds_ephemeral_port`
335+
+ `test__tcp_socket__listen_bound_keeps_port_and_does_not_repick`.
326336
- **Still scoped out (follow-ons):** TCP `SO_SNDBUF` (TCP has its own send
327337
buffering / retransmit queue, released on ACK, not on wire-write — a
328338
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/`).
339+
(receive-window derivation).
331340

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

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,16 @@ def listen(self, *, backlog: int = TCP__DEFAULT_BACKLOG) -> None:
766766

767767
assert backlog > 0, f"The 'backlog' argument must be positive. Got: {backlog!r}"
768768

769+
# Linux 'inet_csk_listen_start' auto-binds an unbound socket to
770+
# an ephemeral local port before it starts listening; PyTCP
771+
# mirrors that rather than requiring an explicit prior bind()
772+
# (which would otherwise leave the listener on the invalid port
773+
# 0). A socket already bound to a specific port keeps it.
774+
if self._local_port not in range(1, 65536):
775+
stack.sockets.unregister(self)
776+
self._local_port = pick_local_port()
777+
stack.sockets.register(self)
778+
769779
self._backlog = backlog
770780
self._tcp_session = TcpSession(
771781
local_ip_address=self._local_ip_address,

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,53 @@ def test__tcp_socket__listen_registers_and_starts_session(self) -> None:
569569
)
570570
self._session_cls.return_value.listen.assert_called_once_with()
571571

572+
def test__tcp_socket__listen_unbound_autobinds_ephemeral_port(self) -> None:
573+
"""
574+
Ensure listen() on a socket with no prior bind() auto-binds an
575+
ephemeral local port and registers the listener — mirroring
576+
Linux rather than listening on the invalid port 0.
577+
578+
Reference: Linux net/ipv4/inet_connection_sock.c
579+
inet_csk_listen_start (auto-bind an unbound listener).
580+
"""
581+
582+
s = TcpSocket(family=AddressFamily.INET4)
583+
self.assertEqual(s.local_port, 0, msg="A fresh TCP socket is unbound (port 0).")
584+
585+
with patch("pytcp.runtime.socket.tcp__socket.pick_local_port", return_value=45000):
586+
s.listen()
587+
588+
self.assertEqual(
589+
s.local_port,
590+
45000,
591+
msg="listen() on an unbound socket must auto-bind an ephemeral port.",
592+
)
593+
self.assertIn(
594+
s.socket_id,
595+
self._sockets,
596+
msg="An auto-bound listener must be registered under its ephemeral port.",
597+
)
598+
self._session_cls.return_value.listen.assert_called_once_with()
599+
600+
def test__tcp_socket__listen_bound_keeps_port_and_does_not_repick(self) -> None:
601+
"""
602+
Ensure listen() on an already-bound socket keeps its bound port
603+
and does not re-pick an ephemeral one — the auto-bind path
604+
applies only to an unbound socket.
605+
606+
Reference: Linux net/ipv4/inet_connection_sock.c
607+
inet_csk_listen_start (a bound listener keeps its port).
608+
"""
609+
610+
s = TcpSocket(family=AddressFamily.INET4)
611+
s.bind(("0.0.0.0", 8080))
612+
613+
with patch("pytcp.runtime.socket.tcp__socket.pick_local_port", return_value=99999) as pick:
614+
s.listen()
615+
616+
self.assertEqual(s.local_port, 8080, msg="listen() must keep the bound port.")
617+
pick.assert_not_called()
618+
572619
def test__tcp_socket__accept_returns_socket_and_address(self) -> None:
573620
"""
574621
Ensure accept() returns a '(socket, (remote_ip_str, port))'

0 commit comments

Comments
 (0)