Skip to content

Commit f2b84c5

Browse files
committed
feat(core): add TCP client/server modules
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 66389b6 commit f2b84c5

7 files changed

Lines changed: 689 additions & 0 deletions

File tree

‎src/xcp_storage/network/socket.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ def socket_receive(sock: socket.socket, buffer: bytearray, size: Optional[int] =
267267
def get_socket_family_str(sock: socket.socket) -> str:
268268
return _FAMILY_TO_STR.get(sock.family, "Unknown")
269269

270+
def get_socket_port(sock: socket.socket) -> Optional[int]:
271+
try:
272+
return sock.getsockname()[1]
273+
except OSError:
274+
return None
275+
270276
# ------------------------------------------------------------------------------
271277

272278
class Socket(contextlib.AbstractContextManager):
@@ -311,6 +317,10 @@ def close(self) -> None:
311317
def family_str(self) -> str:
312318
return get_socket_family_str(self.sock)
313319

320+
@property
321+
def port(self) -> Optional[int]:
322+
return get_socket_port(self.sock)
323+
314324
@property
315325
def timeout(self) -> Optional[float]:
316326
return self.sock.gettimeout()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Copyright (C) 2026 Vates SAS
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
15+
import contextlib
16+
import ssl
17+
from types import TracebackType
18+
19+
from xcp_storage.network.socket import (
20+
create_client_sock,
21+
Socket,
22+
SocketDisconnectedError,
23+
)
24+
from xcp_storage.utils.sync import wait_for_condition
25+
26+
from xcp_storage.typing import (
27+
Optional,
28+
override,
29+
Type,
30+
)
31+
32+
# ==============================================================================
33+
34+
class TcpClientError(Exception):
35+
def __init__(self, message: str) -> None:
36+
super().__init__(message)
37+
38+
# ------------------------------------------------------------------------------
39+
40+
class TcpClient(contextlib.AbstractContextManager):
41+
def __init__(
42+
self,
43+
address: str,
44+
port: int,
45+
ssl_context: Optional[ssl.SSLContext] = None,
46+
client_timeout: float = 120
47+
) -> None:
48+
self._address = address
49+
self._port = port
50+
self._ssl_context = ssl_context
51+
self._client_timeout = client_timeout
52+
self._socket: Optional[Socket] = None
53+
54+
self._entered_count = 0
55+
56+
def __del__(self) -> None:
57+
self.disconnect()
58+
59+
@override
60+
def __enter__(self) -> "TcpClient":
61+
self.connect()
62+
self._entered_count += 1
63+
return self
64+
65+
@override
66+
def __exit__(
67+
self,
68+
exc_type: Optional[Type[BaseException]],
69+
exc_value: Optional[BaseException],
70+
traceback: Optional[TracebackType]
71+
) -> None:
72+
self._entered_count -= 1
73+
if self._entered_count == 0:
74+
self.disconnect()
75+
76+
@property
77+
def socket(self) -> Optional[Socket]:
78+
return self._socket
79+
80+
@property
81+
def connected(self) -> bool:
82+
return self._socket is not None
83+
84+
def connect(self, timeout: Optional[float] = None) -> None:
85+
if self._socket:
86+
return
87+
88+
if timeout is None:
89+
# If the `connect` timeout is not set, we use the client one.
90+
timeout = self._client_timeout
91+
92+
error: Optional[Exception] = None
93+
def connect_impl() -> bool:
94+
nonlocal error
95+
try:
96+
self._socket = Socket(create_client_sock(
97+
self._address,
98+
self._port,
99+
reuse_address=True,
100+
keep_alive=True,
101+
timeout=self._client_timeout,
102+
ssl_context=self._ssl_context
103+
))
104+
except Exception as e:
105+
error = e
106+
return False
107+
return True
108+
109+
if not wait_for_condition(connect_impl, timeout=timeout, interval=1):
110+
raise TcpClientError("Unable to connect to server.") from error
111+
112+
def disconnect(self) -> None:
113+
if self._socket:
114+
self._socket.close()
115+
self._socket = None
116+
117+
def send(self, buffer: bytes, size: Optional[int] = None) -> None:
118+
if not self._socket:
119+
raise TcpClientError("Cannot send. Not connected.")
120+
121+
try:
122+
self._socket.send(buffer, size)
123+
except SocketDisconnectedError:
124+
self.disconnect()
125+
raise
126+
127+
def receive(self, buffer: bytearray, size: Optional[int] = None) -> None:
128+
if not self._socket:
129+
raise TcpClientError("Cannot receive. Not connected.")
130+
131+
try:
132+
self._socket.receive(buffer, size)
133+
except SocketDisconnectedError:
134+
self.disconnect()
135+
raise
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# Copyright (C) 2026 Vates SAS
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
15+
from abc import ABC, abstractmethod
16+
import asyncio
17+
import contextlib
18+
import ssl
19+
import sys
20+
import threading
21+
22+
import xcp_storage.log as log
23+
from xcp_storage.network.socket import create_server_sock, Socket
24+
from xcp_storage.utils.asyncio import cancel_event_loop_tasks, close_stream_writer
25+
26+
from xcp_storage.typing import (
27+
Any,
28+
Optional,
29+
override,
30+
Set,
31+
)
32+
33+
# ==============================================================================
34+
35+
logger = log.get_logger() # Use default logger.
36+
37+
# ------------------------------------------------------------------------------
38+
39+
class TcpServerError(Exception):
40+
def __init__(self, message: str) -> None:
41+
super().__init__(message)
42+
43+
# ------------------------------------------------------------------------------
44+
45+
class TcpServer(ABC):
46+
class Client:
47+
def __init__(
48+
self,
49+
peername: Any, # noqa: ANN401
50+
reader: asyncio.StreamReader,
51+
writer: asyncio.StreamWriter
52+
) -> None:
53+
self.peername: Any = peername
54+
self.reader = reader
55+
self.writer = writer
56+
57+
@override
58+
def __str__(self) -> str:
59+
return str(self.peername)
60+
61+
def __init__(self, address: str, port: int, ssl_context: Optional[ssl.SSLContext] = None) -> None:
62+
self._address = address
63+
self._port = port
64+
self._ssl_context = ssl_context
65+
66+
self._running = False
67+
68+
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
69+
self._startup_event = threading.Event()
70+
self._shutdown_event = threading.Event()
71+
72+
self._server_socket: Optional[Socket] = None
73+
self._server: Optional[asyncio.AbstractServer] = None
74+
75+
self._clients: Set[TcpServer.Client] = set()
76+
77+
@property
78+
def address(self) -> str:
79+
return self._address
80+
81+
@property
82+
def port(self) -> int:
83+
# Return trivial port if it's not 0. As reminder, 0 = dynamic binding.
84+
if self._port or not self._server_socket:
85+
return self._port
86+
87+
port = self._server_socket.port
88+
return port if port is not None else 0
89+
90+
def run(self) -> None: # noqa: C901
91+
if self._running:
92+
raise TcpServerError("Server is already running.")
93+
94+
self._startup_event.clear()
95+
self._shutdown_event.clear()
96+
self._running = True
97+
98+
old_event_loop = None
99+
try:
100+
logger.info("Running TCP server on `%s:%d`...", self._address, self._port)
101+
self._server_socket = Socket(create_server_sock(
102+
self._address,
103+
self._port,
104+
reuse_address=True,
105+
keep_alive=True,
106+
timeout=0.0, # 0 here to set non-blocking mode.
107+
ssl_context=self._ssl_context
108+
))
109+
110+
with contextlib.suppress(RuntimeError):
111+
old_event_loop = asyncio.get_event_loop()
112+
113+
self._event_loop = asyncio.new_event_loop()
114+
asyncio.set_event_loop(self._event_loop)
115+
116+
server_params = {
117+
"client_connected_cb": self._handle_client,
118+
"sock": self._server_socket.sock
119+
}
120+
121+
if sys.version_info < (3, 8):
122+
# TODO(XCPNG-3032): Workaround for old python versions. Remove me later.
123+
# In fact we must give `loop` param for these versions and the entire
124+
# event loop management is also required just for it.
125+
server_params["loop"] = self._event_loop
126+
127+
self._server = self._event_loop.run_until_complete(
128+
asyncio.start_server(**server_params) # type: ignore[arg-type]
129+
)
130+
self._startup_event.set()
131+
132+
logger.info("TCP server started!")
133+
self._event_loop.run_forever()
134+
except KeyboardInterrupt:
135+
logger.info("Closing server because break signal has been received...")
136+
except Exception:
137+
self._startup_event.set()
138+
raise
139+
finally:
140+
if self._server:
141+
self._server.close()
142+
# TODO(XCPNG-3032): Use `abort_clients` only.
143+
for client in list(self._clients):
144+
try:
145+
client.writer.transport.abort()
146+
except Exception as e: # noqa: PERF203
147+
logger.debug("Failed to abort client %s transport: `%s`.", client, e)
148+
self._server = None
149+
150+
if self._event_loop:
151+
try:
152+
cancel_event_loop_tasks(self._event_loop)
153+
finally:
154+
self._event_loop.close()
155+
self._event_loop = None
156+
if old_event_loop:
157+
asyncio.set_event_loop(old_event_loop)
158+
159+
if self._server_socket:
160+
self._server_socket.close()
161+
self._server_socket = None
162+
163+
self._clients.clear()
164+
self._running = False
165+
self._shutdown_event.set()
166+
167+
def stop(self) -> bool:
168+
if self.async_stop():
169+
self.wait_for_shutdown()
170+
return True
171+
return False
172+
173+
def async_stop(self) -> bool:
174+
if self._event_loop and self._event_loop.is_running():
175+
self._event_loop.call_soon_threadsafe(self._event_loop.stop)
176+
return True
177+
return False
178+
179+
def wait_for_startup(self) -> None:
180+
self._startup_event.wait()
181+
182+
def wait_for_shutdown(self) -> None:
183+
self._shutdown_event.wait()
184+
185+
async def _handle_client(
186+
self,
187+
client_reader: asyncio.StreamReader,
188+
client_writer: asyncio.StreamWriter
189+
) -> None:
190+
client = self.Client(client_writer.get_extra_info("peername"), client_reader, client_writer)
191+
logger.info("New client %s connected.", client)
192+
self._clients.add(client)
193+
194+
rejected = False
195+
try:
196+
if not await self._handle_client_connect(client):
197+
rejected = True
198+
return
199+
while not client_writer.transport.is_closing():
200+
if not await self._handle_client_request(client):
201+
break
202+
logger.info("Client %s has terminated.", client)
203+
except asyncio.TimeoutError as e:
204+
logger.warning("Timeout reached for client %s: `%s`.", client, e)
205+
except asyncio.IncompleteReadError as e:
206+
logger.warning("Connection closed for client %s: `%s`.", client, e)
207+
except Exception as e:
208+
logger.error("Unhandled exception for client %s: `%s`.", client, e)
209+
finally:
210+
if not rejected:
211+
try:
212+
await self._handle_client_disconnect(client)
213+
except Exception as e:
214+
logger.error("Unhandled exception for client %s during disconnect: `%s`.", client, e)
215+
216+
await close_stream_writer(client_writer)
217+
logger.info("Client %s disconnected.", client)
218+
self._clients.remove(client)
219+
220+
@abstractmethod
221+
async def _handle_client_connect(self, client: Client) -> bool:
222+
return False
223+
224+
@abstractmethod
225+
async def _handle_client_disconnect(self, client: Client) -> None:
226+
pass
227+
228+
@abstractmethod
229+
async def _handle_client_request(self, client: Client) -> bool:
230+
return False

0 commit comments

Comments
 (0)