From 5a95ea39086db130242e85968aa6196f45ffb53b Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Mon, 25 Aug 2025 17:37:27 -0600 Subject: [PATCH 1/2] pylint and pytest;fix multi-client connection issue --- .coverage | Bin 0 -> 69632 bytes .coveragerc | 13 ++ Makefile | 24 +++- features/steps/steps.py | 19 ++- jsocket/jsocket_base.py | 131 +++++++++--------- jsocket/tserver.py | 80 ++++++----- requirements-dev.txt | 3 + tests/test_additional_coverage.py | 156 ++++++++++++++++++++++ tests/test_e2e.py | 11 +- tests/test_listener_persistence.py | 5 +- tests/test_serverfactory_concurrent.py | 65 +++++++++ tests/test_serverfactory_serialization.py | 75 +++++++++++ 12 files changed, 472 insertions(+), 110 deletions(-) create mode 100644 .coverage create mode 100644 .coveragerc create mode 100644 tests/test_additional_coverage.py create mode 100644 tests/test_serverfactory_concurrent.py create mode 100644 tests/test_serverfactory_serialization.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..651261872cf95f072bd33be350c99548b0ee5439 GIT binary patch literal 69632 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 O_A!Ugv~vNKeg6e_#*hjC literal 0 HcmV?d00001 diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..129c150 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,13 @@ +[run] +branch = True +source = jsocket +omit = + */__init__.py + +[report] +show_missing = True +skip_covered = True +exclude_lines = + pragma: no cover + if __name__ == .__main__. + diff --git a/Makefile b/Makefile index ee3a35b..86588f1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,26 @@ -.PHONY: test-behave +.PHONY: test-behave test-pytest-cov test-behave-cov coverage lint test-behave: PYTHONPATH=. behave -f progress2 + +# Pytest coverage (terminal report) +test-pytest-cov: + pytest -q --cov=jsocket --cov-branch --cov-report=term-missing + +# Behave coverage (appends to same .coverage data) +test-behave-cov: + coverage run -a -m behave -f progress2 + +# Combined coverage: erase, run pytest+behave, show and export XML/HTML +coverage: + coverage erase + pytest -q --cov=jsocket --cov-branch --cov-report=term + coverage run -a -m behave -f progress2 + coverage report -m + coverage xml -o coverage.xml + coverage html -d .coverage_html + +# Static analysis with pylint; fail if score below threshold +lint: + mkdir -p .pylint.d + PYLINTHOME=.pylint.d pylint jsocket tests features/steps --fail-under=9.0 --persistent=n diff --git a/features/steps/steps.py b/features/steps/steps.py index 2444bbe..8245fb7 100644 --- a/features/steps/steps.py +++ b/features/steps/steps.py @@ -1,3 +1,10 @@ +"""Behave step implementations for json socket scenarios. + +Note: Pylint is unaware of Behave's decorator callables, so we disable the +"not-callable" check for this file. +""" +# pylint: disable=not-callable, missing-function-docstring + import json import logging import time @@ -12,8 +19,10 @@ class MyServer(jsocket.ThreadedServer): + """Simple echo server used by Behave scenarios.""" + def __init__(self, **kwargs): - super(MyServer, self).__init__(**kwargs) + super().__init__(**kwargs) self.timeout = 2.0 def _process_message(self, obj): @@ -60,7 +69,7 @@ def server_sends_object(context, obj): def client_sees_message(context, obj): expected = json.loads(obj) msg = context.jsonclient.read_obj() - assert msg == expected, "%s" % expected + assert msg == expected, f"{expected}" @then(r"within (\d+(?:\.\d+)?) seconds the server is connected") @@ -138,6 +147,8 @@ def client_attempts_read_with_timeout(context, seconds): def client_read_fails(context): e = getattr(context, 'client_read_exception', None) # Either a socket.timeout or a RuntimeError("socket connection broken") is acceptable - assert e is not None, "client read unexpectedly succeeded: %r" % getattr(context, 'client_read_value', None) - acceptable = isinstance(e, socket.timeout) or (isinstance(e, RuntimeError) and 'socket connection broken' in str(e)) + assert e is not None, f"client read unexpectedly succeeded: {getattr(context, 'client_read_value', None)!r}" + acceptable = isinstance(e, socket.timeout) or ( + isinstance(e, RuntimeError) and 'socket connection broken' in str(e) + ) assert acceptable, f"unexpected exception type: {type(e)} {e}" diff --git a/jsocket/jsocket_base.py b/jsocket/jsocket_base.py index 7d612c5..50016c8 100644 --- a/jsocket/jsocket_base.py +++ b/jsocket/jsocket_base.py @@ -30,30 +30,37 @@ logger = logging.getLogger("jsocket") -class JsonSocket(object): +class JsonSocket: + """Lightweight JSON-over-TCP socket wrapper with length-prefixed framing.""" + def __init__(self, address='127.0.0.1', port=5489, timeout=2.0): self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.conn = self.socket self._timeout = timeout self._address = address self._port = port + # Ensure the primary socket respects timeout for accept/connect operations + self.socket.settimeout(self._timeout) def send_obj(self, obj): + """Send a JSON-serializable object over the connection.""" msg = json.dumps(obj, ensure_ascii=False) if self.socket: payload = msg.encode('utf-8') - frmt = "=%ds" % len(payload) + frmt = f"={len(payload)}s" packed_msg = struct.pack(frmt, payload) packed_hdr = struct.pack('!I', len(packed_msg)) self._send(packed_hdr) self._send(packed_msg) def _send(self, msg): + """Send all bytes in `msg` to the peer.""" sent = 0 while sent < len(msg): sent += self.conn.send(msg[sent:]) def _read(self, size): + """Read exactly `size` bytes from the peer or raise on disconnect.""" data = b'' while len(data) < size: data_tmp = self.conn.recv(size - len(data)) @@ -63,51 +70,80 @@ def _read(self, size): return data def _msg_length(self): + """Read and unpack the 4-byte big-endian length header.""" d = self._read(4) s = struct.unpack('!I', d) return s[0] def read_obj(self): + """Read a full message and decode it as JSON, returning a Python object.""" size = self._msg_length() data = self._read(size) - frmt = "=%ds" % size + frmt = f"={size}s" msg = struct.unpack(frmt, data) return json.loads(msg[0].decode('utf-8')) def close(self): + """Close active connection and the listening socket if open.""" logger.debug("closing all connections") self._close_connection() self._close_socket() def _close_socket(self): + """Best-effort shutdown and close of the main socket.""" logger.debug("closing main socket") - if self.socket.fileno() != -1: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() + try: + if self.socket and self.socket.fileno() != -1: + try: + self.socket.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self.socket.close() + except OSError: + pass + except OSError: + pass def _close_connection(self): + """Best-effort shutdown and close of the accepted connection socket.""" logger.debug("closing the connection socket") - if self.conn.fileno() != -1: - self.conn.shutdown(socket.SHUT_RDWR) - self.conn.close() + try: + if self.conn and self.conn is not self.socket and self.conn.fileno() != -1: + try: + self.conn.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self.conn.close() + except OSError: + pass + except OSError: + pass def _get_timeout(self): + """Get the current socket timeout in seconds.""" return self._timeout def _set_timeout(self, timeout): + """Set the socket timeout in seconds and apply to the main socket.""" self._timeout = timeout self.socket.settimeout(timeout) def _get_address(self): + """Return the configured bind address.""" return self._address def _set_address(self, address): + """No-op: address is read-only after initialization.""" pass def _get_port(self): + """Return the configured bind port.""" return self._port def _set_port(self, port): + """No-op: port is read-only after initialization.""" pass timeout = property(_get_timeout, _set_timeout, doc='Get/set the socket timeout') @@ -116,97 +152,58 @@ def _set_port(self, port): class JsonServer(JsonSocket): + """Server socket that accepts one connection at a time.""" + def __init__(self, address='127.0.0.1', port=5489): - super(JsonServer, self).__init__(address, port) + super().__init__(address, port) self._bind() def _bind(self): self.socket.bind((self.address, self.port)) def _listen(self): - self.socket.listen(1) + self.socket.listen(5) def _accept(self): return self.socket.accept() def accept_connection(self): + """Listen and accept a single client connection; set timeout accordingly.""" self._listen() self.conn, addr = self._accept() self.conn.settimeout(self.timeout) - logger.debug("connection accepted, conn socket (%s,%d,%d)" % (addr[0], addr[1], self.conn.gettimeout())) + logger.debug( + "connection accepted, conn socket (%s,%d,%s)", addr[0], addr[1], str(self.conn.gettimeout()) + ) + + def _reset_connection_ref(self): + """Reset the server's connection reference to the listening socket.""" + self.conn = self.socket def _is_connected(self): try: return (self.conn is not None) and (self.conn is not self.socket) and (self.conn.fileno() != -1) - except Exception: + except (OSError, AttributeError): return False connected = property(_is_connected, doc="True if server has an active client connection") class JsonClient(JsonSocket): + """Client socket for connecting to a JsonServer and exchanging JSON messages.""" + def __init__(self, address='127.0.0.1', port=5489): - super(JsonClient, self).__init__(address, port) + super().__init__(address, port) def connect(self): - for i in range(10): + """Attempt to connect to the server up to 10 times with backoff.""" + for _ in range(10): try: self.socket.connect((self.address, self.port)) except socket.error as msg: - logger.error("SockThread Error: %s" % msg) + logger.error("SockThread Error: %s", msg) time.sleep(3) continue logger.info("...Socket Connected") return True return False - - -if __name__ == "__main__": - """ basic json echo server """ - import threading - logger.setLevel(logging.DEBUG) - FORMAT = '[%(asctime)-15s][%(levelname)s][%(module)s][%(funcName)s] %(message)s' - logging.basicConfig(format=FORMAT) - - def server_thread(): - logger.debug("starting JsonServer") - server = JsonServer() - server.accept_connection() - while 1: - try: - msg = server.read_obj() - logger.info("server received: %s" % msg) - server.send_obj(msg) - except socket.timeout as e: - logger.debug("server socket.timeout: %s" % e) - continue - except Exception as e: - logger.error("server: %s" % e) - break - - server.close() - - t = threading.Timer(1, server_thread) - t.start() - - time.sleep(2) - logger.debug("starting JsonClient") - - client = JsonClient() - client.connect() - - i = 0 - while i < 10: - client.send_obj({"i": i}) - try: - msg = client.read_obj() - logger.info("client received: %s" % msg) - except socket.timeout as e: - logger.debug("client socket.timeout: %s" % e) - continue - except Exception as e: - logger.error("client: %s" % e) - break - i = i + 1 - - client.close() diff --git a/jsocket/tserver.py b/jsocket/tserver.py index 2bed77b..e438ed4 100644 --- a/jsocket/tserver.py +++ b/jsocket/tserver.py @@ -22,22 +22,24 @@ """ __version__ = "1.0.3" -import jsocket.jsocket_base as jsocket_base import threading import socket import time import logging import abc from typing import Optional +from jsocket import jsocket_base logger = logging.getLogger("jsocket.tserver") class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.ABCMeta): + """Single-threaded server that accepts one connection and processes messages in its thread.""" + def __init__(self, **kwargs): threading.Thread.__init__(self) jsocket_base.JsonServer.__init__(self, **kwargs) - self._isAlive = False + self._is_alive = False @abc.abstractmethod def _process_message(self, obj) -> Optional[dict]: @@ -52,22 +54,22 @@ def _process_message(self, obj) -> Optional[dict]: return None def run(self): - while self._isAlive: + while self._is_alive: try: self.accept_connection() except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) + logger.debug("socket.timeout: %s", e) continue - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught # Avoid noisy error logs during normal shutdown/sequencing - if self._isAlive: + if self._is_alive: logger.debug("accept_connection error: %s", e) else: logger.debug("server stopping; accept loop exiting") break continue - while self._isAlive: + while self._is_alive: try: obj = self.read_obj() resp_obj = self._process_message(obj) @@ -75,9 +77,9 @@ def run(self): logger.debug("message has a response") self.send_obj(resp_obj) except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) + logger.debug("socket.timeout: %s", e) continue - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught # Treat client disconnects as normal; keep logs at info/debug msg = str(e) if isinstance(e, RuntimeError) and 'socket connection broken' in msg: @@ -89,7 +91,7 @@ def run(self): # Ensure sockets are cleaned up when the server stops try: self.close() - except Exception: + except OSError: pass def start(self): @@ -98,8 +100,8 @@ def start(self): @retval None """ - self._isAlive = True - super(ThreadedServer, self).start() + self._is_alive = True + super().start() logger.debug("Threaded Server has been started.") def stop(self): @@ -108,15 +110,17 @@ def stop(self): @retval None """ - self._isAlive = False + self._is_alive = False logger.debug("Threaded Server has been stopped.") class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=abc.ABCMeta): + """Per-connection worker thread used by ServerFactory.""" + def __init__(self, **kwargs): threading.Thread.__init__(self, **kwargs) jsocket_base.JsonSocket.__init__(self, **kwargs) - self._isAlive = False + self._is_alive = False def swap_socket(self, new_sock): """ Swaps the existing socket with a new one. Useful for setting socket after a new connection. @@ -124,7 +128,6 @@ def swap_socket(self, new_sock): @param new_sock socket to replace the existing default jsocket.JsonSocket object @retval None """ - del self.socket self.socket = new_sock self.conn = self.socket @@ -132,7 +135,7 @@ def run(self): """ Should exit when client closes socket conn. Can force an exit with force_stop. """ - while self._isAlive: + while self._is_alive: try: obj = self.read_obj() resp_obj = self._process_message(obj) @@ -140,11 +143,11 @@ def run(self): logger.debug("message has a response") self.send_obj(resp_obj) except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) + logger.debug("socket.timeout: %s", e) continue - except Exception as e: - logger.info("client connection broken, exit and close connection socket") - self._isAlive = False + except Exception as e: # pylint: disable=broad-exception-caught + logger.info("client connection broken, closing connection: %s", e) + self._is_alive = False break self._close_connection() @@ -164,8 +167,8 @@ def start(self): @retval None """ - self._isAlive = True - super(ServerFactoryThread, self).start() + self._is_alive = True + super().start() logger.debug("ServerFactoryThread has been started.") def force_stop(self): @@ -175,11 +178,12 @@ def force_stop(self): @retval None """ - self._isAlive = False + self._is_alive = False logger.debug("ServerFactoryThread has been stopped.") class ServerFactory(ThreadedServer): + """Accepts clients and spawns a ServerFactoryThread per connection.""" def __init__(self, server_thread, **kwargs): ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port']) if not issubclass(server_thread, ServerFactoryThread): @@ -195,20 +199,24 @@ def _process_message(self, obj) -> Optional[dict]: return None def run(self): - while self._isAlive: + while self._is_alive: tmp = self._thread_type(**self._thread_args) self._purge_threads() - while not self.connected and self._isAlive: + while not self.connected and self._is_alive: try: self.accept_connection() except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) + logger.debug("socket.timeout: %s", e) continue - except Exception as e: - logger.exception(e) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("accept error: %s", e) continue else: - tmp.swap_socket(self.conn) + # Hand off the accepted connection to the worker + accepted_conn = self.conn + # Reset server connection reference so we can accept again + self._reset_connection_ref() + tmp.swap_socket(accepted_conn) tmp.start() self._threads.append(tmp) break @@ -223,9 +231,17 @@ def stop_all(self): t.join() def _purge_threads(self): - for t in self._threads: - if not t.is_alive(): - self._threads.remove(t) + # Rebuild list to avoid mutating while iterating + self._threads = [t for t in self._threads if t.is_alive()] + + def stop(self): + # Stop accepting and stop all workers + self._is_alive = False + try: + self.stop_all() + except Exception: # pylint: disable=broad-exception-caught + pass + logger.debug("ServerFactory has been stopped.") def _wait_to_exit(self): while self._get_num_of_active_threads(): diff --git a/requirements-dev.txt b/requirements-dev.txt index 09e1af7..e5c0dab 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,6 @@ behave>=1.2.6 pytest>=7.0 pytest-timeout>=2.1 +coverage>=7.5 +pytest-cov>=4.1 +pylint>=3.2 diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py new file mode 100644 index 0000000..9bdda6d --- /dev/null +++ b/tests/test_additional_coverage.py @@ -0,0 +1,156 @@ +"""Additional tests to exercise error/edge paths for higher coverage. + +These tests avoid real network I/O by monkeypatching/stubbing where possible. +""" + +import threading +import time +import socket +import pytest + +import jsocket + + +def test_client_connect_failure_returns_false(monkeypatch): + """JsonClient.connect should return False after repeated failures.""" + + def fail_connect(self, addr): # pylint: disable=unused-argument + raise OSError("boom") + + # Short-circuit sleeps so the test is fast + monkeypatch.setattr(jsocket.jsocket_base.time, "sleep", lambda *_: None) + monkeypatch.setattr(socket.socket, "connect", fail_connect, raising=True) + + try: + client = jsocket.JsonClient(address="127.0.0.1", port=9) + except PermissionError as e: # sandboxed environments may forbid sockets + pytest.skip(f"Socket creation blocked: {e}") + assert client.connect() is False + + +def test_close_idempotent_and_connected_guard(): + """close() is safe to call multiple times; connected guard tolerates None.""" + # Client close idempotence + try: + c = jsocket.JsonClient(address="127.0.0.1", port=0) + except PermissionError as e: # sandboxed environments may forbid sockets + pytest.skip(f"Socket creation blocked: {e}") + c.close() + c.close() # should not raise + + # Server connected property guard (None conn) + try: + s = jsocket.JsonServer(address="127.0.0.1", port=0) + except PermissionError as e: # sandboxed environments may forbid sockets + pytest.skip(f"Socket creation blocked: {e}") + s.conn = None + assert s.connected is False + s.close() + + +def test_threadedserver_timeout_then_exception_triggers_close(monkeypatch): + """ThreadedServer should ignore timeouts and close on generic exceptions.""" + + class ProbeServer(jsocket.ThreadedServer): + def __init__(self): + # Do not call super to avoid binding sockets + threading.Thread.__init__(self) + self._is_alive = True + self._close_calls = 0 + self._reads = iter([ + lambda: (_ for _ in ()).throw(socket.timeout("t")), + lambda: (_ for _ in ()).throw(ValueError("boom")), + ]) + + def accept_connection(self): + # No-op; simulate an accepted connection + return None + + def read_obj(self): + # First a timeout, then an exception, then timeouts until stopped + st = getattr(self, "_state", 0) + if st == 0: + self._state = 1 + raise socket.timeout("t") + if st == 1: + self._state = 2 + raise ValueError("boom") + raise socket.timeout("t") + + def _process_message(self, obj): # pragma: no cover - not reached + return None + + def _close_connection(self): + self._close_calls += 1 + + def close(self): + # Avoid base close touching real sockets + return None + + srv = ProbeServer() + srv.start() + # Let the loop process the two read attempts + time.sleep(0.1) + srv.stop() + srv.join(timeout=1.0) + # One close due to ValueError path + assert srv._close_calls == 1 + + +def test_serverfactorythread_exception_closes_connection(): + """ServerFactoryThread should close the connection when handler raises.""" + + class BoomWorker(jsocket.ServerFactoryThread): + def __init__(self): + # Avoid base JsonSocket init + threading.Thread.__init__(self) + self._is_alive = True + self.closed = False + + def _process_message(self, obj): # pylint: disable=unused-argument + raise ValueError("boom") + + def read_obj(self): + return {"echo": 1} + + def _close_connection(self): + self.closed = True + + w = BoomWorker() + w.start() + w.join(timeout=1.0) + assert w.closed is True + + +def test_serverfactory_accept_error_branch(monkeypatch): + """ServerFactory should continue on accept() errors and then stop cleanly.""" + + class EchoWorker(jsocket.ServerFactoryThread): + def __init__(self): + threading.Thread.__init__(self) + self._is_alive = False + + def _process_message(self, obj): # pragma: no cover - not used here + return None + + # Real factory to get run loop; we'll stub accept_connection to raise + try: + server = jsocket.ServerFactory(EchoWorker, address="127.0.0.1", port=0) + except PermissionError as e: # sandboxed environments may forbid sockets + pytest.skip(f"Socket creation blocked: {e}") + + calls = {"n": 0} + + def flappy_accept(): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("accept failed") + # On second call, request stop + server._is_alive = False + + monkeypatch.setattr(server, "accept_connection", flappy_accept) + + t = threading.Thread(target=server.run, daemon=True) + t.start() + t.join(timeout=1.0) + assert calls["n"] >= 1 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d4cd431..85b6f34 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,20 +1,22 @@ +"""Pytest end-to-end tests for basic client/server echo.""" + import time -import socket import pytest import jsocket class EchoServer(jsocket.ThreadedServer): + """Minimal echo server for tests.""" def __init__(self, **kwargs): super().__init__(**kwargs) self.timeout = 2.0 - self.isConnected = False + self.is_connected = False def _process_message(self, obj): if obj != '': if obj.get('message') == 'new connection': - self.isConnected = True + self.is_connected = True # echo back if present if 'echo' in obj: return obj @@ -23,6 +25,7 @@ def _process_message(self, obj): @pytest.mark.timeout(10) def test_end_to_end_echo_and_connection(): + """Server accepts a connection and echoes payloads end-to-end.""" try: server = EchoServer(address='127.0.0.1', port=0) except PermissionError as e: @@ -39,7 +42,7 @@ def test_end_to_end_echo_and_connection(): # Signal connection and wait briefly for server to process client.send_obj({"message": "new connection"}) time.sleep(0.2) - assert server.isConnected is True + assert server.is_connected is True # Echo round-trip payload = {"echo": "hello", "i": 1} diff --git a/tests/test_listener_persistence.py b/tests/test_listener_persistence.py index c5ec80f..1f57264 100644 --- a/tests/test_listener_persistence.py +++ b/tests/test_listener_persistence.py @@ -1,11 +1,13 @@ +"""Pytest: server should accept multiple clients sequentially without restart.""" + import time import pytest -import socket import jsocket class EchoServer(jsocket.ThreadedServer): + """Echo server used to verify listener persistence.""" def __init__(self, **kwargs): super().__init__(**kwargs) self.timeout = 1.0 @@ -53,4 +55,3 @@ def test_server_accepts_multiple_clients_sequentially(): finally: server.stop() server.join(timeout=3) - diff --git a/tests/test_serverfactory_concurrent.py b/tests/test_serverfactory_concurrent.py new file mode 100644 index 0000000..057381d --- /dev/null +++ b/tests/test_serverfactory_concurrent.py @@ -0,0 +1,65 @@ +"""Pytest: ServerFactory should handle two clients concurrently.""" + +import time +import pytest + +import jsocket + + +class EchoWorker(jsocket.ServerFactoryThread): + """Worker that echoes messages containing 'echo' key.""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.timeout = 1.0 + + def _process_message(self, obj): + if isinstance(obj, dict) and 'echo' in obj: + return obj + return None + + +@pytest.mark.timeout(15) +def test_serverfactory_handles_two_clients_concurrently(): + """Two clients can connect and receive echoes concurrently.""" + try: + server = jsocket.ServerFactory(EchoWorker, address='127.0.0.1', port=0) + except PermissionError as e: + pytest.skip(f"Socket creation blocked: {e}") + + _, port = server.socket.getsockname() + server.start() + + try: + c1 = jsocket.JsonClient(address='127.0.0.1', port=port) + c2 = jsocket.JsonClient(address='127.0.0.1', port=port) + assert c1.connect() is True + assert c2.connect() is True + c1.timeout = 1.5 + c2.timeout = 1.5 + + # Send from both clients without closing the first + p1 = {"echo": "alpha"} + p2 = {"echo": "beta"} + c1.send_obj(p1) + c2.send_obj(p2) + + # Both should receive echoes without waiting for the other to disconnect + r1 = c1.read_obj() + r2 = c2.read_obj() + assert r1 == p1 + assert r2 == p2 + + # At some point, we should have at least two active workers + time.sleep(0.2) + assert getattr(server, 'active', 0) >= 2 + + c1.close() + c2.close() + finally: + try: + if hasattr(server, 'stop_all'): + server.stop_all() + except Exception: + pass + server.stop() + server.join(timeout=3) diff --git a/tests/test_serverfactory_serialization.py b/tests/test_serverfactory_serialization.py new file mode 100644 index 0000000..adc14f8 --- /dev/null +++ b/tests/test_serverfactory_serialization.py @@ -0,0 +1,75 @@ +"""Pytest: ServerFactory concurrency behavior (updated to allow multiple clients).""" + +import time +import pytest + +import jsocket + + +class EchoWorker(jsocket.ServerFactoryThread): + """Worker that echoes messages containing 'echo' key.""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.timeout = 1.0 + + def _process_message(self, obj): + # Echo payloads that include 'echo' for verification + if isinstance(obj, dict) and 'echo' in obj: + return obj + return None + + +@pytest.mark.timeout(15) +def test_serverfactory_accepts_multiple_active_clients_concurrently(): + """ServerFactory accepts a second client while the first is active. + + Updated behavior: ServerFactory accepts additional clients while one is + already active, starting a new worker per connection. + """ + try: + server = jsocket.ServerFactory(EchoWorker, address='127.0.0.1', port=0) + except PermissionError as e: + pytest.skip(f"Socket creation blocked: {e}") + + # Discover ephemeral port and start server + _, port = server.socket.getsockname() + server.start() + + try: + # First client establishes a connection and gets echoed responses + c1 = jsocket.JsonClient(address='127.0.0.1', port=port) + assert c1.connect() is True + c1.timeout = 1.0 + payload1 = {"echo": "one"} + c1.send_obj(payload1) + echoed1 = c1.read_obj() + assert echoed1 == payload1 + + # Second client connects while the first is still active + c2 = jsocket.JsonClient(address='127.0.0.1', port=port) + assert c2.connect() is True + c2.timeout = 1.0 + payload2 = {"echo": "two"} + c2.send_obj(payload2) + + # Both clients should receive responses + echoed2 = c2.read_obj() + assert echoed2 == payload2 + + # Active workers should reach at least 2 + time.sleep(0.2) + assert getattr(server, 'active', 0) >= 2 + + # Clean up clients + c2.close() + c1.close() + finally: + # Stop the server thread and join + try: + # Ensure any worker threads are stopped to avoid hangs + if hasattr(server, 'stop_all'): + server.stop_all() + except Exception: + pass + server.stop() + server.join(timeout=3) From fd25bb164ae3ccbf14e001fdb15a807ac0b2bec1 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Mon, 25 Aug 2025 20:30:01 -0600 Subject: [PATCH 2/2] fix tests --- .coverage | Bin 69632 -> 69632 bytes coverage.xml | 310 +++++++++++++++++++++++++++++++++++++++++++++ jsocket/tserver.py | 8 ++ 3 files changed, 318 insertions(+) create mode 100644 coverage.xml diff --git a/.coverage b/.coverage index 651261872cf95f072bd33be350c99548b0ee5439..f1edc58b345f47f5743c1239f427994b33f23427 100644 GIT binary patch literal 69632 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 E0eim}%>V!Z literal 69632 zcmeI433L=yy2o$zQg^B9E)XGvuyhbe0tpcIBy3@mCG0x}LML=cBuPUycBBdv6&-X2 zMh6w14vvbrJlt^H5L|E>#nD+%6diFz$7Nh*bew+Qt-7}YGs8Qc^WHn}jC1>t;CK3cfY#*H8ZD|R5vDKmC5?rcw?+TYK1fnjf}+*LKJ>l!;gGnfRGgMS38Z*h7|Rl zJ27I-L8g8svKB?AT6yO6ksiiNW-Yzk*oEV?0#>M(azHtt98eDYdplrHH*s2ey5_8J zjF;CW8tdZ~iF)sMWb}-&#k0o7W)+Vr85{GK#j;rpUU_-3;#hrhWo&t(K2}*>lZaK< zEvc@EH&)jzjWt#!me*I;#_LzdmL*o_ zx*LdhYfP+a^cKKD64guV_!hBjv3pLeK2e#dPt;W;8oaZ}u3nPEayDJWa9W!-+Lnad zrg(h?{4>sI4J>GB%7jWdP(`wS2|xSt`gmPMRiYs`7WZ3Nk%aSH)#$ahK3UtRF&V3= zu5+7N*HGP9ove!`RwXK$8WT(YX#;b)Y4Qc;o!%Na)?(hE#onIcy-stdW#@mgr`TNV zTnQ&vmz1`)?8x65c6QIXuBo;hEWfa+b~(Q|u!r}?jmfj^dA5BUlcKTT``Y}IYuVD? zGcKpJoL^CIuco@{6-^2M;^)R%_RMvgQJ2h(@kT%o=x*p~1#L70y)vwlXvB3v=MyK%?pQY>Y^=I!l{4P1OlkmH! zrY4rGbZ;5Hcg(vu(3?-cXZlDp;`RJSizUT|aHjvSy^WtNoI|XiQZzZ7Z(1%I}&uAe5$97?Q*uR<9C6wd>@&euH&?f z49%f#HxgG%{K02*A$cYLNf?!@Y7_B>rg|Aj{<|P4_h^Zd@*WXupgDP1Pb2_)qwZM(`yqx4_>G1mFMkm&Y5cV*hyVd@-DOR#;CfYJ0X5 z=ciDX&wuNiE(o}%yd~XrzZQZ%{|TOP%6v)7DNjnQ&TEd>Kr~q%udat%7GkhB0>t=+ zOX9F`ygb>|==NVO-R{Z9o|g0V4$z{d_pnutyE)w6(~=Dh)iA8`VXL7~E{$bX#T$4O zYRHO}CzCaac%3&kz#bDC;61AwV(vNo=?^}o3a6J(Z4&UJg8X9BbGdV@8#eLKWc4!iFdp6 zi*ni#S5_td;c(t)G$!7dXsoVH#P}xe$+#VBWxOF)QJ;X7FqYHlz^_(WKL5?1 zk_k`c>@FHk>(xuMo5aoN#hJ1O7}RUy^0b8voY6`CZe>eulmp5E<-iZm zfuI)9Fn|6JSZ^ZhkMK*qlmp5E<$!WPIiMU+4k!nd1IhvAfO0@Npd9#@bifP*J6W>A zBfpgv%t#kC0E2ty_w7B9L8}7R`^b9V`to10f$F4{1IhvAfO0@Npd3&RC_#3@T;;ywCr)A?rzNo4>IF<$!WPIiMU+4k!nd1IhvAfO0@N zpd3&RC^Cac^5XouZCpBvP5GaVE1ohL!us@ z;^99%DA0mv_4nLM>|ItKZ%Dwd-sk^UA?qpYs{hM|sbeb#lmp5E<$!WPIiMU+4k!nd z1IhvAfO0@N(Bi@Px39#|AB(wYWh9*`n%_U_5c61c&|t~pd3&RCulmp5E<$!YF2kAfu zlmp5E<$!WPIiMU+4k!nd1ImGaUk6$_1p#QiX8nb{$`oMbKddYgydeFMVy3yKUU1qJfR#*vZfi=ws*UjDEd|Oqv+ewSEEOxPemV&?vLIY-4)#)y*#=x+8nKk#-nqilcS@f1<^jy z?$L~B>nM(V7x`16%A{R&2Mpi_YMixe9MkYi?LA*ne8+sve93&;e8jxRyv^Kg?l8BSwz<-*H7m^d=2Ua6S!Cv$S!O4* zjY*A^@uhLfc+2>m@w{=!xZl`o>^6R4Y%$gvNu$D;YfLgm7z2!KqodKv2(dr2kJy{+ z6?TL@#O`D_vh8dOTf=JEVm6bFV+Aaibz$eQF#R(?(0id{q31%6gzgI682U-*;?T-ab?Ac7~nbvv7W{H%RWtxygq2b2TK0p-BIpacE~C=7j#cKJ$2-=Z6Q6$W*^sZ;(@3p$!A zMAu32B07q8O7Sdu5&cYxXVA0gS}Bg8XV5iLJe3-Wek#QisbOe`6o=7MXuA}Tqr>PY zQXE8&qaPdm4730`m_k>}6_2DU(N$7Bn3{mLNpS!@h<+r+1LzTSr4;w2CZj8)xCcFe zwn}j~x(i({#a-xbbeS(wbI_$y+zFd)k>U_c~;%~I?|_n=Ku+?tw? z94T%=dyy^0&8bqf(QRlzLwnFI7fHS;RklI$jcCt$$-7e(>m=_&yVpv-0o}Mp@^$Ej z)slCjgR5Hjx|Pf|RYTXDXqJnAimqvryaWBTQS#MjM}y>TsfK#VSE8#|NWKhRxm@y= z)Y_!vi_v9u@~t*)td)x$w5dk&Ms)Eq3TLBf+J?;+@{P5CwhnEq77LrVR|#rrUMi@* zsZ!92`h=k6E0##wULh#Cyj)OSGA^iQ-C{wNd&&eQDi;YVPb?G^FE16eIDUbkvc(Go zEh?KYsI+FDpapy93Yx!Qj-Yw-XA7EBI!n;3{WAs4oHawxjG5B~O`kDM(6s4OCGDRg zXv&<)f=UjQ2%1zfNzlYe6D1v(AZYxQ@q)%3I$zM(L*oRE89P=|@fbm)$BY&eDx0P~MQff_feABPgd=Z$a5Pd4jUCa|QLt>LsXqkDh|M z<>d(K+AUjj%1pE~ODxRn+Cxz1%+2Rk~|U}3`!n`o(Q;6B|u?RI>Mj-_u%W1^|^J@`mJ@u z`lYqc+GSm3IaZTZZ7sB>TVt&PE6>Wb+F3?475!86z36Mv7o&%x4@7T|?vDN>x+S_c znv7P!GykOMh-m+47CiA=(O~53$SHWw7W~HK-_6K;Xf0{kWZfCpLRm@?Htdh-R z6WLJKo5kRnK19Ew@6%W5^Yl@AH@%5&re0x=lL;Y5XmMTh>8D(KgEB*ui&TPS$;1Vsb0zf<$!WPIiMW)A37i( zCee4P^R>~w`WAhwjcQRTT`N9Kg|(5s`UZWcjqugiDW(ng)mP{nZJ4jVNVU<1`sy?E zg;wONPtj*up|3tcpK1lZ`WSts4e`|}bV?iCqEczvpwm=aZJ@6{Kp$%ZeDxkWsrC2O zyXZZwpRe9Q?`rv{sgqh?U!6qnXnk5#s*~2+S8t;av^-zEl^U$&`sz*eme$KxZ=h$j zp1yh=y`km!>Nt8`%l6f4=(v{Ut5?x$S`T0S9=)n{_tme_ue5HydI|knJI_}~(Mwua zU%iNauVtR5j%qPqy?|cSy7=l>=mo9wY3f<6ldqnK_v`4Z=g{+7hOeGQZ)zP*Q_pGX zzIq0|t+n^nljs>O%~yw0W3_g^I)n~uZGH7PI;5TJE0}7~+W6`*bWl6TSC62_wAQ|Q z7(Jr3^3_A=NzL-rFVRDqU!4jw5x>+TCa+c1Ut91)poQ4{moZbqwVNBUtNVRM}PIzHgpyG)>l75+t4?@x)S{e{l!;TpsUfJ zPg7T-uYI)@U4g#x)#Yd_`qEdIpq=Oo-?Gh{(4YKOo6u(TIbQ{?2iBuY&}Rl0pw?cB zK9#&?E&4?A>NV((l2@%qA4_gtg+7u8YJ$soO0H;XMjuM9-+?}myrLexFL~K<^q%DE z-RND(Rn_Pn$xExyNy+hL=xxahkDxzDE?tP;lDwb*y(xMA0(3(1ocZVt$+PF6*Co%I zjgCv6F$=vWdFl-Gs^lr-(J{%Br=Z_UE}4ve=ks{Bxj*dvq z8-kveoSTQ9lHBtcdQx&uPxOT3>>PAha#k)nB)NMQdR%h1CUnr}?&vYencdK%lDlN0 zMq{Smy$C&q6Z~+$Up}qr*}XPNKQ*f_e;iU=sw9hM)yh%>*yW^ z9i4akFxtY6iLw{lZu0!|imlug0Q>(H>t7hDqd~`>GQ$ z8ajn;^i^9>{{i*?T0!7q{`voh)(Pts>ly1&>mKV?>jvw`))s3WQ~)fs7Fn~c5^J9`7No)k`&$3tsvsjRRO+Tcs)1&k- zy`SDjuczCnLz`$7y#QtrM$y4EmuA8|f8T%$U&$M*h_Yk?c_4Dfi#dR zvJmDI&L=}jKIuU+NGlS?-{4R1Nqh{yfDgm0!X5Y~d=0(=+qfAo!;A53JQ0t88i5?# z8Mnbi|4#p0e@{QIzob8vQ!Iy;vWl_tZP-t@Tj&>+px+ z*TYA{hr{=WZ-XjP%d^Y$<@b2KxgFg*k7F-9F0_DNk!STVO zVDDgN@Z2B{d=vOM@MhrUz*A5wurIJX@MGw5EidL2gxxQtz&BDpWzG;GAh~4DU?Cv4 zWX>QdQfbT?=!>?@86X5Cmdxod1mu;>=_drFmCVT(0d$?%c{8B33}M$Q&#LD?q^b2w5Sq zmvh!2DI|8%!!>J(UFYHIHN>vfOXncvw|U>?Iy9ts-`Xhw){^F86TZ5n{(ZEL}+K#U3swAa_+Ti9(Ktj_D~NycOiC>hn+eT zyU@dqorukc1L$2D9f{2c1HcX$#O6Z*V0s5)^ML>`EuGkW7y!g+#O8wlppJ>nhXBB^ zj%_{wK&K01n@4}>+bLr6&=0GR!+G(*4|pt_*gWh59s$pJ&-jf2nJr>{3aS&BEn_IC`+JrJCC6%f!b|7hN1*&pX)Ky zBv8AJ$54_$?Q=Yaa8K;koHZy&pmr;7G1Mba+wvI75vU#Y7^)Gd9q|~75vXl?47CW< zHavz>1ZqQtCT|KHX)Cp27%f^kD&yC z+5wND0)g6^$54PkZR9c3AJB_Dgz5vj!9yrMpzA$^+5@`ILnu9h;8_kwP>e^rFnSCg<-(}ZxY&i_ad@N$qwok9Mi%4YE({-!hq*Ac z2oH6kXeci7;0P{sp|A)SxG-cm9^%5Fm+@d11`fi5To^DA4|Jjb06f5je*JNO7xMez zelGOQ$N4Vw>5KcikT(SPaiQ07+}nkmUO3N%>>QlyLRL2JOzMMoasV(2OM*ueLC*qLc8|3 zvkQ7V+{pzP2|BtE3hO-fQ7xqNxTnz+<}pvxPGn*p@HFjscg&-mroG$;^H8U0M+z~I zbDDN&B<4X*(+*6;Ji=+({%M$pH%;3+5A)cjX?qr79@#W)dmZLsP181SAUvjN0VoEb zUPR-y0MrX;DJTV?2CRqq2B-v}UN}oZAprHFS&G*Ic)<*YlAEa)%b*_WR_cW^sG!+Hc)# zU2AQHh3cgoP!1>ulmp5E<$!WPIiMU+4k!nd1IhvA!2f~+%;EFu{3~Km;>R35v(5!n z_%Vmit-B(nGl$Qvdm_vnKEKWdl=m@*&#-d=)qTw2bL?C|aUXN|EISua+s7O}&&~yu O_A!Ugv~vNKeg6e_#*hjC diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..48dc476 --- /dev/null +++ b/coverage.xml @@ -0,0 +1,310 @@ + + + + + + /home/chris/python-json-socket/jsocket + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jsocket/tserver.py b/jsocket/tserver.py index e438ed4..82a6cdc 100644 --- a/jsocket/tserver.py +++ b/jsocket/tserver.py @@ -54,6 +54,10 @@ def _process_message(self, obj) -> Optional[dict]: return None def run(self): + # Ensure the run loop is active even when run() is invoked directly + # (tests may call run() in a separate thread without invoking start()). + if not self._is_alive: + self._is_alive = True while self._is_alive: try: self.accept_connection() @@ -199,6 +203,10 @@ def _process_message(self, obj) -> Optional[dict]: return None def run(self): + # Ensure the run loop is active even when run() is invoked directly + # (tests may call run() in a separate thread without invoking start()). + if not self._is_alive: + self._is_alive = True while self._is_alive: tmp = self._thread_type(**self._thread_args) self._purge_threads()