Repository navigation
Discard the late response of a cancelled request - #3076
Conversation
|
I just tried your test case, as expected it shows the problem, so with that I will try
I do suspect however that the problem might be older, because I can produce the problem without cancelling:
This is of course a configuration problem (timeout too small), but the effect seems identical to cancelling. I will keep you informed of my findings, because your method to solve might be the best one. |
|
Used your test case on v3.15.0 modified line 321: to adapt to the non passing of cancel exception. Your test FAILS, meaning this is as suspected a very old problem. Your fix raises a big problem, because it should be allowed to send a request to another device on the same line, without delay: Ex 1:
Ex 2:
If we get those 2 examples working, the only outstanding problem is if timeout is configured too low:
This seems theoretically insolvable, but it is a configuration problem. |
|
I have updated your test case in #3077, to cover the scenarios I think are important. Please have a look at it, and feel free to amend commits if something seems wrong. As you can see, it causes a bit of problems with your PR; however I think you are on the right road. And of course you are added as co-author, since the original comes from your PR. |
|
thanks for writing the scenarios. i ran your test from #3077 here. async def _cancel_send(self, transact, transport, delay_cond, pdu):
"""Create task and wait."""
+ writes = transport.write.call_count
resp = asyncio.create_task(transact.execute(False, pdu))
delay = 0
- while transport.write.call_count < 2:
+ while transport.write.call_count == writes:
await asyncio.sleep(0.1)
delay += 1
@@
case "resend": # resend within timeout, must delay until after timeout
- resp = asyncio.create_task(transact.execute(False, req_org2))
await asyncio.sleep(0.1)timeout and new_id pass on dev, on ce90cff and on bbf30db, which i just pushed: after a cancel only the next request to the same device waits, and requests to other devices and requests with a transaction id go out at once. (new_id also passes with the old global wait, because with timeout 0.5 the wait ends within 5 ticks.
and how do you want to land it: rebase this PR on #3077, or take your test into this one? both change |
|
I am happy you liked the scenarios, it was easier than writing pseudo code :-) I have added your 2 first suggestions, thanks they are very much correct, and I have merged the PR to dev. Your 3 suggestion (resend) is not added, since it is a resend WITHIN the timeout, actually this is the most likely scenario....app cancels the request, and immediately reissued the same request. If you saw something I did not see in this scenario, please add your change again, but please explain why mine is wrong. resend: I am happy with the delay being timeout_connect, not considering cancel time, whatever is easier for you, new_fc: I think I just changed that on dev. Sorry it was a mix of 2 places making the same check. the message should be dropped. I think I made a wrong assert, feel free to correct it. Please rebase your PR on dev, and remove "pragma: no cover", "mark.skip" in transaction.py in your pr. Thanks a lot for your work, this is NOT an easy task and you are doing a great job !!!! I really hope you would like to help with other interesting problems later. |
|
Sorry the conflict is because I added a test to check that wrong responses are discarded. |
3d6fcf9 to
03343a6
Compare
|
thanks, glad the scenarios helped, and thanks for the kind words. i rebased this PR on dev and removed the skip and the new_fc: dev drops the response in resend: your scenario is right, the app cancels and sends the same request again at once. but the test had two requests: the
the delay keeps the cancel time, since that was already in place. i also removed the guard and its pragma in i'd be glad to help with other problems later too. |
|
The resend was my bad, thanks for catching it. I will review your code a bit later. |
janiversen
left a comment
There was a problem hiding this comment.
LGTM, nice idea with the dict key as tuple.
| raise self._io_exception_from_request(txt, request) | ||
|
|
||
| async def execute( | ||
| async def execute( # noqa: C901 |
There was a problem hiding this comment.
Would be nice with a followup PR that got rid of the noqa, by moving a part out in a new method
|
thanks for merging, and for writing the scenarios. happy to take the next one: is there a problem you'd like me to look at? |
|
For a starter please submit a PR, making the noqa unnecessary We try to solve the issues/problems when they arrives, so there are no open problems, but there are a number of pending enhancements:
These are all bigger problems, but can of course be split in chunks and thus something easier to make and to review. |
understood |
|
Thanks for merging #3084. I'd like to take the third item, the sync client using async execute internally. My plan: each sync client owns its async counterpart and a private event loop running in a daemon thread. A sync call submits the coroutine with Chunks, each with its tests and keeping the documented sync API:
Is this what you had in mind, or would you rather run a private loop with |
|
Your plan sounds logical, I only have one unknown, does asyncio.run_coroutine_threadsafe() impact the performance negatively ? Would it be a good idea to make a small example, with a function that eg. have a loop to a million. call it directly (sync) and via asyncio...while measuring the time I have been toying with a third idea. Start a new loop in the client init, and then use futures to send/receive the execute call. But admitted I never found time to actually try it out. Making the changes as you suggest, means the client changes can be isolated in mixin.py (the execute method) and thus in one go, be available for all clients....of course there will be some cleanup in each client. Can I politely suggest, that you make a PR very early, because then it is a lot easier to discuss the actual code. Thanks for taking this challenge, it is currently the biggest maintenance bottleneck, having 2 implementations the constantly differ slightly. |
|
I measured it on a Mac with Python 3.12: a local server, 5000 reads of 10 registers, median of 5 runs.
For a function that does nothing, the call itself costs 39 us through the thread and 29 us with So the thread adds about 75 us per request against the sync client today. Over a network or a serial line that disappears: reading 10 registers at 9600 baud alone takes about 34 ms on the wire (33 bytes at 8N1). Your idea, a loop created in I'll open a draft PR with the tcp client so we can discuss the code. |
|
Lets continue with the threadsafe solution, I expected a much bigger difference, thanks for investigating. I will continue in the PR. |
When an async
execute()is cancelled while it waits, the device can still answer after the next request is sent. With RTU/ASCII, which have no transaction id, and the same function code, the next request to that device then returns that stale data, as in #3070.execute()keeps a fresh response future for the late response, and the nextexecute()to the same device with the same function code waits for it before sending, at mosttimeout_connectafter the cancel. Requests to other devices, with another function code, or with a transaction id (socket framers, wherehandleFrameskips an old transaction id) are not delayed.callback_dataskips a response whose function code differs from the request's, before it sets the response future. Test wrong response gets discarded. #3078 drops it inexecute(), after it has taken the future, so a correct response right behind it was ignored and the request was sent again.CancelledErrorstill propagates as in Let CancelledError propagate from async execute #3010, and there is no API change.Validation
pragma: no coverremoved): timeout, resend, resend_response, new_id and new_fc. Ondev(45c8933), resend, resend_response and new_fc fail; with this change all pass.test_transaction_execute_cancel_no_wait(new): after a cancel, a request to another device, a request with another function code (FramerRTU) or a request with a transaction id (FramerSocket) is sent at once and gets its own response. The function code case fails ondev.pytest test/transaction: 54 passed on Python 3.10.18 and 3.14.7 (macOS), with 100% line and branch coverage ofpymodbus/transactionandtest/transaction.ruff check,ruff format --check,codespellandzuban checkon the touched files are clean, andpylintreports nothing new.execute()was already at the C901 limit of 10, so it gets# noqa: C901, likeconvert_from_registers.Fixes #3070
AI disclosure: written with an AI coding agent and tested as described above.