Answer each client in its own thread

The server handled one client at a time, so a client that connected and sent nothing blocked every other client. The request handling is now in handle_client, and the main loop starts one thread for each connection.

The server closes a connection that stays silent for 5 seconds, so its thread ends.
This commit is contained in:
tim
2026-10-10 10:59:34 +02:00
parent 6ee9bbaca8
commit f17d1d82c2
3 changed files with 163 additions and 154 deletions
+59 -59
View File
@@ -5,6 +5,7 @@ import struct
import subprocess
import sys
import threading
import time
from unittest.mock import patch
@@ -702,66 +703,20 @@ class TestServerMain:
finally:
self._stop_server(sock_path, t)
def test_keyboard_interrupt_closes_connection(self, tmp_path):
"""Test KeyboardInterrupt handler closes connection when connection is active."""
def test_keyboard_interrupt_ends_the_server(self, tmp_path):
"""An interrupt reaches main() while it waits for a client."""
sock_path = str(tmp_path / "server_kbi.sock")
ready = threading.Event()
from unittest.mock import MagicMock, patch
from bmspy.server import main as server_main, read_request as original_read_request
from bmspy.jbd_bms import JBDBMS
fake_bms = JBDBMS()
call_count = [0]
def _do_main_kbi():
argv = ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]
original_listen = socket_module.socket.listen
def _patched_listen(self, backlog=1):
result = original_listen(self, backlog)
ready.set()
return result
def _patched_read_request(conn, debug=0):
call_count[0] += 1
if call_count[0] >= 2:
raise KeyboardInterrupt("test interrupt")
return original_read_request(conn, debug)
with patch("sys.argv", argv), \
patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \
patch("bmspy.server.read_request", side_effect=_patched_read_request), \
patch.object(socket_module.socket, "listen", _patched_listen):
try:
server_main()
except SystemExit:
pass
t = threading.Thread(target=_do_main_kbi, daemon=True)
t.start()
ready.wait(timeout=2)
import time as _t
_t.sleep(0.2)
# Send first request to register call_count[0] = 1
try:
self._send_command(sock_path, {"command": "REGISTER", "client": "test"})
except Exception:
pass
_t.sleep(0.1)
# Send second request to trigger KeyboardInterrupt with active connection
try:
self._send_command(sock_path, {"command": "REGISTER", "client": "test2"})
except Exception:
pass
self._stop_server(sock_path, t)
with patch("sys.argv", ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]), \
patch("bmspy.server.signal.signal"), \
patch("bmspy.server.atexit.register"), \
patch("bmspy.server.initialise_serial"), \
patch("bmspy.server.start_polling"), \
patch("bmspy.server.os.path.isdir", return_value=True), \
patch.object(socket_module.socket, "accept", side_effect=KeyboardInterrupt):
from bmspy.server import main as server_main
with pytest.raises(SystemExit) as exit_info:
server_main()
assert exit_info.value.code == 1
def test_socket_read_error_logs_and_continues(self, tmp_path, capsys):
"""Test that read_request errors are caught and logged."""
@@ -1036,6 +991,51 @@ class TestServerMain:
self._stop_server(sock_path, t)
assert "socket ERROR: unable to encode response" in capsys.readouterr().out
def test_clients_are_served_at_the_same_time(self, tmp_path, monkeypatch):
"""A client that is slow to send its request must not delay another client."""
monkeypatch.setattr("bmspy.server.CLIENT_TIMEOUT_SECONDS", 30)
sock_path = str(tmp_path / "server_two.sock")
ready = threading.Event()
t = self._make_server_thread(sock_path, ready, threading.Event())
t.start()
ready.wait(timeout=2)
slow = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
slow.settimeout(5)
try:
# The first client connects and sends nothing yet.
slow.connect(sock_path)
start = time.monotonic()
response = self._send_command(sock_path, {"command": "REGISTER", "client": "fast"})
assert response == {"status": "REGISTERED", "client": "fast"}
assert time.monotonic() - start < 5
# The first connection is still open, and gets its own answer.
payload = json.dumps({"command": "DEVICES", "client": "slow"}).encode()
slow.sendall(struct.pack("!I", len(payload)) + payload)
length = struct.unpack("!I", slow.recv(4))[0]
assert json.loads(slow.recv(length)) == {"devices": ["ttyUSB0"]}
finally:
slow.close()
self._stop_server(sock_path, t)
def test_silent_client_is_disconnected_at_the_time_limit(self, tmp_path, capsys, monkeypatch):
"""A client that never sends a request must not keep its thread forever."""
monkeypatch.setattr("bmspy.server.CLIENT_TIMEOUT_SECONDS", 0.2)
sock_path = str(tmp_path / "server_silent.sock")
ready = threading.Event()
t = self._make_server_thread(sock_path, ready, threading.Event())
t.start()
ready.wait(timeout=2)
silent = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
silent.settimeout(5)
try:
silent.connect(sock_path)
# An empty read means that the server closed the connection.
assert silent.recv(1) == b""
finally:
silent.close()
self._stop_server(sock_path, t)
assert "socket ERROR: unable to read request length from socket: timed out" in capsys.readouterr().out
def test_failed_accept_still_ends_the_server(self, tmp_path):
"""A listening socket that fails must not become an endless loop."""
sock_path = str(tmp_path / "server_accept.sock")