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

Use AnyIO #77

Merged
merged 18 commits into from
Jun 16, 2023
Merged

Use AnyIO #77

merged 18 commits into from
Jun 16, 2023

Conversation

davidbrochart
Copy link
Collaborator

@davidbrochart davidbrochart commented Jun 7, 2023

This PR has breaking changes, and should probably be released as v1.0.
In particular, WebsocketProvider, WebsocketServer, YRoom and YStore must either be used with an async context manager (this is the preferred way), or using lower-level start() and stop() methods.
Before this PR, some tasks were created implicitly on instanciation. The new approach is more explicit regarding the async nature of these objects, and ensures no task is left running on tear-down. Under the hood, AnyIO's task groups are used, but ypy-websocket can be used in a "pure asyncio" environment, no need to adopt AnyIO outside of this library.
Here is an example with an async context manager:

async def main():
    async with WebsocketServer():
        ...

Which is equivalent to the following, when using the lower-level API (with start()/stop()):

async def main():
    server = WebsocketServer()
    task = asyncio.create_task(server.start())
    await server.started.wait()
    ...
    server.stop()

Closes #76.

@davidbrochart davidbrochart marked this pull request as draft June 7, 2023 14:29
@davidbrochart davidbrochart force-pushed the anyio branch 2 times, most recently from 5f5ef34 to 210f6f4 Compare June 7, 2023 14:52
@davidbrochart davidbrochart marked this pull request as ready for review June 7, 2023 14:52
@davidbrochart davidbrochart changed the title Use AnyIO's task groups Use AnyIO Jun 8, 2023
Copy link
Collaborator

@fcollonval fcollonval left a comment

Choose a reason for hiding this comment

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

Thanks @davidbrochart

I have questions trying to understand all changes. It would also be great to add more documentation to the code especially as it is meant to be extended by other libraries.

ypy_websocket/websocket_server.py Outdated Show resolved Hide resolved
Comment on lines 62 to 67
async def run(self):
async with create_task_group() as self._task_group:
self._task_group.start_soon(self._run)

def stop(self):
self._task_group.cancel_scope.cancel()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm probably missing something but why not removing all public API (aka run and stop) as this should only be used as async context manager according to the PR description?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I want to allow both an async context manager API (that should be the preferred way) and a lower-level API with start() and stop() equivalent methods. It is a common practice. I'll update the PR description.

Comment on lines 52 to 58
async def serve(self, websocket):
room = self.get_room(websocket.path)
room.clients.append(websocket)
await sync(room.ydoc, websocket, self.log)
async for message in websocket:
# filter messages (e.g. awareness)
skip = False
if room.on_message:
skip = await room.on_message(message)
if skip:
continue
message_type = message[0]
if message_type == YMessageType.SYNC:
# update our internal state in the background
# changes to the internal state are then forwarded to all clients
# and stored in the YStore (if any)
task = asyncio.create_task(
process_sync_message(message[1:], room.ydoc, websocket, self.log)
)
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
elif message_type == YMessageType.AWARENESS:
# forward awareness messages from this client to all clients,
# including itself, because it's used to keep the connection alive
self.log.debug(
"Received %s message from endpoint: %s",
YMessageType.AWARENESS.name,
websocket.path,
)
for client in room.clients:
self.log.debug(
"Sending Y awareness from client with endpoint %s to client with endpoint: %s",
websocket.path,
client.path,
)
task = asyncio.create_task(client.send(message))
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
# remove this client
room.clients = [c for c in room.clients if c != websocket]
if self.auto_clean_rooms and not room.clients:
self.delete_room(room=room)
if self._task_group is None:
raise RuntimeError(
"The WebsocketServer is not running: use `async with websocket_server:` or `await websocket_server.run()`"
)

await self._task_group.start(self._serve, websocket)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not starting to serve directly? The need to do

await websocket_server.run()
await websocket_server.serve()

seems error prone to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The serve() method serves one WebSocket, but the server can serve multiple WebSockets. The goal of the (now called) start() method is to create the main task group, whose cancel scope is cancelled when stop() is called or when the async context manager exits. This ensures no task is running when the WebsocketServer stops.

Comment on lines 86 to 95
async def enter(self):
if self._entered:
return

async with create_task_group() as self._task_group:
self._task_group.start_soon(self._broadcast_updates)
self._entered = True

def exit(self):
self._task_group.cancel_scope.cancel()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it make sense to align the wording using run/stop?

It seems you implemented a flag to ensure enter is called only once, why not implementing the flag completely and prevent exit if not enter was called or avoiding clashing between using the object in async context manager and public API -- this applies to the other async context manager objects too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Would it make sense to align the wording using run/stop?

Yes, it's done in f0fa069.

It seems you implemented a flag to ensure enter is called only once, why not implementing the flag completely and prevent exit if not enter was called or avoiding clashing between using the object in async context manager and public API -- this applies to the other async context manager objects too.

Good point, I'll make the code more robust in that regard, thanks.

self._task_group.cancel_scope.cancel()
return await self._exit_stack.__aexit__(exc_type, exc_value, exc_tb)

async def start(self):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same question about naming alignment and protection of the two API usage.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

👍

@davidbrochart
Copy link
Collaborator Author

I have questions trying to understand all changes. It would also be great to add more documentation to the code especially as it is meant to be extended by other libraries.

Thanks for the review @fcollonval, I updated the PR description. Where do you think I should add comments in the code in particular?

@davidbrochart
Copy link
Collaborator Author

The documentation is accessible at https://davidbrochart.github.io/ypy-websocket.

@davidbrochart
Copy link
Collaborator Author

See jupyter-server/jupyverse#320 for an example of needed changes.

@fcollonval
Copy link
Collaborator

Thanks a lot for the comments and the documentation @davidbrochart
The current documentation is a great start. I would say the main missing thing in the doc is in the overview a graph and a description of the roles and interactions of the provided classes: Server / websocket / wsprovider / room / store.

@fcollonval
Copy link
Collaborator

best would be to use mermaidJS so the graph are nicely display directly in GitHub.

@davidbrochart
Copy link
Collaborator Author

Thanks for the suggestion @fcollonval, that's a good idea. Here is how it currently looks (open to feedback):
image

@fcollonval
Copy link
Collaborator

Thanks David this looks great.

@davidbrochart davidbrochart merged commit 17a2e14 into y-crdt:main Jun 16, 2023
@davidbrochart davidbrochart deleted the anyio branch June 16, 2023 13:18
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