Skip to content

Commit c91bf1b

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

7 files changed

Lines changed: 686 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
@@ -263,6 +263,12 @@ def socket_receive(sock: socket.socket, buffer: bytearray, size: Optional[int] =
263263
def get_socket_family_str(sock: socket.socket) -> str:
264264
return _FAMILY_TO_STR.get(sock.family, "Unknown")
265265

266+
def get_socket_port(sock: socket.socket) -> Optional[int]:
267+
try:
268+
return sock.getsockname()[1]
269+
except OSError:
270+
return None
271+
266272
# ------------------------------------------------------------------------------
267273

268274
class Socket(contextlib.AbstractContextManager):
@@ -307,6 +313,10 @@ def close(self) -> None:
307313
def family_str(self) -> str:
308314
return get_socket_family_str(self.sock)
309315

316+
@property
317+
def port(self) -> Optional[int]:
318+
return get_socket_port(self.sock)
319+
310320
@property
311321
def timeout(self) -> Optional[float]:
312322
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 self._socket:
119+
try:
120+
self._socket.send(buffer, size)
121+
except SocketDisconnectedError:
122+
self.disconnect()
123+
raise
124+
else:
125+
TcpClientError("Cannot send. Not connected.")
126+
127+
def receive(self, buffer: bytearray, size: Optional[int] = None) -> None:
128+
if self._socket:
129+
try:
130+
self._socket.receive(buffer, size)
131+
except SocketDisconnectedError:
132+
self.disconnect()
133+
raise
134+
else:
135+
TcpClientError("Cannot receive. Not connected.")
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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 ssl
18+
import sys
19+
import threading
20+
21+
import xcp_storage.log as log
22+
from xcp_storage.network.socket import create_server_sock, Socket
23+
from xcp_storage.utils.asyncio import cancel_event_loop_tasks, close_stream_writer
24+
25+
from xcp_storage.typing import (
26+
Any,
27+
Optional,
28+
override,
29+
Set,
30+
)
31+
32+
# ==============================================================================
33+
34+
logger = log.get_logger() # Use default logger.
35+
36+
# ------------------------------------------------------------------------------
37+
38+
class TcpServerError(Exception):
39+
def __init__(self, message: str) -> None:
40+
super().__init__(message)
41+
42+
# ------------------------------------------------------------------------------
43+
44+
class TcpServer(ABC):
45+
class Client:
46+
def __init__(
47+
self,
48+
peername: Any, # noqa: ANN401
49+
reader: asyncio.StreamReader,
50+
writer: asyncio.StreamWriter
51+
) -> None:
52+
self.peername: Any = peername
53+
self.reader = reader
54+
self.writer = writer
55+
56+
@override
57+
def __str__(self) -> str:
58+
return str(self.peername)
59+
60+
def __init__(self, address: str, port: int, ssl_context: Optional[ssl.SSLContext] = None) -> None:
61+
self._address = address
62+
self._port = port
63+
self._ssl_context = ssl_context
64+
65+
self._running = False
66+
67+
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
68+
self._startup_event = threading.Event()
69+
self._shutdown_event = threading.Event()
70+
71+
self._server_socket: Optional[Socket] = None
72+
self._server: Optional[asyncio.AbstractServer] = None
73+
74+
self._clients: Set[TcpServer.Client] = set()
75+
76+
@property
77+
def address(self) -> str:
78+
return self._address
79+
80+
@property
81+
def port(self) -> int:
82+
# Return trivial port if it's not 0. As reminder, 0 = dynamic binding.
83+
if self._port or not self._server_socket:
84+
return self._port
85+
86+
port = self._server_socket.port
87+
return port if port is not None else 0
88+
89+
def run(self) -> None: # noqa: C901
90+
if self._running:
91+
raise TcpServerError("Server is already running.")
92+
93+
self._startup_event.clear()
94+
self._shutdown_event.clear()
95+
self._running = True
96+
97+
try:
98+
logger.info("Running TCP server on `%s:%d`...", self._address, self._port)
99+
self._server_socket = Socket(create_server_sock(
100+
self._address,
101+
self._port,
102+
reuse_address=True,
103+
keep_alive=True,
104+
timeout=0.0, # 0 here to set non-blocking mode.
105+
ssl_context=self._ssl_context
106+
))
107+
108+
try:
109+
old_event_loop = asyncio.get_event_loop()
110+
except RuntimeError:
111+
# No event loop.
112+
old_event_loop = None
113+
114+
self._event_loop = asyncio.new_event_loop()
115+
asyncio.set_event_loop(self._event_loop)
116+
117+
server_params = {
118+
"client_connected_cb": self._handle_client,
119+
"sock": self._server_socket.sock
120+
}
121+
122+
if sys.version_info < (3, 8):
123+
# TODO(XCPNG-3032): Workaround for old python versions. Remove me later.
124+
# In fact we must give `loop` param for these versions and the entire
125+
# event loop management is also required just for it.
126+
server_params["loop"] = self._event_loop
127+
128+
self._server = self._event_loop.run_until_complete(
129+
asyncio.start_server(**server_params) # type: ignore[arg-type]
130+
)
131+
self._startup_event.set()
132+
133+
logger.info("TCP server started!")
134+
self._event_loop.run_forever()
135+
except KeyboardInterrupt:
136+
logger.info("Closing server because break signal has been received...")
137+
except Exception:
138+
self._startup_event.set()
139+
raise
140+
finally:
141+
if self._server:
142+
self._server.close()
143+
# TODO(XCPNG-3032): Use `abort_clients` only.
144+
for client in list(self._clients):
145+
try:
146+
client.writer.transport.abort()
147+
except Exception as e: # noqa: PERF203
148+
logger.debug("Failed to abort client %s transport: `%s`.", client, e)
149+
self._server = None
150+
151+
if self._event_loop:
152+
try:
153+
cancel_event_loop_tasks(self._event_loop)
154+
finally:
155+
self._event_loop.close()
156+
self._event_loop = None
157+
if old_event_loop:
158+
asyncio.set_event_loop(old_event_loop)
159+
160+
if self._server_socket:
161+
self._server_socket.close()
162+
self._server_socket = None
163+
164+
self._clients.clear()
165+
self._running = False
166+
self._shutdown_event.set()
167+
168+
def stop(self) -> bool:
169+
if self.async_stop():
170+
self.wait_for_shutdown()
171+
return True
172+
return False
173+
174+
def async_stop(self) -> bool:
175+
if self._event_loop and self._event_loop.is_running():
176+
self._event_loop.call_soon_threadsafe(self._event_loop.stop)
177+
return True
178+
return False
179+
180+
def wait_for_startup(self) -> None:
181+
self._startup_event.wait()
182+
183+
def wait_for_shutdown(self) -> None:
184+
self._shutdown_event.wait()
185+
186+
async def _handle_client(
187+
self,
188+
client_reader: asyncio.StreamReader,
189+
client_writer: asyncio.StreamWriter
190+
) -> None:
191+
client = self.Client(client_writer.get_extra_info("peername"), client_reader, client_writer)
192+
logger.info("New client %s connected.", client)
193+
self._clients.add(client)
194+
195+
rejected = False
196+
try:
197+
if not await self._handle_client_connect(client):
198+
rejected = True
199+
return
200+
while not client_writer.transport.is_closing():
201+
if not await self._handle_client_request(client):
202+
break
203+
logger.info("Client %s has terminated.", client)
204+
except asyncio.TimeoutError as e:
205+
logger.warning("Timeout reached for client %s: `%s`.", client, e)
206+
except asyncio.IncompleteReadError as e:
207+
logger.warning("Connection closed for client %s: `%s`.", client, e)
208+
except Exception as e:
209+
logger.error("Unhandled exception for client %s: `%s`.", client, e)
210+
finally:
211+
if not rejected:
212+
try:
213+
await self._handle_client_disconnect(client)
214+
except Exception as e:
215+
logger.error("Unhandled exception for client %s during disconnect: `%s`.", client, e)
216+
217+
await close_stream_writer(client_writer)
218+
logger.info("Client %s disconnected.", client)
219+
self._clients.remove(client)
220+
221+
@abstractmethod
222+
async def _handle_client_connect(self, client: Client) -> bool:
223+
return False
224+
225+
@abstractmethod
226+
async def _handle_client_disconnect(self, client: Client) -> None:
227+
pass
228+
229+
@abstractmethod
230+
async def _handle_client_request(self, client: Client) -> bool:
231+
return False

0 commit comments

Comments
 (0)