@@ -28,29 +28,56 @@ Starting with an echo server
2828Before building the chat server, let's start with something simpler: an echo
2929server that sends back whatever a client sends.
3030
31+ Accepting connections
32+ ---------------------
33+
3134The core of any asyncio network server is :func: `asyncio.start_server `. You
3235give 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-
8197To 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``
91107Building 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
101119module-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
106138Broadcasting messages
107139---------------------
108140
109141To 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+
127173The 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+
196253To 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