Visitar URL original
Run the sync tcp client on the async client (draft) by sankalpsthakur · Pull Request #3088 · pymodbus-dev/pymodbus · GitHub
Skip to content

Run the sync tcp client on the async client (draft) - #3088

Draft
sankalpsthakur wants to merge 2 commits into
pymodbus-dev:devfrom
sankalpsthakur:feat/sync-via-async
Draft

sankalpsthakur wants to merge 2 commits into
pymodbus-dev:devfrom
sankalpsthakur:feat/sync-via-async

Conversation

@sankalpsthakur

Copy link
Copy Markdown
Contributor

Draft, to discuss the approach from #3076 on real code. Only the tcp client is converted.

ModbusTcpClient now creates an AsyncModbusTcpClient on a private event loop that runs in a daemon thread. connect() starts the loop and connects, execute() submits the request with asyncio.run_coroutine_threadsafe() and waits for the result, close() closes the async client and stops the loop. The async client gets reconnect_delay=0, because the sync client connects when a request needs it.

The mechanism is in ModbusBaseSyncClient (_LoopThread, _connect_async, _close_async), so the other clients only need _new_async_client() and _run_async = True. The TLS client keeps its sync code for now, UDP and serial are unchanged.

Open points:

  • 5 tests in test_client_sync.py and test_client.py mock socket.create_connection/socket.send for the tcp client and fail now. I'd rewrite them against a server, like the new test_client_sync_loop.py, once the approach is agreed.
  • trace_packet/trace_pdu/trace_connect are now called on the loop thread, not the caller's thread.
  • Benchmark numbers are in Discard the late response of a cancelled request #3076.

Validation

  • pytest test/client/test_client_sync_loop.py: 6 passed (real server: read, connect on request, with block, failed connect leaves no thread, call from a thread that runs a loop, set_max_no_responses).
  • pytest test/client: 242 passed, 8 failed (the 5 above, and 3 tls tests that cannot find the certificates in my worktree, also on dev).
  • ruff check, ruff format --check, codespell and pylint (10.00/10) are clean; mypy reports the same findings as on dev.

Related to #3076.

AI disclosure: written with an AI coding agent and tested as described above.

ModbusTcpClient now runs an AsyncModbusTcpClient on a private event loop
in a daemon thread. Each request is submitted with
asyncio.run_coroutine_threadsafe(), so the sync client uses the async
transaction code and also works when the calling thread runs a loop.

The loop starts on connect and stops on close. The TLS, UDP and serial
clients keep their sync code for now.
@janiversen

Copy link
Copy Markdown
Collaborator

Please check that an exception raised in the async part goes back to the app....I remember having that problem in the server.

@janiversen janiversen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good ! I have added a small number of questions/comments.

Comment thread pymodbus/client/base.py Outdated
self.use_udp = False
self._loop_thread: _LoopThread | None = None
self._async_client: ModbusBaseClient | None = None
self._custom_responses: list[type[ModbusPDU]] = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to store the custom responses, once registred they are kept in the pdu module.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are kept per decoder: DecodePDU.register() copies pdu_table into the instance, so the async client created on connect did not know them. I removed the list, the async client now uses the decoder of the sync client, so registered responses are known to both.

Comment thread pymodbus/client/base.py

#: True when the client runs its async counterpart on a private loop,
#: instead of using its own sync communication code.
_run_async = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meaning the old code still works, and you/I can test by setting it to true ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, with _run_async = False the tcp client uses the old sync code. test_read now runs with both values.

Comment thread pymodbus/client/base.py
"""
if not self.connect():
raise ConnectionException(f"Failed to connect[{self!s}]")
if self._async_client and self._loop_thread:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test for run_async ? did 8 misunderstand something ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests are in test_client_sync_loop.py. test_read is now parametrized on _run_async, so both paths in execute() are tested.

Comment thread pymodbus/client/tcp.py Outdated

:meta private:
"""
return AsyncModbusTcpClient(**self._async_args, reconnect_delay=0) # type: ignore[arg-type]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nicer with functools.partial

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the tcp client sets self._async_client_factory = functools.partial(AsyncModbusTcpClient, ...).

Comment thread test/client/test_client_sync_loop.py Outdated
from pymodbus.simulator import DataType, SimData, SimDevice


def free_port() -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please see the other tests, we use a standard fixture use_port

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the test class uses use_port with a new entry in BASE_PORTS.

Comment thread test/client/test_client_sync_loop.py Outdated
return sock.getsockname()[1]


class ServerThread: # pylint: disable=too-few-public-methods

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a standard fixture for this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the tests use mock_server. The server runs on the test loop, so the sync calls go through asyncio.to_thread().

The tcp client creates its async counterpart with functools.partial.
The async client shares the decoder of the sync client, so the list of
registered custom responses is no longer needed.

The tests use the standard use_port and mock_server fixtures, run with
_run_async True and False, and check that an exception raised in the
async client reaches the caller.
@sankalpsthakur

Copy link
Copy Markdown
Contributor Author

Checked: run_coroutine_threadsafe().result() raises the exception of the coroutine in the calling thread. test_exception makes the async execute() raise a ModbusIOException and checks that the app gets the same exception, and that the client still works afterwards. test_connect_fails covers the ConnectionException.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants