Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support for async dispatch #1771

Merged
merged 12 commits into from
Mar 26, 2024
Merged

Add basic support for async dispatch #1771

merged 12 commits into from
Mar 26, 2024

Conversation

corranwebster
Copy link
Contributor

This adds basic support for async coroutines as trait observe handlers, as discussed in #1770

This is designed to be the smallest thing that will work.

Checklist

  • Tests
  • Update API reference (docs/source/traits_api_reference) -- don't think there are any changes here
  • Update User manual (docs/source/traits_user_manual)
  • Update type annotation hints in stub files -- also no changes here

@corranwebster
Copy link
Contributor Author

Python 3.7 doesn't have unittest.IsolatedAsyncioTestCase. I propose removing 3.7 from our test suite, since we are a year after 3.7 EOL.

@mdickinson
Copy link
Member

I merged main into this branch (after merging #1773); that should get the tests passing again.

@corranwebster
Copy link
Contributor Author

Looks like whitespace code style errors because I did the work on my not-quite correctly configured personal machine.

Copy link
Member

@mdickinson mdickinson left a comment

Choose a reason for hiding this comment

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

Implementation LGTM; for the tests, I'd like to rework to avoid the sleep statements.

docs/source/traits_user_manual/notification.rst Outdated Show resolved Hide resolved
traits/observation/observe.py Outdated Show resolved Hide resolved
traits/observation/tests/test_observe.py Show resolved Hide resolved

dispatch_same(handler, event)

await asyncio.sleep(0.1)
Copy link
Member

Choose a reason for hiding this comment

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

Can we rework this (and similar tests) to wait on an event instead? That is, create an event with handled = asyncio.Event(), then do a handled.set() within the handler, and replace this line with something like await asyncio.wait(handled.wait(), SOME_TIMEOUT). Then SOME_TIMEOUT can be quite large (e.g., 60 seconds), since we don't expect to hit the timeout in a normal test run.


@observe('value')
async def value_changed_async(self, event):
await asyncio.sleep(0)
Copy link
Member

Choose a reason for hiding this comment

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

What's the purpose of the sleep here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My understanding is that this yields execution to the event loop - originally I had a longer sleep here, but less than the sleep in the test. It may not be needed - but I would like the async function to be actually doing something async-y before it sets the trait.

from traits.observation._observe import add_or_remove_notifiers
from traits.observation.expression import compile_expr

#: Set to hold references to active async traits handlers.
_active_handler_tasks = set()
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering whether we need this to be thread-local, given that different threads might have different current event loops. It's probably okay to have multiple threads modifying a single set simultaneously, if there's no overlap in the actual elements being added / removed. Not sure whether that's still going to be true in free-threaded Python 3.13, though. (OTOH, we've got significant work to do if we ever want to make Traits compatible with free-threading.)

Copy link
Contributor Author

@corranwebster corranwebster Mar 18, 2024

Choose a reason for hiding this comment

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

I think this is fine - each object in the set is independent and there should never be the same task created twice in different threads. I can see a problem if somehow the task gets finished before the callback is added, but I don't know if that is possible.

Copy link
Member

Choose a reason for hiding this comment

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

each object in the set is independent and there should never be the same task created twice in different threads

Agreed, but the set structure itself would be shared between calls. Right now I think the GIL protects us, but that may eventually no longer be true.

I can see a problem if somehow the task gets finished before the callback is added, but I don't know if that is possible.

Yep, I don't think that's possible. The task can't start executing until we hit a yield point, so it won't even start until after the callback is added.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, but the set structure itself would be shared between calls. Right now I think the GIL protects us, but that may eventually no longer be true.

Not sure exactly what the free-threading folks are proposing, but if threaded code needs to worry about adding locks around shared containers at the Python level, it probably won't get adopted widely. But if it does there'll be plenty of warning I suspect.

Co-authored-by: Mark Dickinson <mdickinson@enthought.com>
@corranwebster
Copy link
Contributor Author

Looks like asyncio.timeout is only Python 3.11 or later ☹️

@corranwebster
Copy link
Contributor Author

All the twisty ins and outs of different Python versions accounted for, I think.

Would feel more comfortable if we tested the async handlers actually doing something async-y, so may add something to the tests to do that.

@corranwebster
Copy link
Contributor Author

Ready for a re-review.

with self.asyncio_exception_handler(exception_handler):
with self.assertRaises(asyncio.exceptions.TimeoutError):
dispatch_same(handler, event)
await asyncio.wait_for(event.wait(), timeout=0.1)
Copy link
Member

Choose a reason for hiding this comment

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

I adjusted this slightly in cc9127c to move the event to the exception handler, which then gives us something to wait on.

Copy link
Member

@mdickinson mdickinson left a comment

Choose a reason for hiding this comment

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

LGTM

@mdickinson mdickinson merged commit 2d13ed5 into main Mar 26, 2024
29 checks passed
@mdickinson mdickinson deleted the enh/async-event-handler branch March 26, 2024 10:02
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