Skip to content

Commit 68a0975

Browse files
committed
Update Tube, Process, Socket and implement Server
1 parent 4853afa commit 68a0975

11 files changed

Lines changed: 2080 additions & 1615 deletions

File tree

ptrlib/connection/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""This package provides some utilities for socket and pipe connections.
22
"""
3+
from .tube import *
34
from .proc import *
45
from .sock import *
56
from .ssh import *
7+
from .server import *

ptrlib/connection/server.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
"""TCP server and connection abstractions.
2+
3+
This module provides the `Server` class for creating and managing TCP listening sockets,
4+
and the `TCPConnection` class for handling individual accepted TCP connections with a
5+
buffered send/receive API. Supports IPv4, IPv6, dual-stack sockets, and thread-safe
6+
acceptance of incoming connections.
7+
8+
Classes:
9+
Server: A TCP server class for managing incoming connections.
10+
TCPConnection: A TCP connection class for handling individual client connections.
11+
"""
12+
import socket
13+
import select
14+
import errno
15+
import contextlib
16+
from logging import getLogger
17+
from .tube import Tube
18+
19+
20+
AddressT = tuple[str, int] | tuple[str, int, int, int]
21+
22+
class TCPConnection(Tube):
23+
"""A single accepted TCP connection wrapped as a Tube.
24+
25+
This class is returned by :meth:`Server.accept` and provides the usual
26+
buffered `recv*` / `send*` API backed by a connected socket.
27+
28+
Args:
29+
sock: A connected socket (already accepted).
30+
peer: Optional peer address tuple for display/logging.
31+
32+
Raises:
33+
ValueError: If `sock` is not a connected TCP socket.
34+
"""
35+
def __init__(self, sock: socket.socket, peer: AddressT | None = None, **kwargs):
36+
self._sock: socket.socket | None
37+
self._peer: AddressT | None
38+
self._sock = sock
39+
self._peer = peer
40+
41+
if not isinstance(sock, socket.socket) or sock.type != socket.SOCK_STREAM:
42+
raise ValueError("TCPConnection requires a connected TCP socket")
43+
44+
self._timeout: float | None = None
45+
super().__init__(**kwargs)
46+
self._logger = getLogger(__name__)
47+
48+
def __str__(self) -> str:
49+
try:
50+
return f"TCPConnection({self.remote_address})"
51+
except RuntimeError:
52+
return "TCPConnection(<closed>)"
53+
54+
# ---- Properties ------------------------------------------------------
55+
56+
@property
57+
def remote_address(self) -> AddressT:
58+
"""Return the peer (remote) address.
59+
60+
Raises:
61+
RuntimeError: If the socket is closed.
62+
"""
63+
if self._sock is None:
64+
raise RuntimeError("Connection is closed")
65+
66+
return self._sock.getpeername()
67+
68+
@property
69+
def local_address(self) -> AddressT:
70+
"""Return the local address.
71+
72+
Raises:
73+
RuntimeError: If the socket is closed.
74+
"""
75+
if self._sock is None:
76+
raise RuntimeError("Connection is closed")
77+
78+
return self._sock.getsockname()
79+
80+
# ---- Abstracts ------------------------------------------------------
81+
82+
def _recv_impl(self, blocksize: int) -> bytes:
83+
"""Receive up to ``blocksize`` bytes from the connection.
84+
85+
Returns:
86+
bytes: Received data. Empty only if peer performed an orderly shutdown.
87+
88+
Raises:
89+
EOFError: The peer closed the connection (orderly shutdown or reset).
90+
TimeoutError: The read operation timed out.
91+
OSError: Other OS-level socket errors.
92+
"""
93+
assert blocksize > 0, "BUG: blocksize must be positive"
94+
95+
if self._sock is None:
96+
raise EOFError("Socket is closed")
97+
98+
try:
99+
data = self._sock.recv(blocksize)
100+
if not data:
101+
raise EOFError("Connection closed by peer")
102+
return data
103+
104+
except socket.timeout as e:
105+
raise TimeoutError("Read operation timed out") from e
106+
107+
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e:
108+
raise EOFError("Connection reset by peer") from e
109+
110+
except OSError as e:
111+
if e.errno in (errno.ECONNRESET, errno.ENOTCONN, errno.ESHUTDOWN):
112+
raise EOFError("Socket not connected or shutdown") from e
113+
raise
114+
115+
def _send_impl(self, data: bytes) -> int:
116+
"""Send a chunk of bytes to the connection (single syscall).
117+
118+
Returns:
119+
int: Number of bytes written (may be less than ``len(data)``).
120+
121+
Raises:
122+
BrokenPipeError: The peer closed the write side / connection broken.
123+
TimeoutError: The write operation timed out.
124+
OSError: Other OS-level socket errors.
125+
"""
126+
if self._sock is None:
127+
raise BrokenPipeError("Connection is closed")
128+
129+
try:
130+
n = self._sock.send(data)
131+
if n == 0:
132+
raise BrokenPipeError("Connection is broken")
133+
return n
134+
135+
except socket.timeout as e:
136+
raise TimeoutError("Write operation timed out") from e
137+
138+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
139+
raise BrokenPipeError("Connection closed by peer") from e
140+
141+
except OSError as e:
142+
if e.errno in (errno.EPIPE, errno.ECONNRESET, errno.ENOTCONN, errno.ESHUTDOWN):
143+
raise BrokenPipeError("Socket not connected or shutdown") from e
144+
raise
145+
146+
def _close_impl(self):
147+
"""Close the connection and release resources.
148+
149+
This method is best-effort and suppresses close/shutdown errors.
150+
"""
151+
sock, self._sock = self._sock, None
152+
if sock is not None:
153+
with contextlib.suppress(Exception):
154+
sock.shutdown(socket.SHUT_RDWR)
155+
with contextlib.suppress(Exception):
156+
sock.close()
157+
158+
def _close_recv_impl(self):
159+
"""Half-close the receive side.
160+
161+
Raises:
162+
(never)
163+
"""
164+
if self._sock is not None:
165+
with contextlib.suppress(Exception):
166+
self._sock.shutdown(socket.SHUT_RD)
167+
168+
def _close_send_impl(self):
169+
"""Half-close the send side.
170+
171+
Raises:
172+
(never)
173+
"""
174+
if self._sock is not None:
175+
with contextlib.suppress(Exception):
176+
self._sock.shutdown(socket.SHUT_WR)
177+
178+
def _settimeout_impl(self, timeout: float):
179+
if self._sock is None:
180+
return
181+
182+
if timeout < 0:
183+
self._sock.settimeout(None)
184+
self._timeout = None
185+
else:
186+
self._sock.settimeout(timeout)
187+
self._timeout = timeout
188+
189+
def _gettimeout_impl(self) -> float:
190+
if self._timeout is None:
191+
return 0.0
192+
return self._timeout
193+
194+
def _is_alive_impl(self) -> bool:
195+
"""Check if the remote endpoint is still reachable.
196+
197+
Returns:
198+
bool: True if the connection is alive, False otherwise.
199+
"""
200+
if self._sock is None:
201+
return False
202+
203+
with self.timeout(-1):
204+
try:
205+
self._sock.setblocking(False)
206+
return self._sock.recv(1, socket.MSG_PEEK) == 1
207+
except (BlockingIOError, ValueError):
208+
# SSLSocket may raise ValueError but we treat it as alive
209+
return True
210+
except (ConnectionResetError, socket.timeout):
211+
return False
212+
finally:
213+
self._sock.setblocking(True)
214+
215+
216+
class Server:
217+
"""A TCP listening socket that accepts connections as :class:`TCPConnection`.
218+
219+
Thread-safe for concurrent ``accept()`` calls: multiple threads may call
220+
:meth:`accept` simultaneously on the same instance and each will obtain
221+
distinct client connections (kernel arbiters which waiter gets awakened).
222+
223+
Args:
224+
host: Bind address (e.g., "0.0.0.0", "::", or hostname).
225+
port: TCP port to listen on.
226+
backlog: Listen backlog.
227+
reuse_addr: Set SO_REUSEADDR (default True).
228+
reuse_port: Set SO_REUSEPORT if available (default False).
229+
dualstack: If True, prefer an IPv6 socket with IPV6_V6ONLY=0 to accept
230+
both IPv6 and IPv4 (platform-dependent).
231+
232+
Raises:
233+
OSError: Any OS-level failure during socket creation/bind/listen.
234+
ValueError: Invalid arguments.
235+
"""
236+
def __init__(self,
237+
host: str,
238+
port: int,
239+
*,
240+
backlog: int = 128,
241+
dualstack: bool = True):
242+
self._sock: socket.socket | None = None
243+
244+
# Choose an address family via getaddrinfo; prefer IPv6 dualstack if requested.
245+
family = socket.AF_UNSPEC
246+
infos = socket.getaddrinfo(host, port, family, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
247+
# Try IPv6 first (for dualstack), then IPv4.
248+
infos_sorted = sorted(
249+
infos,
250+
key=lambda ai: 0 if (ai[0] == socket.AF_INET6 and dualstack) else 1
251+
)
252+
253+
last_err: OSError | None = None
254+
for af, socktype, proto, _canon, sa in infos_sorted:
255+
s = socket.socket(af, socktype, proto)
256+
try:
257+
with contextlib.suppress(OSError):
258+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
259+
if hasattr(socket, 'SO_REUSEPORT'):
260+
# Some platform does not support SO_REUSEPORT
261+
with contextlib.suppress(OSError):
262+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
263+
264+
if af == socket.AF_INET6 and dualstack and hasattr(socket, "IPV6_V6ONLY"):
265+
with contextlib.suppress(OSError):
266+
# 0 => dualstack (accepts v4-mapped)
267+
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
268+
269+
s.bind(sa)
270+
s.listen(backlog)
271+
s.setblocking(False)
272+
self._sock = s
273+
return
274+
275+
except OSError as e:
276+
last_err = e
277+
with contextlib.suppress(Exception):
278+
s.close()
279+
continue
280+
281+
assert last_err is not None
282+
raise last_err
283+
284+
def __str__(self) -> str:
285+
try:
286+
return f"Server(listening on {self.address})"
287+
except RuntimeError:
288+
return "Server(<closed>)"
289+
290+
def __del__(self):
291+
self.close()
292+
293+
# --- Properties -------------------------------------------------------
294+
295+
@property
296+
def fd(self) -> int:
297+
"""Return the underlying listening FD.
298+
299+
Raises:
300+
RuntimeError: If the server is closed.
301+
"""
302+
if self._sock is None:
303+
raise RuntimeError("Server is closed")
304+
305+
return self._sock.fileno()
306+
307+
@property
308+
def address(self) -> AddressT:
309+
"""Return the bound (host, port[, flowinfo, scopeid]) address tuple.
310+
311+
Raises:
312+
RuntimeError: If the server is closed.
313+
"""
314+
if self._sock is None:
315+
raise RuntimeError("Server is closed")
316+
317+
return self._sock.getsockname()
318+
319+
def close(self) -> None:
320+
"""Close the listening socket.
321+
322+
Raises:
323+
(never)
324+
"""
325+
sock, self._sock = self._sock, None
326+
if sock is not None:
327+
with contextlib.suppress(Exception):
328+
sock.close()
329+
330+
def accept(self,
331+
accept_timeout: float | int | None = None,
332+
**kwargs) -> TCPConnection:
333+
"""Accept a single incoming connection and wrap it as :class:`TCPConnection`.
334+
335+
This method is safe to call concurrently from multiple threads.
336+
337+
Returns:
338+
TCPConnection: A Tube-like connection object for the accepted client.
339+
340+
Raises:
341+
TimeoutError: No connection arrived within ``timeout`` seconds.
342+
OSError: Accept failed due to an OS error (e.g., EMFILE/ENFILE).
343+
RuntimeError: Server is closed.
344+
"""
345+
if self._sock is None:
346+
raise RuntimeError("server is closed")
347+
348+
# Block in select() rather than changing SO timeout (thread-safe).
349+
rlist = [self._sock]
350+
while True:
351+
r, _, _ = select.select(rlist, [], [], accept_timeout)
352+
if not r:
353+
raise TimeoutError("accept timed out")
354+
355+
try:
356+
conn, addr = self._sock.accept()
357+
conn.setblocking(True) # connection-level I/O uses its own timeout
358+
break
359+
except BlockingIOError:
360+
# Raced: another thread accepted first; keep waiting.
361+
continue
362+
except InterruptedError:
363+
# Retry on EINTR
364+
continue
365+
366+
return TCPConnection(conn, addr, **kwargs)
367+
368+
__all__ = ['Server', 'TCPConnection']

0 commit comments

Comments
 (0)