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:
+59
-59
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user