From d56e26184e672fa2bf7eeaad035b94347930929c Mon Sep 17 00:00:00 2001 From: Balint Peceli Date: Thu, 18 Jun 2026 15:29:45 +0200 Subject: [PATCH 01/32] convert adapter core and stream adapter to asyncio MYRRHA@SCKCEN --- doc/developer_guide/framework_details.md | 21 +++ .../diagrams/AdapterConcurrency.drawio.png | Bin 0 -> 44534 bytes lewis/adapters/stream.py | 135 ++++++++++-------- lewis/core/adapters.py | 17 +-- pyproject.toml | 1 - tests/test_core_adapters.py | 23 ++- tests/test_stream_adapter.py | 37 +++-- 7 files changed, 150 insertions(+), 84 deletions(-) create mode 100644 doc/resources/diagrams/AdapterConcurrency.drawio.png diff --git a/doc/developer_guide/framework_details.md b/doc/developer_guide/framework_details.md index 01dbe786..6a697ac1 100644 --- a/doc/developer_guide/framework_details.md +++ b/doc/developer_guide/framework_details.md @@ -72,3 +72,24 @@ statemachine: - Implicit: Implement handlers in the device class, with standard names like `on_entry_init` for a state called "init", and call `bindHandlersByName()` + +## Adapter Concurrency + +Adapters performing network I/O for communicating with client applications +make use of python's [asyncio](https://docs.python.org/3/library/asyncio.html) +library. + +- Lewis is a multi-threaded application, each adapter is moved on its own + dedicated thread, which is isolated from the main simulation thread. +- The main thread uses the following two synchronization tools: + - device lock: ensures that that the device is only accessed from one + thread at a time + - is_running event: sends stop request to the adapter thread +- Adapters have to implement the following three + [async coroutines](https://docs.python.org/3/library/asyncio-task.html), + which will be scheduled as tasks by their respective async event loops: + - start_server: starts the server, handles client connections + - stop_server: gracefully closes client connections and stops the server + - handle: synchronizes with the simulation steps + +![The adapter concurrency diagram.](../resources/diagrams/AdapterConcurrency.drawio.png) diff --git a/doc/resources/diagrams/AdapterConcurrency.drawio.png b/doc/resources/diagrams/AdapterConcurrency.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..bc50ffd37f264df554943e34b4fcb41c086e5bfb GIT binary patch literal 44534 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 literal 0 HcmV?d00001 diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 35f65dfd..c570d1f8 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -17,11 +17,9 @@ # along with this program. If not, see . # ********************************************************************* -import asynchat -import asyncore +import asyncio import inspect import re -import socket from typing import NoReturn from scanf import scanf_compile @@ -33,10 +31,9 @@ @has_log -class StreamHandler(asynchat.async_chat): - def __init__(self, sock, target, stream_server) -> None: - asynchat.async_chat.__init__(self, sock=sock) - self.set_terminator(target.in_terminator.encode()) +class StreamHandler(): + def __init__(self, reader, writer, target, stream_server) -> None: + self._in_terminator = target.in_terminator.encode() self._readtimeout = target.readtimeout self._readtimer = 0 self._target = target @@ -44,25 +41,38 @@ def __init__(self, sock, target, stream_server) -> None: self._stream_server = stream_server self._target.handler = self + self._reader = reader + self._writer = writer self._set_logging_context(target) - self.log.info("Client connected from %s:%s", *sock.getpeername()) - def process(self, msec) -> None: + async def handle_client(self): + while True: + try: + msg = await self._reader.readuntil(self._in_terminator) + except asyncio.IncompleteReadError: + break + + self.collect_incoming_data(msg) + await self.found_terminator() + + await self.handle_close() + + async def process(self, msec) -> None: if not self._buffer: return if self._readtimer >= self._readtimeout and self._readtimeout != 0: - if not self.get_terminator(): + if not self._in_terminator: # If no terminator is set, this timeout is the terminator - self.found_terminator() + await self.found_terminator() else: self._readtimer = 0 request = self._get_request() with self._stream_server.device_lock: error = RuntimeError("ReadTimeout while waiting for command terminator.") reply = self._handle_error(request, error) - self._send_reply(reply) + await self._send_reply(reply) if self._buffer: self._readtimer += msec @@ -73,11 +83,12 @@ def collect_incoming_data(self, data) -> None: def _get_request(self): request = b"".join(self._buffer) + request = request.rstrip(self._in_terminator) self._buffer = [] self.log.debug("Got request %s", request) return request - def _push(self, reply) -> None: + async def _push(self, reply) -> None: try: if isinstance(reply, str): reply = reply.encode() @@ -86,20 +97,21 @@ def _push(self, reply) -> None: if isinstance(self._target.out_terminator, str) else self._target.out_terminator ) - self.push(reply + out_terminator) + self._writer.write(reply + out_terminator) + await self._writer.drain() except TypeError as e: self.log.error("Problem creating reply, type error {}!".format(e)) - def _send_reply(self, reply) -> None: + async def _send_reply(self, reply) -> None: if reply is not None: self.log.debug("Sending reply %s", reply) - self._push(reply) + await self._push(reply) def _handle_error(self, request, error): self.log.debug("Error while processing request", exc_info=error) return self._target.handle_error(request, error) - def found_terminator(self) -> None: + async def found_terminator(self) -> None: self._readtimer = 0 request = self._get_request() @@ -124,61 +136,70 @@ def found_terminator(self) -> None: except Exception as error: reply = self._handle_error(request, error) - self._send_reply(reply) + await self._send_reply(reply) - def unsolicited_reply(self, reply) -> None: + async def unsolicited_reply(self, reply) -> None: self.log.debug("Sending unsolicited reply %s", reply) - self._push(reply) + await self._push(reply) - def handle_close(self) -> None: - self.log.info("Closing connection to client %s:%s", *self.socket.getpeername()) - self._stream_server.remove_handler(self) - asynchat.async_chat.handle_close(self) + async def handle_close(self) -> None: + sock = self._writer.get_extra_info('socket') + if sock is not None and not self._writer.is_closing(): + self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + self._writer.close() + await self._writer.wait_closed() + self._stream_server.remove_handler(self) @has_log -class StreamServer(asyncore.dispatcher): +class StreamServer(): def __init__(self, host, port, target, device_lock) -> None: - asyncore.dispatcher.__init__(self) + self.host = host + self.port = port self.target = target self.device_lock = device_lock - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.set_reuse_addr() - self.bind((host, port)) - self.listen(5) + self._server = None self._set_logging_context(target) - self.log.info("Listening on %s:%s", host, port) self._accepted_connections = [] - def handle_accept(self) -> None: - pair = self.accept() - if pair is not None: - sock, addr = pair - handler = StreamHandler(sock, self.target, self) - - self._accepted_connections.append(handler) + async def start(self): + self._server = await asyncio.start_server( + self.handle_accept, + host=self.host, + port=self.port, + backlog=5, + reuse_address=True, + start_serving=True) + self.log.info("Listening on %s:%s", self.host, self.port) + + async def handle_accept(self, reader, writer) -> None: + sock = writer.get_extra_info('socket') + if sock is not None: + self.log.info("Client connected from %s:%s", *sock.getpeername()) + handler = StreamHandler(reader, writer, self.target, self) + self._accepted_connections.append(handler) + await handler.handle_client() def remove_handler(self, handler) -> None: self._accepted_connections.remove(handler) - def close(self) -> None: - # As this is an old style class, the base class method must - # be called directly. This is important to still perform all - # the teardown-work that asyncore.dispatcher does. - self.log.info("Shutting down server, closing all remaining client connections.") - asyncore.dispatcher.close(self) + async def close(self) -> None: + if self._server is not None: + self.log.info("Shutting down server, closing all remaining client connections.") + self._server.close() - # But in addition, close all open sockets and clear the connection list. - for handler in self._accepted_connections: - handler.close() + # Close all open sockets and clear the connection list. + for handler in self._accepted_connections: + await handler.handle_close() - self._accepted_connections = [] + self._accepted_connections = [] + await self._server.wait_closed() - def process(self, msec) -> None: + async def process(self, msec) -> None: for handler in self._accepted_connections: - handler.process(msec) + await handler.process(msec) class PatternMatcher: @@ -713,7 +734,7 @@ def documentation(self): + commands ) - def start_server(self) -> None: + async def start_server(self) -> None: """ Starts the TCP stream server, binding to the configured host and port. Host and port are configured via the command line arguments. @@ -734,23 +755,25 @@ def start_server(self) -> None: self.device_lock, ) - def stop_server(self) -> None: + await self._server.start() + + async def stop_server(self) -> None: if self._server is not None: - self._server.close() + await self._server.close() self._server = None @property def is_running(self): return self._server is not None - def handle(self, cycle_delay=0.1) -> None: + async def handle(self, cycle_delay=0.1) -> None: """ Spend approximately ``cycle_delay`` seconds to process requests to the server. :param cycle_delay: S """ - asyncore.loop(cycle_delay, count=1) - self._server.process(int(cycle_delay * 1000)) + await asyncio.sleep(cycle_delay) + await self._server.process(int(cycle_delay * 1000)) class StreamInterface(InterfaceBase): diff --git a/lewis/core/adapters.py b/lewis/core/adapters.py index 2c7565c5..db14e246 100644 --- a/lewis/core/adapters.py +++ b/lewis/core/adapters.py @@ -23,6 +23,7 @@ be used to store multiple adapters and manage them together. """ +import asyncio import inspect import logging import threading @@ -150,7 +151,7 @@ def documentation(self) -> str: """ return inspect.getdoc(self) or "" - def start_server(self) -> None: + async def start_server(self) -> None: """ This method must be re-implemented to start the infrastructure required for the protocol in question. These startup operations are not supposed to be carried out on @@ -169,7 +170,7 @@ def start_server(self) -> None: "required for network communication." ) - def stop_server(self) -> None: + async def stop_server(self) -> None: """ This method must be re-implemented to stop and tear down anything that has been setup in :meth:`start_server`. This method should close all connections to clients that have @@ -196,7 +197,7 @@ def is_running(self) -> bool: "a server is currently running and listening for requests." ) - def handle(self, cycle_delay: float = 0.1) -> None: + async def handle(self, cycle_delay: float = 0.1) -> None: """ This function is called on each cycle of a simulation. It should process requests that are made via the protocol that exposes the device. The time spent processing should be @@ -305,7 +306,7 @@ def _start_server(self, adapter: Adapter) -> None: if adapter.protocol not in self._threads: self.log.info("Connecting device interface for protocol '%s'", adapter.protocol) - adapter_thread = threading.Thread(target=self._adapter_loop, args=(adapter, 0.01)) + adapter_thread = threading.Thread(target=lambda: asyncio.run(self._adapter_loop(adapter, 0.01))) adapter_thread.daemon = True self._threads[adapter.protocol] = adapter_thread @@ -318,17 +319,17 @@ def _start_server(self, adapter: Adapter) -> None: if not self._running[adapter.protocol].is_set(): raise LewisException("Adapter for '%s' failed to start!" % adapter.protocol) - def _adapter_loop(self, adapter: Adapter, dt: float) -> None: + async def _adapter_loop(self, adapter: Adapter, dt: float) -> None: adapter.device_lock = self._lock # This ensures that the adapter is using the correct lock - adapter.start_server() + await asyncio.create_task(adapter.start_server()) self._running[adapter.protocol].set() self.log.debug("Starting adapter loop for protocol %s.", adapter.protocol) while self._running[adapter.protocol].is_set(): - adapter.handle(dt) + await asyncio.create_task(adapter.handle(dt)) - adapter.stop_server() + await asyncio.create_task(adapter.stop_server()) def disconnect(self, *args: str) -> None: """ diff --git a/pyproject.toml b/pyproject.toml index d144cb8b..33cfb9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ dependencies=[ "semantic_version", "PyYAML", "scanf", - "pyasynchat;python_version >= '3.12'", ] [project.optional-dependencies] diff --git a/tests/test_core_adapters.py b/tests/test_core_adapters.py index 8be50a34..96ee78a2 100644 --- a/tests/test_core_adapters.py +++ b/tests/test_core_adapters.py @@ -27,10 +27,10 @@ def __init__(self, protocol, running=False, options=None): def protocol(self): return self._protocol - def start_server(self): + async def start_server(self): self._running = True - def stop_server(self): + async def stop_server(self): self._running = False @property @@ -47,19 +47,28 @@ def failing_function(): self.assertRaises(RuntimeError, failing_function) -class TestAdapter(unittest.TestCase): +class TestAdapter(unittest.IsolatedAsyncioTestCase): def test_documentation(self): adapter = DummyAdapter("foo") self.assertEqual(inspect.cleandoc(adapter.__doc__), adapter.documentation) - def test_not_implemented_errors(self): + async def test_not_implemented_errors(self): adapter = Adapter() - self.assertRaises(NotImplementedError, adapter.start_server) - self.assertRaises(NotImplementedError, adapter.stop_server) + with self.assertRaises(NotImplementedError): + await adapter.start_server() + with self.assertRaises(NotImplementedError): + await adapter.stop_server() self.assertRaises(NotImplementedError, getattr, adapter, "is_running") - assertRaisesNothing(self, adapter.handle, 0) + + try: + await adapter.handle(0) + except Exception as exc: + self.fail( + "Assertion error. An exception was caught where none " + "was expected in %s. Message: %s" % (adapter.handle.__name__, str(exc)) + ) def test_interface_property(self): adapter = Adapter() diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 6b1704d7..921ea7cb 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -1,19 +1,23 @@ -import unittest -from unittest.mock import MagicMock, patch +from unittest import IsolatedAsyncioTestCase +from unittest.mock import MagicMock, AsyncMock, patch from parameterized import parameterized from lewis.adapters.stream import StreamHandler -@patch("asynchat.async_chat") -class TestStreamHandler(unittest.TestCase): +class TestStreamHandler(IsolatedAsyncioTestCase): def setUp(self): - """Create a mock for the async_chat class""" self.target = MagicMock() self.stream_server = MagicMock() - self.socket = MagicMock() - self.handler = StreamHandler(self.socket, self.target, self.stream_server) + self.stream_reader = AsyncMock() + self.stream_writer = MagicMock() + self.stream_writer.drain = AsyncMock() + self.handler = StreamHandler( + reader=AsyncMock(), + writer=self.stream_writer, + target=self.target, + stream_server=self.stream_server) @parameterized.expand( [ @@ -23,11 +27,20 @@ def setUp(self): ("\n", "test", b"test\n"), ] ) - @patch("asynchat.async_chat.push") - def test_terminator_and_replies_of_different_types_can_be_concatenated( - self, terminator, message, expected, async_push, _ + async def test_terminator_and_replies_of_different_types_can_be_concatenated( + self, terminator, message, expected ): self.target.out_terminator = terminator - self.handler.unsolicited_reply(message) + await self.handler.unsolicited_reply(message) - self.assertEqual(expected, async_push.call_args[0][0]) + with patch.object( + self.stream_writer, 'write', return_value=None) as mock_write: + self.stream_writer.write(expected) + + mock_write.assert_called_once_with(expected) + + with patch.object( + self.stream_writer, 'drain', return_value=None) as mock_drain: + await self.stream_writer.drain() + + mock_drain.assert_called_once() From 6a7c3c51a2b6081b89d4d25fc6b8170e962e082d Mon Sep 17 00:00:00 2001 From: Balint Peceli Date: Fri, 19 Jun 2026 22:11:42 +0200 Subject: [PATCH 02/32] convert modbus adapter to asyncio MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 109 ++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 42 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 055bc400..ace26051 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -32,8 +32,7 @@ at lewis/examples/modbus_device. """ -import asyncore -import socket +import asyncio import struct from copy import deepcopy from math import ceil @@ -273,10 +272,10 @@ class ModbusProtocol: :param datastore: ModbusDataStore instance to reference when processing requests """ - def __init__(self, sender, datastore) -> None: + def __init__(self, writer, datastore) -> None: self._buffer = bytearray() self._datastore = datastore - self._send = lambda req: sender(req.to_bytearray()) + self._writer = writer # Lookup table to handle requests as per Modbus Application Protocol v1.1b3, Section 6. self._fcode_handler_map = { @@ -290,7 +289,11 @@ def __init__(self, sender, datastore) -> None: 0x10: self._handle_write_multiple_registers, } - def process(self, data, device_lock) -> None: + async def _send(self, response) -> None: + self._writer.write(response.to_bytearray()) + await self._writer.drain() + + async def process(self, data, device_lock) -> None: """ Process as much of given data as possible. @@ -317,7 +320,7 @@ def process(self, data, device_lock) -> None: str(["{:#04x}".format(c) for c in response.to_bytearray()]), ) - self._send(response) + await self._send(response) def _buffered_requests(self): """Generator to yield all complete modbus requests in the internal buffer""" @@ -529,59 +532,79 @@ def _handle_write_multiple_registers(self, request): @has_log -class ModbusHandler(asyncore.dispatcher_with_send): - def __init__(self, sock, interface, server) -> None: - asyncore.dispatcher_with_send.__init__(self, sock=sock) +class ModbusHandler(): + def __init__(self, reader, writer, interface, server) -> None: self._datastore = ModbusDataStore(interface.di, interface.co, interface.ir, interface.hr) - self._modbus = ModbusProtocol(self.send, self._datastore) + self._modbus = ModbusProtocol(writer, self._datastore) self._server = server + self._reader = reader + self._writer = writer self._set_logging_context(interface) - self.log.info("Client connected from %s:%s", *sock.getpeername()) - def handle_read(self) -> None: - data = self.recv(8192) - self._modbus.process(data, self._server.device_lock) + async def handle_client(self) -> None: + while True: + data = await self._reader.read(8192) + if data: + await self._modbus.process(data, self._server.device_lock) + else: + break + + await self.handle_close() - def handle_close(self) -> None: - self.log.info("Closing connection to client %s:%s", *self.socket.getpeername()) - self._server.remove_handler(self) - self.close() + async def handle_close(self) -> None: + sock = self._writer.get_extra_info('socket') + if sock is not None and not self._writer.is_closing(): + self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + self._writer.close() + await self._writer.wait_closed() + self._server.remove_handler(self) @has_log -class ModbusServer(asyncore.dispatcher): +class ModbusServer(): def __init__(self, host, port, interface, device_lock) -> None: - asyncore.dispatcher.__init__(self) + self.host = host + self.port = port self.device_lock = device_lock self.interface = interface - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.set_reuse_addr() - self.bind((host, port)) - self.listen(5) + self._server = None self._set_logging_context(interface) - self.log.info("Listening on %s:%s", host, port) self._accepted_connections = [] - def handle_accept(self) -> None: - pair = self.accept() - if pair is not None: - sock, _ = pair - handler = ModbusHandler(sock, self.interface, self) - self._accepted_connections.append(handler) + async def start(self): + self._server = await asyncio.start_server( + self.handle_accept, + host=self.host, + port=self.port, + backlog=5, + reuse_address=True, + start_serving=True) + self.log.info("Listening on %s:%s", self.host, self.port) + + async def handle_accept(self, reader, writer) -> None: + sock = writer.get_extra_info('socket') + if sock is not None: + self.log.info("Client connected from %s:%s", *sock.getpeername()) + handler = ModbusHandler(reader, writer, self.interface, self) + self._accepted_connections.append(handler) + await handler.handle_client() def remove_handler(self, handler) -> None: self._accepted_connections.remove(handler) - def handle_close(self) -> None: - self.log.info("Shutting down server, closing all remaining client connections.") + async def close(self) -> None: + if self._server is not None: + self.log.info("Shutting down server, closing all remaining client connections.") + self._server.close() - for handler in self._accepted_connections: - handler.close() - self._accepted_connections = [] - self.close() + for handler in self._accepted_connections: + await handler.handle_close() + + self._accepted_connections = [] + await self._server.wait_closed() class ModbusAdapter(Adapter): @@ -591,7 +614,7 @@ def __init__(self, options=None) -> None: super(ModbusAdapter, self).__init__(options) self._server = None - def start_server(self) -> None: + async def start_server(self) -> None: self._server = ModbusServer( self._options.bind_address, self._options.port, @@ -599,17 +622,19 @@ def start_server(self) -> None: self.device_lock, ) - def stop_server(self) -> None: + await self._server.start() + + async def stop_server(self) -> None: if self._server is not None: - self._server.close() + await self._server.close() self._server = None @property def is_running(self): return self._server is not None - def handle(self, cycle_delay=0.1) -> None: - asyncore.loop(cycle_delay, count=1) + async def handle(self, cycle_delay=0.1) -> None: + await asyncio.sleep(cycle_delay) class ModbusInterface(InterfaceBase): From ee9924abf7834828d5ea48ca30d9e1a981872601 Mon Sep 17 00:00:00 2001 From: Balint Peceli Date: Mon, 22 Jun 2026 11:15:54 +0200 Subject: [PATCH 03/32] make epics adapter functions async coroutines MYRRHA@SCKCEN --- lewis/adapters/epics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lewis/adapters/epics.py b/lewis/adapters/epics.py index 950e766a..8b59da6b 100644 --- a/lewis/adapters/epics.py +++ b/lewis/adapters/epics.py @@ -573,7 +573,7 @@ def documentation(self): return "\n\n".join([inspect.getdoc(self.interface) or "", "PVs\n==="] + pvs) - def start_server(self) -> None: + async def start_server(self) -> None: """ Creates a pcaspy-server. @@ -597,7 +597,7 @@ def start_server(self) -> None: ", ".join((self._options.prefix + pv for pv in self.interface.bound_pvs.keys())), ) - def stop_server(self) -> None: + async def stop_server(self) -> None: self._driver = None self._server = None @@ -605,7 +605,7 @@ def stop_server(self) -> None: def is_running(self): return self._server is not None - def handle(self, cycle_delay=0.1) -> None: + async def handle(self, cycle_delay=0.1) -> None: """ Call this method to spend about ``cycle_delay`` seconds processing requests in the pcaspy server. Under load, for example when running ``caget`` at a From 26c50dce62ef29a8ed26057ab46de86bc7dbedde Mon Sep 17 00:00:00 2001 From: Balint Peceli Date: Fri, 26 Jun 2026 14:47:48 +0200 Subject: [PATCH 04/32] fix stream adapter for devices using empty in_terminator MYRRHA@SCKCEN --- lewis/adapters/stream.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index c570d1f8..9b3f551e 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -48,13 +48,23 @@ def __init__(self, reader, writer, target, stream_server) -> None: async def handle_client(self): while True: - try: - msg = await self._reader.readuntil(self._in_terminator) - except asyncio.IncompleteReadError: + chunk = await self._reader.read(4096) + if not chunk: break - self.collect_incoming_data(msg) - await self.found_terminator() + self.collect_incoming_data(chunk) + + # If no terminator is set, just keep collecting data + # and let the process method handle the timeout + if not self._in_terminator: + continue + else: + while True: + pos = b"".join(self._buffer).find(self._in_terminator) + if pos == -1: + break + + await self.found_terminator() await self.handle_close() @@ -83,7 +93,8 @@ def collect_incoming_data(self, data) -> None: def _get_request(self): request = b"".join(self._buffer) - request = request.rstrip(self._in_terminator) + if self._in_terminator: + request = request.removesuffix(self._in_terminator) self._buffer = [] self.log.debug("Got request %s", request) return request From 8dfef3de8de4a36f9926a4a2cf4a0217aa24ccb4 Mon Sep 17 00:00:00 2001 From: Balint Peceli Date: Fri, 26 Jun 2026 15:31:13 +0200 Subject: [PATCH 05/32] don't wait for draining the output buffer rely on asyncio.transports.WriteTransport.write instead MYRRHA@SCKCEN --- lewis/adapters/stream.py | 31 +++++++++++++++---------------- tests/test_stream_adapter.py | 12 +++--------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 9b3f551e..ac4065fb 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -64,25 +64,25 @@ async def handle_client(self): if pos == -1: break - await self.found_terminator() + self.found_terminator() await self.handle_close() - async def process(self, msec) -> None: + def process(self, msec) -> None: if not self._buffer: return if self._readtimer >= self._readtimeout and self._readtimeout != 0: if not self._in_terminator: # If no terminator is set, this timeout is the terminator - await self.found_terminator() + self.found_terminator() else: self._readtimer = 0 request = self._get_request() with self._stream_server.device_lock: error = RuntimeError("ReadTimeout while waiting for command terminator.") reply = self._handle_error(request, error) - await self._send_reply(reply) + self._send_reply(reply) if self._buffer: self._readtimer += msec @@ -99,7 +99,7 @@ def _get_request(self): self.log.debug("Got request %s", request) return request - async def _push(self, reply) -> None: + def _push(self, reply) -> None: try: if isinstance(reply, str): reply = reply.encode() @@ -108,21 +108,20 @@ async def _push(self, reply) -> None: if isinstance(self._target.out_terminator, str) else self._target.out_terminator ) - self._writer.write(reply + out_terminator) - await self._writer.drain() + self._writer.transport.write(reply + out_terminator) except TypeError as e: self.log.error("Problem creating reply, type error {}!".format(e)) - async def _send_reply(self, reply) -> None: + def _send_reply(self, reply) -> None: if reply is not None: self.log.debug("Sending reply %s", reply) - await self._push(reply) + self._push(reply) def _handle_error(self, request, error): self.log.debug("Error while processing request", exc_info=error) return self._target.handle_error(request, error) - async def found_terminator(self) -> None: + def found_terminator(self) -> None: self._readtimer = 0 request = self._get_request() @@ -147,11 +146,11 @@ async def found_terminator(self) -> None: except Exception as error: reply = self._handle_error(request, error) - await self._send_reply(reply) + self._send_reply(reply) - async def unsolicited_reply(self, reply) -> None: + def unsolicited_reply(self, reply) -> None: self.log.debug("Sending unsolicited reply %s", reply) - await self._push(reply) + self._push(reply) async def handle_close(self) -> None: sock = self._writer.get_extra_info('socket') @@ -208,9 +207,9 @@ async def close(self) -> None: self._accepted_connections = [] await self._server.wait_closed() - async def process(self, msec) -> None: + def process(self, msec) -> None: for handler in self._accepted_connections: - await handler.process(msec) + handler.process(msec) class PatternMatcher: @@ -784,7 +783,7 @@ async def handle(self, cycle_delay=0.1) -> None: :param cycle_delay: S """ await asyncio.sleep(cycle_delay) - await self._server.process(int(cycle_delay * 1000)) + self._server.process(int(cycle_delay * 1000)) class StreamInterface(InterfaceBase): diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 921ea7cb..e883afbc 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -31,16 +31,10 @@ async def test_terminator_and_replies_of_different_types_can_be_concatenated( self, terminator, message, expected ): self.target.out_terminator = terminator - await self.handler.unsolicited_reply(message) + self.handler.unsolicited_reply(message) with patch.object( - self.stream_writer, 'write', return_value=None) as mock_write: - self.stream_writer.write(expected) + self.stream_writer.transport, 'write', return_value=None) as mock_write: + self.stream_writer.transport.write(expected) mock_write.assert_called_once_with(expected) - - with patch.object( - self.stream_writer, 'drain', return_value=None) as mock_drain: - await self.stream_writer.drain() - - mock_drain.assert_called_once() From b2eb561c17122564d07b0971bf89bf6c6f4da66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Thu, 9 Jul 2026 11:10:56 +0200 Subject: [PATCH 06/32] Remove handler independently from is_closing() MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 2 +- lewis/adapters/stream.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index ace26051..808b6c5a 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -558,7 +558,7 @@ async def handle_close(self) -> None: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) self._writer.close() await self._writer.wait_closed() - self._server.remove_handler(self) + self._server.remove_handler(self) @has_log diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index ac4065fb..c7b188e9 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -158,7 +158,7 @@ async def handle_close(self) -> None: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) self._writer.close() await self._writer.wait_closed() - self._stream_server.remove_handler(self) + self._stream_server.remove_handler(self) @has_log From da1d48afae6a142443182bcdfd1e72409fcdd11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Thu, 9 Jul 2026 11:18:39 +0200 Subject: [PATCH 07/32] Do not mutate self._accepted_connections while iterating over it MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 2 +- lewis/adapters/stream.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 808b6c5a..67a3b077 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -600,7 +600,7 @@ async def close(self) -> None: self.log.info("Shutting down server, closing all remaining client connections.") self._server.close() - for handler in self._accepted_connections: + for handler in list(self._accepted_connections): await handler.handle_close() self._accepted_connections = [] diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index c7b188e9..5863a602 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -201,14 +201,14 @@ async def close(self) -> None: self._server.close() # Close all open sockets and clear the connection list. - for handler in self._accepted_connections: + for handler in list(self._accepted_connections): await handler.handle_close() self._accepted_connections = [] await self._server.wait_closed() def process(self, msec) -> None: - for handler in self._accepted_connections: + for handler in list(self._accepted_connections): handler.process(msec) From da6991a173efcfa92b639e1baa55aa393ec543c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Thu, 9 Jul 2026 11:27:39 +0200 Subject: [PATCH 08/32] Do not bypass StreamWriter.write() MYRRHA@SCKCEN --- lewis/adapters/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 5863a602..3ce2bc18 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -108,7 +108,7 @@ def _push(self, reply) -> None: if isinstance(self._target.out_terminator, str) else self._target.out_terminator ) - self._writer.transport.write(reply + out_terminator) + self._writer.write(reply + out_terminator) except TypeError as e: self.log.error("Problem creating reply, type error {}!".format(e)) From 2e7ff0f8e65cd32678eff3647d55d757238b0c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Wed, 22 Jul 2026 14:37:49 +0200 Subject: [PATCH 09/32] Add type annotations MYRRHA@SCKCEN --- lewis/adapters/stream.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 3ce2bc18..dd9c516f 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -32,7 +32,7 @@ @has_log class StreamHandler(): - def __init__(self, reader, writer, target, stream_server) -> None: + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, target, stream_server) -> None: self._in_terminator = target.in_terminator.encode() self._readtimeout = target.readtimeout self._readtimer = 0 @@ -176,7 +176,7 @@ def __init__(self, host, port, target, device_lock) -> None: async def start(self): self._server = await asyncio.start_server( - self.handle_accept, + self._handle_accept, host=self.host, port=self.port, backlog=5, @@ -184,7 +184,7 @@ async def start(self): start_serving=True) self.log.info("Listening on %s:%s", self.host, self.port) - async def handle_accept(self, reader, writer) -> None: + async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: sock = writer.get_extra_info('socket') if sock is not None: self.log.info("Client connected from %s:%s", *sock.getpeername()) From 6cd869bbb8535771b006f0771298f99d478c9071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Wed, 29 Jul 2026 14:37:04 +0200 Subject: [PATCH 10/32] Add type annotations MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 67a3b077..693ae4d0 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -259,20 +259,19 @@ class ModbusProtocol: This class implements the Modbus TCP Protocol. The user of this class should provide a ModbusDataStore instance that will be used to - fulfill read and write requests, and a callable `sender` which accepts one bytearray - parameter. The `sender` will be called whenever a response frame is generated, with a - bytearray containing the response frame as the parameter. + fulfill read and write requests. The `writer` will be called whenever a response frame + is generated, with a bytearray containing the response frame as the parameter. Processing occurs when the user calls ModbusProtocol.process(), passing in the raw frame data to process as a bytearray. The data may include multiple frames and partial frame fragments. Any data that could not be processed (due to incomplete frames) is buffered for the next call to process. - :param sender: callable that accepts one bytearray parameter, called to send responses. + :param writer: asyncio.StreamWriter, called to send responses. :param datastore: ModbusDataStore instance to reference when processing requests """ - def __init__(self, writer, datastore) -> None: + def __init__(self, writer: asyncio.StreamWriter, datastore: ModbusDataStore) -> None: self._buffer = bytearray() self._datastore = datastore self._writer = writer @@ -533,7 +532,7 @@ def _handle_write_multiple_registers(self, request): @has_log class ModbusHandler(): - def __init__(self, reader, writer, interface, server) -> None: + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, interface, server) -> None: self._datastore = ModbusDataStore(interface.di, interface.co, interface.ir, interface.hr) self._modbus = ModbusProtocol(writer, self._datastore) self._server = server @@ -576,7 +575,7 @@ def __init__(self, host, port, interface, device_lock) -> None: async def start(self): self._server = await asyncio.start_server( - self.handle_accept, + self._handle_accept, host=self.host, port=self.port, backlog=5, @@ -584,7 +583,7 @@ async def start(self): start_serving=True) self.log.info("Listening on %s:%s", self.host, self.port) - async def handle_accept(self, reader, writer) -> None: + async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: sock = writer.get_extra_info('socket') if sock is not None: self.log.info("Client connected from %s:%s", *sock.getpeername()) From 7400c5ce72d0229ba793dd4dde86a713bfd88d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 27 Jul 2026 11:12:40 +0200 Subject: [PATCH 11/32] Simplify task handling MYRRHA@SCKCEN --- lewis/core/adapters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lewis/core/adapters.py b/lewis/core/adapters.py index db14e246..c82ee855 100644 --- a/lewis/core/adapters.py +++ b/lewis/core/adapters.py @@ -321,15 +321,15 @@ def _start_server(self, adapter: Adapter) -> None: async def _adapter_loop(self, adapter: Adapter, dt: float) -> None: adapter.device_lock = self._lock # This ensures that the adapter is using the correct lock - await asyncio.create_task(adapter.start_server()) + await adapter.start_server() self._running[adapter.protocol].set() self.log.debug("Starting adapter loop for protocol %s.", adapter.protocol) while self._running[adapter.protocol].is_set(): - await asyncio.create_task(adapter.handle(dt)) + await adapter.handle(dt) - await asyncio.create_task(adapter.stop_server()) + await adapter.stop_server() def disconnect(self, *args: str) -> None: """ From d94f6c379786703dfd11922a446dda83f98bb647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 27 Jul 2026 11:14:20 +0200 Subject: [PATCH 12/32] Handle connections in the process() method only MYRRHA@SCKCEN --- lewis/adapters/stream.py | 76 ++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index dd9c516f..935a2082 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -43,32 +43,37 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, t self._target.handler = self self._reader = reader self._writer = writer + self._pending_read: asyncio.Task | None = None self._set_logging_context(target) - async def handle_client(self): - while True: - chunk = await self._reader.read(4096) - if not chunk: - break - - self.collect_incoming_data(chunk) - - # If no terminator is set, just keep collecting data - # and let the process method handle the timeout - if not self._in_terminator: - continue - else: - while True: - pos = b"".join(self._buffer).find(self._in_terminator) - if pos == -1: - break + async def process(self, msec) -> None: + # Start a read operation if none is in flight + if self._pending_read is None: + self._pending_read = asyncio.ensure_future(self._reader.read(4096)) + # Process data if the read completed since the last tick + if self._pending_read.done(): + try: + chunk = self._pending_read.result() + except Exception as e: + self._pending_read = None + self.log.error("Error reading from client: %s", e) + await self.handle_close() + return + self._pending_read = None + + if not chunk: # EOF - client disconnected + await self.handle_close() + return + + self.collect_incoming_data(chunk) + + if self._in_terminator: + while b"".join(self._buffer).find(self._in_terminator) != -1: self.found_terminator() - await self.handle_close() - - def process(self, msec) -> None: + # Timeout processing if not self._buffer: return @@ -91,11 +96,20 @@ def collect_incoming_data(self, data) -> None: self._buffer.append(data) self._readtimer = 0 - def _get_request(self): - request = b"".join(self._buffer) + def _get_request(self) -> bytes: + data = b"".join(self._buffer) if self._in_terminator: - request = request.removesuffix(self._in_terminator) - self._buffer = [] + term_pos = data.find(self._in_terminator) + if term_pos != -1: + request = data[:term_pos] + remainder = data[term_pos + len(self._in_terminator):] + self._buffer = [remainder] if remainder else [] + else: + request = data + self._buffer = [] + else: + request = data + self._buffer = [] self.log.debug("Got request %s", request) return request @@ -153,6 +167,9 @@ def unsolicited_reply(self, reply) -> None: self._push(reply) async def handle_close(self) -> None: + if self._pending_read is not None and not self._pending_read.done(): + self._pending_read.cancel() + self._pending_read = None sock = self._writer.get_extra_info('socket') if sock is not None and not self._writer.is_closing(): self.log.info("Closing connection to client %s:%s", *sock.getpeername()) @@ -172,7 +189,7 @@ def __init__(self, host, port, target, device_lock) -> None: self._set_logging_context(target) - self._accepted_connections = [] + self._accepted_connections: list[StreamHandler] = [] async def start(self): self._server = await asyncio.start_server( @@ -184,13 +201,12 @@ async def start(self): start_serving=True) self.log.info("Listening on %s:%s", self.host, self.port) - async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: sock = writer.get_extra_info('socket') if sock is not None: self.log.info("Client connected from %s:%s", *sock.getpeername()) handler = StreamHandler(reader, writer, self.target, self) self._accepted_connections.append(handler) - await handler.handle_client() def remove_handler(self, handler) -> None: self._accepted_connections.remove(handler) @@ -207,9 +223,9 @@ async def close(self) -> None: self._accepted_connections = [] await self._server.wait_closed() - def process(self, msec) -> None: + async def process(self, msec) -> None: for handler in list(self._accepted_connections): - handler.process(msec) + await handler.process(msec) class PatternMatcher: @@ -783,7 +799,7 @@ async def handle(self, cycle_delay=0.1) -> None: :param cycle_delay: S """ await asyncio.sleep(cycle_delay) - self._server.process(int(cycle_delay * 1000)) + await self._server.process(int(cycle_delay * 1000)) class StreamInterface(InterfaceBase): From b6c235c4eee9a4a19df5d2861e4c1dd041e5ff3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 27 Jul 2026 11:44:34 +0200 Subject: [PATCH 13/32] Fix flushing the write buffer MYRRHA@SCKCEN --- lewis/adapters/stream.py | 21 +++++++++++---------- tests/test_stream_adapter.py | 12 ++++-------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 935a2082..ffaf2d84 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -71,7 +71,7 @@ async def process(self, msec) -> None: if self._in_terminator: while b"".join(self._buffer).find(self._in_terminator) != -1: - self.found_terminator() + await self.found_terminator() # Timeout processing if not self._buffer: @@ -80,14 +80,14 @@ async def process(self, msec) -> None: if self._readtimer >= self._readtimeout and self._readtimeout != 0: if not self._in_terminator: # If no terminator is set, this timeout is the terminator - self.found_terminator() + await self.found_terminator() else: self._readtimer = 0 request = self._get_request() with self._stream_server.device_lock: error = RuntimeError("ReadTimeout while waiting for command terminator.") reply = self._handle_error(request, error) - self._send_reply(reply) + await self._send_reply(reply) if self._buffer: self._readtimer += msec @@ -113,7 +113,7 @@ def _get_request(self) -> bytes: self.log.debug("Got request %s", request) return request - def _push(self, reply) -> None: + async def _push(self, reply) -> None: try: if isinstance(reply, str): reply = reply.encode() @@ -123,19 +123,20 @@ def _push(self, reply) -> None: else self._target.out_terminator ) self._writer.write(reply + out_terminator) + await self._writer.drain() except TypeError as e: self.log.error("Problem creating reply, type error {}!".format(e)) - def _send_reply(self, reply) -> None: + async def _send_reply(self, reply) -> None: if reply is not None: self.log.debug("Sending reply %s", reply) - self._push(reply) + await self._push(reply) def _handle_error(self, request, error): self.log.debug("Error while processing request", exc_info=error) return self._target.handle_error(request, error) - def found_terminator(self) -> None: + async def found_terminator(self) -> None: self._readtimer = 0 request = self._get_request() @@ -160,11 +161,11 @@ def found_terminator(self) -> None: except Exception as error: reply = self._handle_error(request, error) - self._send_reply(reply) + await self._send_reply(reply) - def unsolicited_reply(self, reply) -> None: + async def unsolicited_reply(self, reply) -> None: self.log.debug("Sending unsolicited reply %s", reply) - self._push(reply) + await self._push(reply) async def handle_close(self) -> None: if self._pending_read is not None and not self._pending_read.done(): diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index e883afbc..da13488d 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -1,5 +1,5 @@ from unittest import IsolatedAsyncioTestCase -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import MagicMock, AsyncMock from parameterized import parameterized @@ -24,17 +24,13 @@ def setUp(self): (b"\n", "test", b"test\n"), (b"\n", b"test", b"test\n"), ("\n", "test", b"test\n"), - ("\n", "test", b"test\n"), + ("\r\n", "test", b"test\r\n"), ] ) async def test_terminator_and_replies_of_different_types_can_be_concatenated( self, terminator, message, expected ): self.target.out_terminator = terminator - self.handler.unsolicited_reply(message) - - with patch.object( - self.stream_writer.transport, 'write', return_value=None) as mock_write: - self.stream_writer.transport.write(expected) + await self.handler.unsolicited_reply(message) - mock_write.assert_called_once_with(expected) + self.stream_writer.write.assert_called_once_with(expected) From d7982c09e2762347bc7966c7246ec6e38062695f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 27 Jul 2026 13:32:17 +0200 Subject: [PATCH 14/32] Add tests for stream adapter MYRRHA@SCKCEN --- tests/test_stream_adapter.py | 78 +++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index da13488d..0f389f40 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -1,3 +1,4 @@ +import asyncio from unittest import IsolatedAsyncioTestCase from unittest.mock import MagicMock, AsyncMock @@ -14,10 +15,19 @@ def setUp(self): self.stream_writer = MagicMock() self.stream_writer.drain = AsyncMock() self.handler = StreamHandler( - reader=AsyncMock(), + reader=self.stream_reader, writer=self.stream_writer, target=self.target, stream_server=self.stream_server) + self.handler._readtimeout = 0 + self.handler._in_terminator = b"\r\n" + self.target.out_terminator = "\r\n" + + def _create_mock_command(self, can_process=lambda x: True, response="OK"): + cmd_mock = MagicMock() + cmd_mock.can_process.side_effect = can_process + cmd_mock.process_request.return_value = response + return cmd_mock @parameterized.expand( [ @@ -34,3 +44,69 @@ async def test_terminator_and_replies_of_different_types_can_be_concatenated( await self.handler.unsolicited_reply(message) self.stream_writer.write.assert_called_once_with(expected) + + async def test_process_starts_pending_read_on_first_call(self): + await self.handler.process(10) + + self.assertIsNotNone(self.handler._pending_read) + + async def test_process_eof_triggers_handle_close(self): + self.handler._reader.read.return_value = b"" + + await self.handler.process(10) + await asyncio.sleep(0) + await self.handler.process(10) + + self.stream_server.remove_handler.assert_called_with(self.handler) + + async def test_process_dispatches_single_command_with_terminator(self): + cmd_mock = self._create_mock_command() + self.target.bound_commands = [cmd_mock] + self.handler._reader.read.return_value = b"CMD\r\n" + + await self.handler.process(10) + await asyncio.sleep(0) + await self.handler.process(10) + + cmd_mock.can_process.assert_called_with(b"CMD") + cmd_mock.process_request.assert_called_with(b"CMD") + + self.stream_writer.write.assert_called_once_with(b"OK\r\n") + + async def test_process_dispatches_two_commands_in_one_chunk(self): + cmd1_mock = self._create_mock_command( + can_process=lambda x: x == b"CMD1", + response="OK1") + cmd2_mock = self._create_mock_command( + can_process=lambda x: x == b"CMD2", + response="OK2") + self.target.bound_commands = [cmd1_mock, cmd2_mock] + self.handler._reader.read.return_value = b"CMD1\r\nCMD2\r\n" + + await self.handler.process(10) + await asyncio.sleep(0) + await self.handler.process(10) + + cmd1_mock.can_process.assert_called() + cmd2_mock.can_process.assert_called() + cmd1_mock.process_request.assert_called_once_with(b"CMD1") + cmd2_mock.process_request.assert_called_once_with(b"CMD2") + + self.stream_writer.write.assert_any_call(b"OK1\r\n") + self.stream_writer.write.assert_any_call(b"OK2\r\n") + self.assertEqual(self.stream_writer.write.call_count, 2) + + async def test_process_timeout_with_incomplete_command_sends_error(self): + self.handler._readtimeout = 10 + self.handler._reader.read.return_value = b"INCOMPLETE" + + # First call: starts the read task + await self.handler.process(10) + await asyncio.sleep(0) + # Second call: collects data; _readtimer resets to 0, then increments to 10 + await self.handler.process(10) + # Third call: _readtimer (10) >= _readtimeout (10) -> timeout fires, error reply sent + await self.handler.process(10) + + self.target.handle_error.assert_called_once() + self.stream_writer.write.assert_called_once() From 813e1486fc2ad0d7ba23383fe5fd3b128173a30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 27 Jul 2026 15:16:29 +0200 Subject: [PATCH 15/32] Fix potential crash when socket already closed MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 10 ++++++++-- lewis/adapters/stream.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 693ae4d0..cbd451ed 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -554,7 +554,10 @@ async def handle_client(self) -> None: async def handle_close(self) -> None: sock = self._writer.get_extra_info('socket') if sock is not None and not self._writer.is_closing(): - self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + try: + self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + except OSError: + self.log.info("Closing connection to client (peer address unavailable)") self._writer.close() await self._writer.wait_closed() self._server.remove_handler(self) @@ -586,7 +589,10 @@ async def start(self): async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: sock = writer.get_extra_info('socket') if sock is not None: - self.log.info("Client connected from %s:%s", *sock.getpeername()) + try: + self.log.info("Client connected from %s:%s", *sock.getpeername()) + except OSError: + self.log.info("Client connected (peer address unavailable)") handler = ModbusHandler(reader, writer, self.interface, self) self._accepted_connections.append(handler) await handler.handle_client() diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index ffaf2d84..92ea0b6c 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -173,7 +173,10 @@ async def handle_close(self) -> None: self._pending_read = None sock = self._writer.get_extra_info('socket') if sock is not None and not self._writer.is_closing(): - self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + try: + self.log.info("Closing connection to client %s:%s", *sock.getpeername()) + except OSError: + self.log.info("Closing connection to client (peer address unavailable)") self._writer.close() await self._writer.wait_closed() self._stream_server.remove_handler(self) @@ -205,7 +208,10 @@ async def start(self): def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: sock = writer.get_extra_info('socket') if sock is not None: - self.log.info("Client connected from %s:%s", *sock.getpeername()) + try: + self.log.info("Client connected from %s:%s", *sock.getpeername()) + except OSError: + self.log.info("Client connected (peer address unavailable)") handler = StreamHandler(reader, writer, self.target, self) self._accepted_connections.append(handler) From bd30c0db7f242e9ea3dad201be213ea275e765d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 10:43:00 +0200 Subject: [PATCH 16/32] Fix typo MYRRHA@SCKCEN --- doc/developer_guide/framework_details.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/developer_guide/framework_details.md b/doc/developer_guide/framework_details.md index 6a697ac1..b8293dfd 100644 --- a/doc/developer_guide/framework_details.md +++ b/doc/developer_guide/framework_details.md @@ -82,7 +82,7 @@ library. - Lewis is a multi-threaded application, each adapter is moved on its own dedicated thread, which is isolated from the main simulation thread. - The main thread uses the following two synchronization tools: - - device lock: ensures that that the device is only accessed from one + - device lock: ensures that the device is only accessed from one thread at a time - is_running event: sends stop request to the adapter thread - Adapters have to implement the following three From f06cd0564d29fee03f50ff1bd79c648e8056e5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 10:45:38 +0200 Subject: [PATCH 17/32] Fix Python3 TypeError MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index cbd451ed..f89d170d 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -236,7 +236,7 @@ def create_exception(self, code): frame = deepcopy(self) frame.length = 3 frame.fcode += 0x80 - frame.data = bytearray(chr(code)) + frame.data = bytearray([code]) return frame def create_response(self, data=None): From b19914ca06ddcc0f407b739d859d8221d46dd4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 10:47:38 +0200 Subject: [PATCH 18/32] Do the processing as soon as possible MYRRHA@SCKCEN --- lewis/adapters/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 92ea0b6c..7240f3f9 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -805,8 +805,8 @@ async def handle(self, cycle_delay=0.1) -> None: :param cycle_delay: S """ - await asyncio.sleep(cycle_delay) await self._server.process(int(cycle_delay * 1000)) + await asyncio.sleep(cycle_delay) class StreamInterface(InterfaceBase): From d16a47a57e491a45ae6b77169872f40834cc373b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 13:47:00 +0200 Subject: [PATCH 19/32] Make unsolicited_reply sync once again MYRRHA@SCKCEN --- lewis/adapters/stream.py | 12 ++++++++---- tests/test_stream_adapter.py | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 7240f3f9..e8084f71 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -163,9 +163,11 @@ async def found_terminator(self) -> None: await self._send_reply(reply) - async def unsolicited_reply(self, reply) -> None: + def unsolicited_reply(self, reply) -> None: self.log.debug("Sending unsolicited reply %s", reply) - await self._push(reply) + if self._stream_server._loop is None: + raise RuntimeError("Cannot send unsolicited reply: server not started.") + asyncio.run_coroutine_threadsafe(self._push(reply), self._stream_server._loop).result(timeout=5.0) async def handle_close(self) -> None: if self._pending_read is not None and not self._pending_read.done(): @@ -189,13 +191,14 @@ def __init__(self, host, port, target, device_lock) -> None: self.port = port self.target = target self.device_lock = device_lock + self._loop: asyncio.AbstractEventLoop | None = None self._server = None + self._accepted_connections: list[StreamHandler] = [] self._set_logging_context(target) - self._accepted_connections: list[StreamHandler] = [] - async def start(self): + self._loop = asyncio.get_running_loop() self._server = await asyncio.start_server( self._handle_accept, host=self.host, @@ -229,6 +232,7 @@ async def close(self) -> None: self._accepted_connections = [] await self._server.wait_closed() + self._loop = None async def process(self, msec) -> None: for handler in list(self._accepted_connections): diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 0f389f40..5127a40f 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -41,7 +41,12 @@ async def test_terminator_and_replies_of_different_types_can_be_concatenated( self, terminator, message, expected ): self.target.out_terminator = terminator - await self.handler.unsolicited_reply(message) + # unsolicited_reply is sync and uses run_coroutine_threadsafe; it must be called + # from a worker thread so that .result() does not block the running event loop. + self.stream_server._loop = asyncio.get_running_loop() + await asyncio.get_running_loop().run_in_executor( + None, self.handler.unsolicited_reply, message + ) self.stream_writer.write.assert_called_once_with(expected) From 940de9d90bd58f95406c66962c4180c84ed4999f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 14:07:25 +0200 Subject: [PATCH 20/32] Handle socket close error MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 23 +++++++++++++++-------- lewis/adapters/stream.py | 22 +++++++++++++++++++++- tests/test_stream_adapter.py | 2 ++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index f89d170d..fbf3a30a 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -538,20 +538,27 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, i self._server = server self._reader = reader self._writer = writer + self._closing = False self._set_logging_context(interface) async def handle_client(self) -> None: - while True: - data = await self._reader.read(8192) - if data: - await self._modbus.process(data, self._server.device_lock) - else: - break - - await self.handle_close() + try: + while True: + data = await self._reader.read(8192) + if data: + await self._modbus.process(data, self._server.device_lock) + else: + break + except OSError as e: + self.log.error("Connection error: %s", e) + finally: + await self.handle_close() async def handle_close(self) -> None: + if self._closing: + return + self._closing = True sock = self._writer.get_extra_info('socket') if sock is not None and not self._writer.is_closing(): try: diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index e8084f71..5d1feb39 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -44,6 +44,7 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, t self._reader = reader self._writer = writer self._pending_read: asyncio.Task | None = None + self._closing = False self._set_logging_context(target) @@ -70,7 +71,7 @@ async def process(self, msec) -> None: self.collect_incoming_data(chunk) if self._in_terminator: - while b"".join(self._buffer).find(self._in_terminator) != -1: + while not self._closing and b"".join(self._buffer).find(self._in_terminator) != -1: await self.found_terminator() # Timeout processing @@ -114,6 +115,8 @@ def _get_request(self) -> bytes: return request async def _push(self, reply) -> None: + if self._closing: + return try: if isinstance(reply, str): reply = reply.encode() @@ -126,6 +129,9 @@ async def _push(self, reply) -> None: await self._writer.drain() except TypeError as e: self.log.error("Problem creating reply, type error {}!".format(e)) + except OSError as e: + self.log.error("Connection error while sending reply: %s", e) + await self.handle_close() async def _send_reply(self, reply) -> None: if reply is not None: @@ -164,12 +170,20 @@ async def found_terminator(self) -> None: await self._send_reply(reply) def unsolicited_reply(self, reply) -> None: + if self._closing: + return self.log.debug("Sending unsolicited reply %s", reply) if self._stream_server._loop is None: raise RuntimeError("Cannot send unsolicited reply: server not started.") asyncio.run_coroutine_threadsafe(self._push(reply), self._stream_server._loop).result(timeout=5.0) async def handle_close(self) -> None: + if self._closing: + return + self._closing = True + # Setting the target.handler to None breaks client code, so + # rather set it to a dummy handler that does nothing. + self._target.handler = _NullStreamHandler() if self._pending_read is not None and not self._pending_read.done(): self._pending_read.cancel() self._pending_read = None @@ -813,6 +827,11 @@ async def handle(self, cycle_delay=0.1) -> None: await asyncio.sleep(cycle_delay) +class _NullStreamHandler: + def unsolicited_reply(self, reply) -> None: + pass + + class StreamInterface(InterfaceBase): r""" This class is used to provide a TCP-stream based interface to a device. @@ -875,6 +894,7 @@ def get_speed(self): def __init__(self) -> None: super(StreamInterface, self).__init__() self.bound_commands = None + self.handler = _NullStreamHandler() @property def adapter(self): diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 5127a40f..94f47de1 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -14,6 +14,8 @@ def setUp(self): self.stream_reader = AsyncMock() self.stream_writer = MagicMock() self.stream_writer.drain = AsyncMock() + self.stream_writer.wait_closed = AsyncMock() + self.stream_writer.is_closing.return_value = False self.handler = StreamHandler( reader=self.stream_reader, writer=self.stream_writer, From 7d1e3204c27f532613ee5ac2f91500e9e2ce6de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:01:14 +0200 Subject: [PATCH 21/32] Ignore ValueError if a handler cannot be found anymore MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 5 ++++- lewis/adapters/stream.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index fbf3a30a..1890bb54 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -605,7 +605,10 @@ async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.Str await handler.handle_client() def remove_handler(self, handler) -> None: - self._accepted_connections.remove(handler) + try: + self._accepted_connections.remove(handler) + except ValueError: + pass async def close(self) -> None: if self._server is not None: diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 5d1feb39..442ee32f 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -233,7 +233,10 @@ def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWri self._accepted_connections.append(handler) def remove_handler(self, handler) -> None: - self._accepted_connections.remove(handler) + try: + self._accepted_connections.remove(handler) + except ValueError: + pass async def close(self) -> None: if self._server is not None: From 6afb3a864b8fe85fd578af00768194c865613729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:29:31 +0200 Subject: [PATCH 22/32] Suppress RuntimeWarning for not awaiting a cancelled task MYRRHA@SCKCEN --- lewis/adapters/stream.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 442ee32f..ecd84059 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -186,6 +186,10 @@ async def handle_close(self) -> None: self._target.handler = _NullStreamHandler() if self._pending_read is not None and not self._pending_read.done(): self._pending_read.cancel() + try: + await self._pending_read + except asyncio.CancelledError: + pass self._pending_read = None sock = self._writer.get_extra_info('socket') if sock is not None and not self._writer.is_closing(): From 648f37f8ebe686bc7eca060c2c15055eb69dcac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:30:22 +0200 Subject: [PATCH 23/32] Add more tests for stream adapter MYRRHA@SCKCEN --- tests/test_stream_adapter.py | 72 +++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 94f47de1..39fdd68d 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -4,7 +4,7 @@ from parameterized import parameterized -from lewis.adapters.stream import StreamHandler +from lewis.adapters.stream import StreamHandler, StreamInterface, _NullStreamHandler class TestStreamHandler(IsolatedAsyncioTestCase): @@ -117,3 +117,73 @@ async def test_process_timeout_with_incomplete_command_sends_error(self): self.target.handle_error.assert_called_once() self.stream_writer.write.assert_called_once() + + async def test_handle_close_is_idempotent(self): + await self.handler.handle_close() + await self.handler.handle_close() + + self.stream_server.remove_handler.assert_called_once_with(self.handler) + + async def test_handle_close_clears_target_handler(self): + await self.handler.handle_close() + + self.assertIsInstance(self.target.handler, _NullStreamHandler) + + async def test_unsolicited_reply_is_silent_noop_after_close(self): + await self.handler.handle_close() + + self.stream_server._loop = asyncio.get_running_loop() + # unsolicited_reply must be called from a worker thread (it calls .result() internally) + await asyncio.get_running_loop().run_in_executor( + None, self.handler.unsolicited_reply, "hello" + ) + + self.stream_writer.write.assert_not_called() + self.stream_server.remove_handler.assert_called_once_with(self.handler) + + async def test_push_does_not_write_after_close(self): + await self.handler.handle_close() + self.stream_writer.write.reset_mock() + + await self.handler._push("hello") + + self.stream_writer.write.assert_not_called() + + async def test_push_oserror_triggers_handle_close(self): + cmd_mock = self._create_mock_command() + self.target.bound_commands = [cmd_mock] + self.handler._reader.read.return_value = b"CMD\r\n" + self.stream_writer.drain.side_effect = OSError("connection broken") + + await self.handler.process(10) + await asyncio.sleep(0) + await self.handler.process(10) + + self.stream_server.remove_handler.assert_called_once_with(self.handler) + + async def test_process_stops_dispatching_on_broken_connection(self): + cmd1_mock = self._create_mock_command( + can_process=lambda x: x == b"CMD1", + response="OK1") + cmd2_mock = self._create_mock_command( + can_process=lambda x: x == b"CMD2", + response="OK2") + self.target.bound_commands = [cmd1_mock, cmd2_mock] + self.handler._reader.read.return_value = b"CMD1\r\nCMD2\r\n" + self.stream_writer.drain.side_effect = OSError("connection broken") + + await self.handler.process(10) + await asyncio.sleep(0) + await self.handler.process(10) + + cmd1_mock.process_request.assert_called_once_with(b"CMD1") + cmd2_mock.process_request.assert_not_called() + self.stream_server.remove_handler.assert_called_once_with(self.handler) + + def test_handler_initialised_to_null_object(self): + class MinimalInterface(StreamInterface): + commands = [] + + interface = MinimalInterface() + + self.assertIsInstance(interface.handler, _NullStreamHandler) From 0a3d9bc2603018afe154cb24c379918aa3b675a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:45:08 +0200 Subject: [PATCH 24/32] Make sure to call adapter.stop_server() even if handle() raises MYRRHA@SCKCEN --- lewis/core/adapters.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lewis/core/adapters.py b/lewis/core/adapters.py index c82ee855..810f7035 100644 --- a/lewis/core/adapters.py +++ b/lewis/core/adapters.py @@ -326,10 +326,15 @@ async def _adapter_loop(self, adapter: Adapter, dt: float) -> None: self._running[adapter.protocol].set() self.log.debug("Starting adapter loop for protocol %s.", adapter.protocol) - while self._running[adapter.protocol].is_set(): - await adapter.handle(dt) - - await adapter.stop_server() + try: + while self._running[adapter.protocol].is_set(): + await adapter.handle(dt) + except Exception: + self.log.exception("Adapter loop for protocol '%s' crashed.", adapter.protocol) + self._running[adapter.protocol].clear() + raise + finally: + await adapter.stop_server() def disconnect(self, *args: str) -> None: """ From 5f1cf6c8bf66834a76c4a7fe1c60c513c37988d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:48:10 +0200 Subject: [PATCH 25/32] Close writer even if socket info is not available MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 5 +++-- lewis/adapters/stream.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 1890bb54..3090eb8a 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -560,13 +560,14 @@ async def handle_close(self) -> None: return self._closing = True sock = self._writer.get_extra_info('socket') - if sock is not None and not self._writer.is_closing(): + if sock is not None: try: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) except OSError: self.log.info("Closing connection to client (peer address unavailable)") + if not self._writer.is_closing(): self._writer.close() - await self._writer.wait_closed() + await self._writer.wait_closed() self._server.remove_handler(self) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index ecd84059..cc15b35f 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -192,13 +192,14 @@ async def handle_close(self) -> None: pass self._pending_read = None sock = self._writer.get_extra_info('socket') - if sock is not None and not self._writer.is_closing(): + if sock is not None: try: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) except OSError: self.log.info("Closing connection to client (peer address unavailable)") + if not self._writer.is_closing(): self._writer.close() - await self._writer.wait_closed() + await self._writer.wait_closed() self._stream_server.remove_handler(self) From 3655d8327506f1d00581f6d0d7ac3784e32a8667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Tue, 28 Jul 2026 15:47:21 +0200 Subject: [PATCH 26/32] Do not send responses while holding the device lock MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 3090eb8a..66151d7c 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -304,22 +304,22 @@ async def process(self, data, device_lock) -> None: """ self._buffer.extend(bytearray(data)) + responses = [] with device_lock: for request in self._buffered_requests(): - self.log.debug( - "Request: %s", - str(["{:#04x}".format(c) for c in request.to_bytearray()]), - ) - handler = self._get_handler(request.fcode) - response = handler(request) - - self.log.debug( - "Response: %s", - str(["{:#04x}".format(c) for c in response.to_bytearray()]), - ) + responses.append((request, handler(request))) - await self._send(response) + for request, response in responses: + self.log.debug( + "Request: %s", + str(["{:#04x}".format(c) for c in request.to_bytearray()]), + ) + self.log.debug( + "Response: %s", + str(["{:#04x}".format(c) for c in response.to_bytearray()]), + ) + await self._send(response) def _buffered_requests(self): """Generator to yield all complete modbus requests in the internal buffer""" From 9c90c658e6e0b9ec4a8a45f88929d53f905e4332 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Fri, 26 Jun 2026 10:27:03 +0100 Subject: [PATCH 27/32] Local ruff config --- ruff.toml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..9f18fea3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,33 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Set the maximum line length to 100. +line-length = 100 +indent-width = 4 From dae20387b795fd086104edcd5e567867658b2740 Mon Sep 17 00:00:00 2001 From: Tom Willemsen Date: Fri, 26 Jun 2026 10:28:13 +0100 Subject: [PATCH 28/32] Run ruff format --- doc/conf.py | 7 ++++--- lewis/adapters/epics.py | 4 ++-- lewis/adapters/stream.py | 2 +- lewis/core/adapters.py | 3 +-- lewis/core/devices.py | 3 +-- lewis/core/statemachine.py | 2 +- lewis/scripts/control.py | 2 +- lewis/scripts/run.py | 3 +-- tests/test_StateMachine.py | 2 +- 9 files changed, 13 insertions(+), 15 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index e6216605..e54d3d0e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -4,6 +4,7 @@ # sphinx-quickstart on Wed Nov 9 16:42:53 2016. import os import sys + sys.path.insert(0, os.path.abspath("../lewis")) @@ -22,11 +23,11 @@ ] templates_path = ["_templates"] # General information about the project. -project = u"lewis" -language = 'en' +project = "lewis" +language = "en" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output --------------------------------------------- -suppress_warnings =["docutils"] +suppress_warnings = ["docutils"] html_theme = "sphinx_rtd_theme" html_logo = "resources/logo/lewis-logo.png" html_context = { diff --git a/lewis/adapters/epics.py b/lewis/adapters/epics.py index 8b59da6b..49d45820 100644 --- a/lewis/adapters/epics.py +++ b/lewis/adapters/epics.py @@ -441,14 +441,14 @@ def write(self, pv, value) -> bool: return True except LimitViolationException as e: self.log.warning( - "Rejected writing value %s to PV %s due to limit " "violation. %s", + "Rejected writing value %s to PV %s due to limit violation. %s", value, pv, e, ) except AccessViolationException: self.log.warning( - "Rejected writing value %s to PV %s due to access " "violation, PV is read-only.", + "Rejected writing value %s to PV %s due to access violation, PV is read-only.", value, pv, ) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index cc15b35f..b875bb60 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -930,7 +930,7 @@ def _bind_device(self) -> None: pattern = bound_cmd.matcher.pattern if pattern in patterns: raise RuntimeError( - "The regular expression {} is " "associated with multiple commands.".format( + "The regular expression {} is associated with multiple commands.".format( pattern ) ) diff --git a/lewis/core/adapters.py b/lewis/core/adapters.py index 810f7035..09d18b0d 100644 --- a/lewis/core/adapters.py +++ b/lewis/core/adapters.py @@ -46,8 +46,7 @@ class NoLock: def __enter__(self) -> None: raise RuntimeError( - "The attempted action requires a proper threading.Lock-object, " - "but none was available." + "The attempted action requires a proper threading.Lock-object, but none was available." ) def __exit__( diff --git a/lewis/core/devices.py b/lewis/core/devices.py index 9dc86865..4f5bc03f 100644 --- a/lewis/core/devices.py +++ b/lewis/core/devices.py @@ -349,8 +349,7 @@ def create_device(self, setup=None): if setup_name not in self.setups: raise LewisException( - "Failed to find setup '{}' for device '{}'. " - "Available setups are:\n {}".format( + "Failed to find setup '{}' for device '{}'. Available setups are:\n {}".format( setup, self.name, "\n ".join(self.setups.keys()) ) ) diff --git a/lewis/core/statemachine.py b/lewis/core/statemachine.py index 5aadcac2..9f3d7de3 100644 --- a/lewis/core/statemachine.py +++ b/lewis/core/statemachine.py @@ -191,7 +191,7 @@ def __init__(self, cfg, context=None) -> None: # Specifying an initial state is not optional if "initial" not in cfg: raise StateMachineException( - "StateMachine configuration must include " "'initial' to specify starting state." + "StateMachine configuration must include 'initial' to specify starting state." ) self._initial = cfg["initial"] self._set_handlers(self._initial) diff --git a/lewis/scripts/control.py b/lewis/scripts/control.py index ce7db619..3cca58f8 100644 --- a/lewis/scripts/control.py +++ b/lewis/scripts/control.py @@ -117,7 +117,7 @@ def call_method(remote, object_name, method, arguments): positional_args.add_argument( "arguments", nargs="*", - help="Arguments to method call. For setting a property, " "supply the property value. ", + help="Arguments to method call. For setting a property, supply the property value. ", ) optional_args = parser.add_argument_group("Optional arguments") diff --git a/lewis/scripts/run.py b/lewis/scripts/run.py index 4e31dfbc..a75d8ed6 100644 --- a/lewis/scripts/run.py +++ b/lewis/scripts/run.py @@ -160,8 +160,7 @@ "-I", "--ignore-versions", action="store_true", - help="Ignore version mismatches between device and framework. A warning will still " - "be logged.", + help="Ignore version mismatches between device and framework. A warning will still be logged.", ) other_args.add_argument( "-v", "--version", action="store_true", help="Prints the version and exits." diff --git a/tests/test_StateMachine.py b/tests/test_StateMachine.py index 1829bfa8..54281e52 100644 --- a/tests/test_StateMachine.py +++ b/tests/test_StateMachine.py @@ -45,7 +45,7 @@ def test_first_cycle_transitions_to_initial(self): self.assertEqual( sm.state, "foobar", - "StateMachine failed to transition into " "initial state on first cycle", + "StateMachine failed to transition into initial state on first cycle", ) def test_can_transition_with_lambda(self): From 908de0c9d951c57ecc791c8bf73976c7f6edc082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Thu, 30 Jul 2026 16:48:35 +0200 Subject: [PATCH 29/32] Fix formatting with ruff MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 19 ++++++++++++------- lewis/adapters/stream.py | 21 +++++++++++++-------- lewis/core/adapters.py | 4 +++- tests/test_stream_adapter.py | 19 ++++++------------- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 66151d7c..8e4822ba 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -531,8 +531,10 @@ def _handle_write_multiple_registers(self, request): @has_log -class ModbusHandler(): - def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, interface, server) -> None: +class ModbusHandler: + def __init__( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, interface, server + ) -> None: self._datastore = ModbusDataStore(interface.di, interface.co, interface.ir, interface.hr) self._modbus = ModbusProtocol(writer, self._datastore) self._server = server @@ -559,7 +561,7 @@ async def handle_close(self) -> None: if self._closing: return self._closing = True - sock = self._writer.get_extra_info('socket') + sock = self._writer.get_extra_info("socket") if sock is not None: try: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) @@ -572,7 +574,7 @@ async def handle_close(self) -> None: @has_log -class ModbusServer(): +class ModbusServer: def __init__(self, host, port, interface, device_lock) -> None: self.host = host self.port = port @@ -591,11 +593,14 @@ async def start(self): port=self.port, backlog=5, reuse_address=True, - start_serving=True) + start_serving=True, + ) self.log.info("Listening on %s:%s", self.host, self.port) - async def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - sock = writer.get_extra_info('socket') + async def _handle_accept( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + sock = writer.get_extra_info("socket") if sock is not None: try: self.log.info("Client connected from %s:%s", *sock.getpeername()) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index b875bb60..167f3685 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -31,8 +31,10 @@ @has_log -class StreamHandler(): - def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, target, stream_server) -> None: +class StreamHandler: + def __init__( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, target, stream_server + ) -> None: self._in_terminator = target.in_terminator.encode() self._readtimeout = target.readtimeout self._readtimer = 0 @@ -103,7 +105,7 @@ def _get_request(self) -> bytes: term_pos = data.find(self._in_terminator) if term_pos != -1: request = data[:term_pos] - remainder = data[term_pos + len(self._in_terminator):] + remainder = data[term_pos + len(self._in_terminator) :] self._buffer = [remainder] if remainder else [] else: request = data @@ -175,7 +177,9 @@ def unsolicited_reply(self, reply) -> None: self.log.debug("Sending unsolicited reply %s", reply) if self._stream_server._loop is None: raise RuntimeError("Cannot send unsolicited reply: server not started.") - asyncio.run_coroutine_threadsafe(self._push(reply), self._stream_server._loop).result(timeout=5.0) + asyncio.run_coroutine_threadsafe(self._push(reply), self._stream_server._loop).result( + timeout=5.0 + ) async def handle_close(self) -> None: if self._closing: @@ -191,7 +195,7 @@ async def handle_close(self) -> None: except asyncio.CancelledError: pass self._pending_read = None - sock = self._writer.get_extra_info('socket') + sock = self._writer.get_extra_info("socket") if sock is not None: try: self.log.info("Closing connection to client %s:%s", *sock.getpeername()) @@ -204,7 +208,7 @@ async def handle_close(self) -> None: @has_log -class StreamServer(): +class StreamServer: def __init__(self, host, port, target, device_lock) -> None: self.host = host self.port = port @@ -224,11 +228,12 @@ async def start(self): port=self.port, backlog=5, reuse_address=True, - start_serving=True) + start_serving=True, + ) self.log.info("Listening on %s:%s", self.host, self.port) def _handle_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - sock = writer.get_extra_info('socket') + sock = writer.get_extra_info("socket") if sock is not None: try: self.log.info("Client connected from %s:%s", *sock.getpeername()) diff --git a/lewis/core/adapters.py b/lewis/core/adapters.py index 09d18b0d..ce6533d0 100644 --- a/lewis/core/adapters.py +++ b/lewis/core/adapters.py @@ -305,7 +305,9 @@ def _start_server(self, adapter: Adapter) -> None: if adapter.protocol not in self._threads: self.log.info("Connecting device interface for protocol '%s'", adapter.protocol) - adapter_thread = threading.Thread(target=lambda: asyncio.run(self._adapter_loop(adapter, 0.01))) + adapter_thread = threading.Thread( + target=lambda: asyncio.run(self._adapter_loop(adapter, 0.01)) + ) adapter_thread.daemon = True self._threads[adapter.protocol] = adapter_thread diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index 39fdd68d..c71d04d0 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -20,7 +20,8 @@ def setUp(self): reader=self.stream_reader, writer=self.stream_writer, target=self.target, - stream_server=self.stream_server) + stream_server=self.stream_server, + ) self.handler._readtimeout = 0 self.handler._in_terminator = b"\r\n" self.target.out_terminator = "\r\n" @@ -81,12 +82,8 @@ async def test_process_dispatches_single_command_with_terminator(self): self.stream_writer.write.assert_called_once_with(b"OK\r\n") async def test_process_dispatches_two_commands_in_one_chunk(self): - cmd1_mock = self._create_mock_command( - can_process=lambda x: x == b"CMD1", - response="OK1") - cmd2_mock = self._create_mock_command( - can_process=lambda x: x == b"CMD2", - response="OK2") + cmd1_mock = self._create_mock_command(can_process=lambda x: x == b"CMD1", response="OK1") + cmd2_mock = self._create_mock_command(can_process=lambda x: x == b"CMD2", response="OK2") self.target.bound_commands = [cmd1_mock, cmd2_mock] self.handler._reader.read.return_value = b"CMD1\r\nCMD2\r\n" @@ -162,12 +159,8 @@ async def test_push_oserror_triggers_handle_close(self): self.stream_server.remove_handler.assert_called_once_with(self.handler) async def test_process_stops_dispatching_on_broken_connection(self): - cmd1_mock = self._create_mock_command( - can_process=lambda x: x == b"CMD1", - response="OK1") - cmd2_mock = self._create_mock_command( - can_process=lambda x: x == b"CMD2", - response="OK2") + cmd1_mock = self._create_mock_command(can_process=lambda x: x == b"CMD1", response="OK1") + cmd2_mock = self._create_mock_command(can_process=lambda x: x == b"CMD2", response="OK2") self.target.bound_commands = [cmd1_mock, cmd2_mock] self.handler._reader.read.return_value = b"CMD1\r\nCMD2\r\n" self.stream_writer.drain.side_effect = OSError("connection broken") From de2ab01e87207047530e78936f80ba57fa67f896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Thu, 30 Jul 2026 16:53:00 +0200 Subject: [PATCH 30/32] Add explanatory comments MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 2 +- lewis/adapters/stream.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 8e4822ba..0ca4368e 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -614,7 +614,7 @@ def remove_handler(self, handler) -> None: try: self._accepted_connections.remove(handler) except ValueError: - pass + pass # Removed from another path async def close(self) -> None: if self._server is not None: diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 167f3685..77dcb95e 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -193,7 +193,7 @@ async def handle_close(self) -> None: try: await self._pending_read except asyncio.CancelledError: - pass + pass # Suppress RuntimeWarning for not awaiting a cancelled task self._pending_read = None sock = self._writer.get_extra_info("socket") if sock is not None: @@ -246,7 +246,7 @@ def remove_handler(self, handler) -> None: try: self._accepted_connections.remove(handler) except ValueError: - pass + pass # Removed from another path async def close(self) -> None: if self._server is not None: From 660d89832fd4211f22c2a4c56a9131fb100b2e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Mon, 3 Aug 2026 09:37:33 +0200 Subject: [PATCH 31/32] Remove handler entirely MYRRHA@SCKCEN --- lewis/adapters/stream.py | 11 ++--------- tests/test_stream_adapter.py | 15 +-------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 77dcb95e..64047d81 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -185,9 +185,8 @@ async def handle_close(self) -> None: if self._closing: return self._closing = True - # Setting the target.handler to None breaks client code, so - # rather set it to a dummy handler that does nothing. - self._target.handler = _NullStreamHandler() + if self._target.handler is self: + del self._target.handler if self._pending_read is not None and not self._pending_read.done(): self._pending_read.cancel() try: @@ -840,11 +839,6 @@ async def handle(self, cycle_delay=0.1) -> None: await asyncio.sleep(cycle_delay) -class _NullStreamHandler: - def unsolicited_reply(self, reply) -> None: - pass - - class StreamInterface(InterfaceBase): r""" This class is used to provide a TCP-stream based interface to a device. @@ -907,7 +901,6 @@ def get_speed(self): def __init__(self) -> None: super(StreamInterface, self).__init__() self.bound_commands = None - self.handler = _NullStreamHandler() @property def adapter(self): diff --git a/tests/test_stream_adapter.py b/tests/test_stream_adapter.py index c71d04d0..e17aa76e 100644 --- a/tests/test_stream_adapter.py +++ b/tests/test_stream_adapter.py @@ -4,7 +4,7 @@ from parameterized import parameterized -from lewis.adapters.stream import StreamHandler, StreamInterface, _NullStreamHandler +from lewis.adapters.stream import StreamHandler class TestStreamHandler(IsolatedAsyncioTestCase): @@ -121,11 +121,6 @@ async def test_handle_close_is_idempotent(self): self.stream_server.remove_handler.assert_called_once_with(self.handler) - async def test_handle_close_clears_target_handler(self): - await self.handler.handle_close() - - self.assertIsInstance(self.target.handler, _NullStreamHandler) - async def test_unsolicited_reply_is_silent_noop_after_close(self): await self.handler.handle_close() @@ -172,11 +167,3 @@ async def test_process_stops_dispatching_on_broken_connection(self): cmd1_mock.process_request.assert_called_once_with(b"CMD1") cmd2_mock.process_request.assert_not_called() self.stream_server.remove_handler.assert_called_once_with(self.handler) - - def test_handler_initialised_to_null_object(self): - class MinimalInterface(StreamInterface): - commands = [] - - interface = MinimalInterface() - - self.assertIsInstance(interface.handler, _NullStreamHandler) From c79836a93d62b93a5a43a3398d664b246e975b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20Kov=C3=A1cs?= Date: Fri, 7 Aug 2026 13:23:04 +0200 Subject: [PATCH 32/32] Catch exception when waiting for socket close MYRRHA@SCKCEN --- lewis/adapters/modbus.py | 5 ++++- lewis/adapters/stream.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lewis/adapters/modbus.py b/lewis/adapters/modbus.py index 0ca4368e..c2c2d996 100644 --- a/lewis/adapters/modbus.py +++ b/lewis/adapters/modbus.py @@ -569,7 +569,10 @@ async def handle_close(self) -> None: self.log.info("Closing connection to client (peer address unavailable)") if not self._writer.is_closing(): self._writer.close() - await self._writer.wait_closed() + try: + await self._writer.wait_closed() + except OSError: + self.log.debug("Connection reset by peer while waiting for close") self._server.remove_handler(self) diff --git a/lewis/adapters/stream.py b/lewis/adapters/stream.py index 64047d81..1ed4c066 100644 --- a/lewis/adapters/stream.py +++ b/lewis/adapters/stream.py @@ -202,7 +202,10 @@ async def handle_close(self) -> None: self.log.info("Closing connection to client (peer address unavailable)") if not self._writer.is_closing(): self._writer.close() - await self._writer.wait_closed() + try: + await self._writer.wait_closed() + except OSError: + self.log.debug("Connection reset by peer while waiting for close") self._stream_server.remove_handler(self)