Skip to content

Commit 5582825

Browse files
kovanclaude
andcommitted
gh-79012: Restructure HOWTO based on review feedback
- Introduce concepts incrementally with small examples before full code - Split echo server into subsections: accepting connections, running the server, reading and writing data - Explain the problem (why track clients?) before showing the solution - Move idle timeout section above the complete example and integrate timeout into the full chat server code - Move join broadcast inside try block for proper cleanup on error - Add note about duplicate client names (exercise for the reader) - Replace contextlib.suppress prose explanation with inline comment - Fix telnet parentheses, apply "broadcasted" wording Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3b2356d commit 5582825

File tree

1 file changed

+107
-83
lines changed

1 file changed

+107
-83
lines changed

Doc/howto/asyncio-chat-server.rst

Lines changed: 107 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,56 @@ Starting with an echo server
2828
Before building the chat server, let's start with something simpler: an echo
2929
server that sends back whatever a client sends.
3030

31+
Accepting connections
32+
---------------------
33+
3134
The core of any asyncio network server is :func:`asyncio.start_server`. You
3235
give it a callback function, a host, and a port. When a client connects,
33-
asyncio calls your callback with two arguments: a
34-
:class:`~asyncio.StreamReader` for receiving data and a
35-
:class:`~asyncio.StreamWriter` for sending data back. Each connection runs
36-
as its own coroutine, so multiple clients are handled concurrently.
37-
38-
The :meth:`~asyncio.StreamWriter.write` method buffers data without sending
39-
it immediately. Awaiting :meth:`~asyncio.StreamWriter.drain` flushes the
40-
buffer and applies back-pressure if the client is slow to read. Similarly,
41-
:meth:`~asyncio.StreamWriter.close` initiates shutdown, and awaiting
42-
:meth:`~asyncio.StreamWriter.wait_closed` waits until the connection is
43-
fully closed.
44-
45-
Using the server as an async context manager (``async with server``) ensures
46-
it is properly cleaned up when done. Calling
47-
:meth:`~asyncio.Server.serve_forever` keeps the server running until the
48-
program is interrupted. Finally, :func:`asyncio.run` starts the event loop
49-
and runs the top-level coroutine.
50-
51-
Here is a complete echo server::
36+
asyncio calls your callback with a :class:`~asyncio.StreamReader` for receiving
37+
data and a :class:`~asyncio.StreamWriter` for sending it back.
5238

53-
import asyncio
39+
Here is a minimal callback that accepts a connection, prints the client's
40+
address, and immediately closes it. The :meth:`~asyncio.StreamWriter.close`
41+
method initiates the connection shutdown, and awaiting
42+
:meth:`~asyncio.StreamWriter.wait_closed` waits until it is fully closed::
43+
44+
async def handle_client(reader, writer):
45+
addr = writer.get_extra_info('peername')
46+
print(f'New connection from {addr}')
47+
writer.close()
48+
await writer.wait_closed()
49+
50+
Running the server
51+
------------------
52+
53+
To keep the server running and accepting connections, use
54+
:meth:`~asyncio.Server.serve_forever` inside an ``async with`` block.
55+
Using the server as an async context manager ensures it is properly cleaned up
56+
when done, and :meth:`~asyncio.Server.serve_forever` keeps it running until the
57+
program is interrupted. Finally, :func:`asyncio.run` starts the event loop and
58+
runs the top-level coroutine::
59+
60+
async def main():
61+
server = await asyncio.start_server(
62+
handle_client, '127.0.0.1', 8888)
63+
async with server:
64+
await server.serve_forever()
65+
66+
asyncio.run(main())
67+
68+
Reading and writing data
69+
------------------------
70+
71+
To turn this into an echo server, the callback needs to read data from the
72+
client and send it back. :meth:`~asyncio.StreamReader.readline` reads one line
73+
at a time, returning an empty :class:`bytes` object when the client
74+
disconnects.
75+
76+
:meth:`~asyncio.StreamWriter.write` buffers outgoing data without sending it
77+
immediately. Awaiting :meth:`~asyncio.StreamWriter.drain` flushes the buffer
78+
and applies back-pressure if the client is slow to read.
79+
80+
With these pieces, the echo callback becomes::
5481

5582
async def handle_client(reader, writer):
5683
addr = writer.get_extra_info('peername')
@@ -67,19 +94,8 @@ Here is a complete echo server::
6794
writer.close()
6895
await writer.wait_closed()
6996

70-
async def main():
71-
server = await asyncio.start_server(
72-
handle_client, '127.0.0.1', 8888)
73-
addr = server.sockets[0].getsockname()
74-
print(f'Serving on {addr}')
75-
76-
async with server:
77-
await server.serve_forever()
78-
79-
asyncio.run(main())
80-
8197
To test, run the server in one terminal and connect from another using ``nc``
82-
(or ``telnet``):
98+
or ``telnet``:
8399

84100
.. code-block:: none
85101
@@ -91,43 +107,74 @@ To test, run the server in one terminal and connect from another using ``nc``
91107
Building the chat server
92108
========================
93109

94-
The chat server extends the echo server with two additions: tracking connected
95-
clients and broadcasting messages to everyone.
110+
The echo server handles each client independently --- it reads from one client
111+
and writes back to the same client. A chat server, on the other hand, needs to
112+
deliver each message to *every* connected client. This means the server must
113+
keep track of who is connected so it can send messages to all of them.
96114

97-
Client tracking
98-
---------------
115+
Tracking connected clients
116+
--------------------------
99117

100-
We store each connected client's name and :class:`~asyncio.StreamWriter` in a
118+
We store each client's name and :class:`~asyncio.StreamWriter` in a
101119
module-level dictionary. When a client connects, ``handle_client`` prompts for
102-
a name and adds the writer to the dictionary. A ``finally`` block ensures the
103-
client is always removed on disconnect, even if the connection drops
104-
unexpectedly.
120+
a name and adds the writer to the dictionary. A ``finally`` block ensures
121+
the client is always removed on disconnect, even if the connection drops
122+
unexpectedly::
123+
124+
connected_clients: dict[str, asyncio.StreamWriter] = {}
125+
126+
async def handle_client(reader, writer):
127+
writer.write(b'Enter your name: ')
128+
await writer.drain()
129+
name = (await reader.readline()).decode().strip()
130+
connected_clients[name] = writer
131+
try:
132+
... # message loop (shown below)
133+
finally:
134+
del connected_clients[name]
135+
writer.close()
136+
await writer.wait_closed()
105137

106138
Broadcasting messages
107139
---------------------
108140

109141
To send a message to all clients, we define a ``broadcast`` function.
110142
:class:`asyncio.TaskGroup` sends to all recipients concurrently rather than
111-
one at a time. :func:`contextlib.suppress` silently handles any
112-
:exc:`ConnectionError` from clients that have already disconnected::
143+
one at a time::
113144

114145
async def broadcast(message, *, sender=None):
115146
"""Send a message to all connected clients except the sender."""
116147
async def send(writer):
148+
# Ignore clients that have already disconnected.
117149
with contextlib.suppress(ConnectionError):
118150
writer.write(message.encode())
119151
await writer.drain()
120152

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

158+
159+
.. _asyncio-chat-server-timeout:
160+
161+
Adding an idle timeout
162+
----------------------
163+
164+
To disconnect clients who have been idle for too long, wrap the read call in
165+
:func:`asyncio.timeout`. This async context manager takes a delay in seconds.
166+
If the enclosed ``await`` does not complete within that time, the operation is
167+
cancelled and :exc:`TimeoutError` is raised, freeing server resources when
168+
clients connect but stop sending data::
169+
170+
async with asyncio.timeout(300): # 5-minute timeout
171+
data = await reader.readline()
172+
127173
The complete chat server
128174
------------------------
129175

130-
Putting it all together::
176+
Putting it all together, here is the complete chat server with client tracking,
177+
broadcasting, and an idle timeout::
131178

132179
import asyncio
133180
import contextlib
@@ -137,12 +184,12 @@ Putting it all together::
137184
async def broadcast(message, *, sender=None):
138185
"""Send a message to all connected clients except the sender."""
139186
async def send(writer):
187+
# Ignore clients that have already disconnected.
140188
with contextlib.suppress(ConnectionError):
141189
writer.write(message.encode())
142190
await writer.drain()
143191

144192
async with asyncio.TaskGroup() as tg:
145-
# Iterate over a copy: clients may leave during the broadcast.
146193
for name, writer in list(connected_clients.items()):
147194
if name != sender:
148195
tg.create_task(send(writer))
@@ -161,11 +208,17 @@ Putting it all together::
161208
name = data.decode().strip()
162209
connected_clients[name] = writer
163210
print(f'{name} ({addr}) has joined')
164-
await broadcast(f'*** {name} has joined the chat ***\n', sender=name)
165211

166212
try:
213+
await broadcast(f'*** {name} has joined the chat ***\n', sender=name)
167214
while True:
168-
data = await reader.readline()
215+
try:
216+
async with asyncio.timeout(300): # 5-minute timeout
217+
data = await reader.readline()
218+
except TimeoutError:
219+
writer.write(b'Disconnected: idle timeout.\n')
220+
await writer.drain()
221+
break
169222
if not data:
170223
break
171224
message = data.decode().strip()
@@ -175,7 +228,6 @@ Putting it all together::
175228
except ConnectionError:
176229
pass
177230
finally:
178-
# Ensure cleanup even if the client disconnects unexpectedly.
179231
del connected_clients[name]
180232
print(f'{name} ({addr}) has left')
181233
await broadcast(f'*** {name} has left the chat ***\n')
@@ -193,8 +245,13 @@ Putting it all together::
193245

194246
asyncio.run(main())
195247

248+
.. note::
249+
250+
This server does not handle two clients choosing the same name. Adding
251+
support for unique names is left as an exercise for the reader.
252+
196253
To test, start the server and connect from two or more terminals using ``nc``
197-
(or ``telnet``):
254+
or ``telnet``:
198255

199256
.. code-block:: none
200257
@@ -204,37 +261,4 @@ To test, start the server and connect from two or more terminals using ``nc``
204261
Bob: Hi Alice!
205262
Hello Bob!
206263
207-
Each message you type is broadcast to all other connected users.
208-
209-
210-
.. _asyncio-chat-server-timeout:
211-
212-
Adding an idle timeout
213-
======================
214-
215-
To disconnect clients who have been idle for too long, wrap the read call in
216-
:func:`asyncio.timeout`. This async context manager takes a duration in
217-
seconds. If the enclosed ``await`` does not complete within that time, the
218-
operation is cancelled and :exc:`TimeoutError` is raised. This frees server
219-
resources when clients connect but stop sending data.
220-
221-
Replace the message loop in ``handle_client`` with::
222-
223-
try:
224-
while True:
225-
try:
226-
async with asyncio.timeout(300): # 5-minute timeout
227-
data = await reader.readline()
228-
except TimeoutError:
229-
writer.write(b'Disconnected: idle timeout.\n')
230-
await writer.drain()
231-
break
232-
if not data:
233-
break
234-
message = data.decode().strip()
235-
if message:
236-
await broadcast(f'{name}: {message}\n', sender=name)
237-
except ConnectionError:
238-
pass
239-
finally:
240-
# ... (cleanup as before) ...
264+
Each message you type is broadcasted to all other connected users.

0 commit comments

Comments
 (0)