Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
gh-79012: Address review feedback on asyncio chat server HOWTO
- Explain concepts (start_server, StreamReader/StreamWriter) before code
- Use asyncio.TaskGroup for concurrent broadcasting
- Use contextlib.suppress instead of bare except/pass
- Remove Python test client, keep only nc/telnet
- Properly explain asyncio.timeout before showing usage
- Move implementation notes to code comments
- Remove Exercises and Common pitfalls sections
- Reorder seealso links in asyncio.rst

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
kovan and claude committed Feb 10, 2026
commit 3f5b5aeab52ffeb36818ba62c9705e1297fb3d0a
235 changes: 66 additions & 169 deletions Doc/howto/asyncio-chat-server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ managers --- and a general understanding of async/await.
.. seealso::

:ref:`a-conceptual-overview-of-asyncio`
An explanation of how asyncio works under the hood.
An introduction to the fundamentals of asyncio.

:mod:`asyncio` reference documentation
The complete API reference.
Expand All @@ -26,10 +26,16 @@ Starting with an echo server
============================

Before building the chat server, let's start with something simpler: an echo
server that sends back whatever a client sends. This introduces the
:ref:`streams <asyncio-streams>` API that the chat server builds on.
server that sends back whatever a client sends.

::
The core of any asyncio network server is :func:`asyncio.start_server`. You
give it a callback function, a host, and a port. When a client connects,
asyncio calls your callback with two arguments: a
:class:`~asyncio.StreamReader` for receiving data and a
:class:`~asyncio.StreamWriter` for sending data back. Each connection runs
as its own coroutine, so multiple clients are handled concurrently.

Here is a complete echo server::

import asyncio

Expand Down Expand Up @@ -59,52 +65,15 @@ server that sends back whatever a client sends. This introduces the

asyncio.run(main())

:func:`asyncio.start_server` listens for incoming connections. Each time a
client connects, it calls ``handle_client`` with a
:class:`~asyncio.StreamReader` and a :class:`~asyncio.StreamWriter`. Multiple
clients are handled concurrently --- each connection runs as its own coroutine.

Two patterns are essential when working with streams:

- **Write then drain:** :meth:`~asyncio.StreamWriter.write` buffers data.
``await`` :meth:`~asyncio.StreamWriter.drain` ensures it is actually sent
(and applies back-pressure if the client is slow to read).

- **Close then wait_closed:** :meth:`~asyncio.StreamWriter.close` initiates
shutdown. ``await`` :meth:`~asyncio.StreamWriter.wait_closed` waits until
the connection is fully closed.


Testing with a client
---------------------

To test the echo server, run it in one terminal and this client in another::

import asyncio

async def main():
reader, writer = await asyncio.open_connection(
'127.0.0.1', 8888)

for message in ['Hello!\n', 'How are you?\n', 'Goodbye!\n']:
writer.write(message.encode())
await writer.drain()

data = await reader.readline()
print(f'Received: {data.decode().strip()!r}')

writer.close()
await writer.wait_closed()

asyncio.run(main())

.. code-block:: none
The :meth:`~asyncio.StreamWriter.write` method buffers data without sending
it immediately. Awaiting :meth:`~asyncio.StreamWriter.drain` flushes the
buffer and applies back-pressure if the client is slow to read. Similarly,
:meth:`~asyncio.StreamWriter.close` initiates shutdown, and awaiting
:meth:`~asyncio.StreamWriter.wait_closed` waits until the connection is
fully closed.
Comment thread
ZeroIntensity marked this conversation as resolved.
Outdated

Received: 'Hello!'
Received: 'How are you?'
Received: 'Goodbye!'

You can also test using ``telnet`` or ``nc``:
To test, run the server in one terminal and connect from another using ``nc``
(or ``telnet``):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this in parentheses?

Suggested change
(or ``telnet``):
or ``telnet``:


.. code-block:: none

Expand All @@ -116,24 +85,34 @@ You can also test using ``telnet`` or ``nc``:
Building the chat server
========================

The chat server extends the echo server with two key additions: tracking
connected clients and broadcasting messages to all of them.
The chat server extends the echo server with two additions: tracking connected
clients and broadcasting messages to everyone.

We store each client's name and :class:`~asyncio.StreamWriter` in a dictionary.
When a message arrives, we broadcast it to all other connected clients.
:class:`asyncio.TaskGroup` sends to all recipients concurrently, and
:func:`contextlib.suppress` silently handles any :exc:`ConnectionError` from
clients that have already disconnected.

::

import asyncio
import contextlib

connected_clients: dict[str, asyncio.StreamWriter] = {}

async def broadcast(message, *, sender=None):
"""Send a message to all connected clients except the sender."""
for name, writer in list(connected_clients.items()):
if name != sender:
try:
writer.write(message.encode())
await writer.drain()
except ConnectionError:
pass # Client disconnected; cleaned up elsewhere.
async def send(writer):
with contextlib.suppress(ConnectionError):
writer.write(message.encode())
await writer.drain()

async with asyncio.TaskGroup() as tg:
# Iterate over a copy: clients may leave during the broadcast.
for name, writer in list(connected_clients.items()):
if name != sender:
tg.create_task(send(writer))

async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
Expand Down Expand Up @@ -163,6 +142,7 @@ connected clients and broadcasting messages to all of them.
except ConnectionError:
pass
finally:
# Ensure cleanup even if the client disconnects unexpectedly.
del connected_clients[name]
print(f'{name} ({addr}) has left')
await broadcast(f'*** {name} has left the chat ***\n')
Expand All @@ -180,22 +160,8 @@ connected clients and broadcasting messages to all of them.

asyncio.run(main())

Some things to note about this design:

- **No locks needed.** ``connected_clients`` is a plain :class:`dict`.
Because asyncio runs in a single thread, no other task can modify it between
``await`` points.

- **Iterating a copy.** ``broadcast()`` iterates over ``list(...)`` because a
client might disconnect (and be removed from the dict) while we are
broadcasting.

- **Cleanup in** ``finally``. The ``try``/``finally`` block ensures the
client is removed from ``connected_clients`` and the connection is closed
even if the client disconnects unexpectedly.

To test, start the server in one terminal and connect from two or more others
using ``telnet`` or ``nc``:
To test, start the server and connect from two or more terminals using ``nc``
(or ``telnet``):

.. code-block:: none

Expand All @@ -208,103 +174,34 @@ using ``telnet`` or ``nc``:
Each message you type is broadcast to all other connected users.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Each message you type is broadcast to all other connected users.
Each message you type is broadcasted to all other connected users.



.. _asyncio-chat-server-extending:

Extending the chat server
=========================

The chat server is a good foundation to build on. Here are some ideas to
try.
.. _asyncio-chat-server-timeout:

Adding an idle timeout
----------------------
======================

Disconnect users who have been idle for too long using
:func:`asyncio.timeout`::
To disconnect clients who have been idle for too long, wrap the read call in
:func:`asyncio.timeout`. This async context manager takes a duration in
seconds. If the enclosed ``await`` does not complete within that time, the
operation is cancelled and :exc:`TimeoutError` is raised. This frees server
resources when clients connect but stop sending data.

async def handle_client(reader, writer):
# ... (name registration as before) ...
try:
while True:
try:
async with asyncio.timeout(300): # 5-minute timeout
data = await reader.readline()
except TimeoutError:
writer.write(b'Disconnected: idle timeout.\n')
await writer.drain()
break
if not data:
break
message = data.decode().strip()
if message:
await broadcast(f'{name}: {message}\n', sender=name)
except ConnectionError:
pass
finally:
# ... (cleanup as before) ...

Exercises
---------

These exercises build on the chat server:

- **Add a** ``/quit`` **command** that lets a user disconnect gracefully by
typing ``/quit``.

- **Add private messaging.** If a user types ``/msg Alice hello``, only
Alice should receive the message.

- **Log messages to a file** using :func:`asyncio.to_thread` to avoid
blocking the event loop during file writes.

- **Limit concurrent connections** using :class:`asyncio.Semaphore` to
restrict the server to a maximum number of users.


.. _asyncio-chat-server-pitfalls:

Common pitfalls
===============

Forgetting to await
-------------------
Replace the message loop in ``handle_client`` with::

Calling a coroutine function without ``await`` creates a coroutine object but
does not run it::

async def main():
asyncio.sleep(1) # Wrong: creates a coroutine but never runs it.
await asyncio.sleep(1) # Correct.

Python will emit a :exc:`RuntimeWarning` if a coroutine is never awaited.
If you see ``RuntimeWarning: coroutine '...' was never awaited``, check for a
missing ``await``.

Blocking the event loop
-----------------------

Calling blocking functions like :func:`time.sleep` or performing synchronous
I/O inside a coroutine freezes the entire event loop::

async def bad():
time.sleep(5) # Wrong: blocks the event loop for 5 seconds.

async def good():
await asyncio.sleep(5) # Correct: suspends without blocking.
await asyncio.to_thread(time.sleep, 5) # Also correct: runs in a thread.

You can use :ref:`debug mode <asyncio-debug-mode>` to detect blocking calls:
pass ``debug=True`` to :func:`asyncio.run`.

Fire-and-forget tasks disappearing
-----------------------------------

If you create a task without keeping a reference to it, the task may be
garbage collected before it finishes::

async def main():
asyncio.create_task(some_coroutine()) # No reference kept!
await asyncio.sleep(10)

Use :class:`asyncio.TaskGroup` to manage task lifetimes, or store task
references in a collection.
try:
while True:
try:
async with asyncio.timeout(300): # 5-minute timeout
data = await reader.readline()
except TimeoutError:
writer.write(b'Disconnected: idle timeout.\n')
await writer.drain()
break
if not data:
break
message = data.decode().strip()
if message:
await broadcast(f'{name}: {message}\n', sender=name)
except ConnectionError:
pass
finally:
# ... (cleanup as before) ...
6 changes: 3 additions & 3 deletions Doc/library/asyncio.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ asyncio is often a perfect fit for IO-bound and high-level

.. seealso::

:ref:`asyncio-chat-server-howto`
Build a TCP chat server with asyncio streams.

:ref:`a-conceptual-overview-of-asyncio`
Explanation of the fundamentals of asyncio.

:ref:`asyncio-chat-server-howto`
Build a TCP chat server with asyncio streams.

asyncio provides a set of **high-level** APIs to:

* :ref:`run Python coroutines <coroutine>` concurrently and
Expand Down
Loading