-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
123 lines (101 loc) · 5.14 KB
/
server.py
File metadata and controls
123 lines (101 loc) · 5.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""
server.py - TCP Acceptor Loop
Owns the listening socket and hands accepted connections off to the thread
pool. It intentionally knows nothing about HTTP — that separation of concerns
means we could swap the protocol parser without touching this file.
Key socket decisions
---------------------
* AF_INET / SOCK_STREAM — IPv4 TCP; the foundation of HTTP/1.1.
* SO_REUSEADDR — Allows the server to rebind immediately after a
crash or fast restart. Without it the OS keeps the
port in TIME_WAIT for ~60 s and every restart fails
with "Address already in use".
* settimeout(1.0) — The accept() call blocks for at most 1 second
before raising socket.timeout. This lets the main
thread check _running periodically so Ctrl-C
(KeyboardInterrupt) is always handled promptly.
"""
import logging
import socket
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Type alias: a callable that handles one accepted client socket.
ConnectionHandler = Callable[[socket.socket], None]
class TCPServer:
"""
Listens on a TCP port and dispatches each accepted connection to a handler.
The handler callable is injected at construction time so the server is
decoupled from HTTP logic and can be unit-tested independently.
Args:
host: Interface to bind to. "0.0.0.0" listens on all
available interfaces.
port: TCP port number.
connection_handler: Called once per accepted connection. Runs in a
worker thread (managed by the caller's thread pool).
backlog: Maximum length of the pending-connection queue
passed to socket.listen(). 127 is a reasonable
default for a development server.
"""
def __init__(
self,
host: str,
port: int,
connection_handler: ConnectionHandler,
backlog: int = 127,
) -> None:
self.host: str = host
self.port: int = port
self._connection_handler: ConnectionHandler = connection_handler
self._backlog: int = backlog
self._running: bool = False
self._server_socket: Optional[socket.socket] = None
# ── Lifecycle ─────────────────────────────────────────────────────────
def start(self) -> None:
"""
Create the listening socket and enter the accept loop.
This method blocks until stop() is called (typically from a signal
handler or KeyboardInterrupt catcher in main.py).
"""
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDR: let the OS reuse a port that is in TIME_WAIT.
# Essential for rapid restarts during development.
self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._server_socket.bind((self.host, self.port))
self._server_socket.listen(self._backlog)
# Short timeout so accept() unblocks regularly and we can check
# _running without blocking forever on Ctrl-C.
self._server_socket.settimeout(1.0)
self._running = True
logger.info("Server listening on %s:%d", self.host, self.port)
try:
self._accept_loop()
finally:
self._server_socket.close()
logger.info("Server socket closed")
def stop(self) -> None:
"""Signal the accept loop to exit after the current timeout expires."""
logger.info("Server stop requested")
self._running = False
# ── Internal ──────────────────────────────────────────────────────────
def _accept_loop(self) -> None:
"""
Continuously accept new connections and pass them to the handler.
We catch socket.timeout in the loop body (not as a fatal error) because
it is the normal mechanism we use to periodically check _running.
Any other OSError on accept is logged and treated as recoverable so
a single bad accept() does not crash the server entirely.
"""
while self._running:
try:
client_socket, client_address = self._server_socket.accept()
logger.debug("Accepted connection from %s:%d", *client_address)
# Hand off to the pool immediately so we can loop back to
# accept() as fast as possible. The handler owns the socket
# from this point on and must close it when done.
self._connection_handler(client_socket)
except socket.timeout:
# Expected: accept() timed out so we can re-check _running.
continue
except OSError as exc:
if self._running:
logger.error("Error accepting connection: %s", exc)