Search code examples
pythonpython-3.xpython-asyncio

How to gracefully stop an asyncio server in python 3.8


As part of learning python and asyncio I have a simple TCP client/server architecture using asyncio (I have reasons why I need to use that) where I want the server to completely exit when it receives the string 'quit' from the client. The server, stored in asyncio_server.py, looks like this:

import socket
import asyncio

class Server:
    def __init__(self, host, port):
        self.host = host
        self.port = port

    async def handle_client(self, reader, writer):
        # Callback from asyncio.start_server() when
        # a client tries to establish a connection
        addr = writer.get_extra_info('peername')
        print(f'Accepted connection from {addr!r}')

        request = None

        try:
            while request != 'quit':
                request = (await reader.read(255)).decode('utf8')
                print(f"Received {request!r} from {addr!r}")

                response = 'ok'

                writer.write(response.encode('utf8'))
                await writer.drain()

                print(f'Sent {response!r} to {addr!r}')
                print('----------')

        except Exception as e:
            print(f"Error handling client {addr!r}: {e}")
        finally:
            print(f"Connection closed by {addr!r}")

        writer.close()
        await writer.wait_closed()
        print(f'Closed the connection with {addr!r}')

        asyncio.get_event_loop().stop()     # <<< WHAT SHOULD THIS BE?

    async def start(self):
        server = await asyncio.start_server(self.handle_client, self.host, self.port)

        async with server:
            print(f"Serving on {self.host}:{self.port}")
            await server.serve_forever()

async def main():
    server = Server(socket.gethostname(), 5000)
    await server.start()

if __name__ == '__main__':
    asyncio.run(main())

and when the client sends quit the connection is closed and the server exits but always with the error message:

Traceback (most recent call last):
  File "asyncio_server.py", line 54, in <module>
    asyncio.run(main())
  File "C:\Python38-32\lib\asyncio\runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "C:\Python38-32\lib\asyncio\base_events.py", line 614, in run_until_complete
    raise RuntimeError('Event loop stopped before Future completed.')
RuntimeError: Event loop stopped before Future completed.

What do I need to do instead of or in addition to calling asyncio.get_event_loop().stop() and/or server.serve_forever() to have the server exit gracefully with no error messages?

I've tried every alternative I can find with google, including calling cancel() on the server, using separate loop construct in main(), trying alternatives to stop(), alternatives to run_forever(), etc., etc. and cannot figure out what I'm supposed to do to gracefully stop the server and exit the program without error messages when it receives a quit message from the client.

I'm using python 3.8.10 and cannot upgrade to a newer version due to managed environment constraints. I'm also calling python from git bash in case that matters.


Additional Information:

The client code, stored in asyncio_client.py`, is below in case that's useful.

import socket
import asyncio

class Client:
    def __init__(self, host, port):
        self.host = host
        self.port = port

    async def handle_server(self, reader, writer):
        addr = writer.get_extra_info('peername')
        print(f'Connected to from {addr!r}')

        request = input('Enter Request: ')

        while request.lower().strip() != 'quit':
            writer.write(request.encode())
            await writer.drain()
            print(f'Sent {request!r} to {addr!r}')

            response = await reader.read(1024)
            print(f'Received {response.decode()!r} from {addr!r}')
            print('----------')

            request = input('Enter Request: ')

        writer.write(request.encode())
        await writer.drain()
        print(f'Sent {request!r} to {addr!r}')

        writer.close()
        await writer.wait_closed()
        print(f'Closed the connection with {addr!r}')

    async def start(self):
        reader, writer = await asyncio.open_connection(host=self.host, port=self.port)

        await self.handle_server(reader, writer)

async def main():
    client = Client(socket.gethostname(), 5000)
    await client.start()

if __name__ == '__main__':
    asyncio.run(main())

Solution

  • You can use an asyncio.Event to set when the QUIT message arrives. Then have asyncio wait for the either server_forever() or the Event to complete first.

    Once the Event is set, call the .close() method and stop the server. In the code below I assigned the server to an attribute.

    import socket
    import asyncio
    
    class Server:
        def __init__(self, host, port):
            self.host = host
            self.port = port
            self._server = None
            self._shutdown_event = asyncio.Event()
    
        async def handle_client(self, reader, writer):
            addr = writer.get_extra_info('peername')
            print(f'Accepted connection from {addr!r}')
            request = None
    
            try:
                while request != 'quit':
                    request = (await reader.read(255)).decode('utf8')
                    print(f"Received {request!r} from {addr!r}")
                    writer.write(b'ok')
                    await writer.drain()
                    print(f'Sent "ok" to {addr!r}')
                    print('----------')
                    if request == 'quit':
                        self._shutdown_event.set() # Signal shutdown
                        await writer.drain()  # Try to drain before closing
                        break # exit the loop
            except Exception as e:
                print(f"Error handling client {addr!r}: {e}")
            finally:
                print(f"Connection closed by {addr!r}")
    
            writer.close()
            await writer.wait_closed()
            print(f'Closed the connection with {addr!r}')
    
        async def start(self):
            self._server = await asyncio.start_server(self.handle_client, self.host, self.port)
            async with self._server:
                print(f"Serving on {self.host}:{self.port}")
                await asyncio.wait(
                    [
                        self._server.serve_forever(), 
                        self._shutdown_event.wait()
                    ], 
                    return_when=asyncio.FIRST_COMPLETED
                )
                await self.stop()
    
        async def stop(self):
            if self._server:
                self._server.close()
                await self._server.wait_closed()
                print("Server stopped.")
    
    async def main():
        server = Server(socket.gethostname(), 5000)
        try:
            await server.start()
        except asyncio.CancelledError:
            print("Server task was cancelled.")
        except Exception as e:
            print(f"An error occurred: {e}")
    
    if __name__ == '__main__':
        asyncio.run(main())
    

    Also, your client code is not reading the final message sent by the server after a quit is received. It is a small addition:

    import socket
    import asyncio
    
    class Client:
        def __init__(self, host, port):
            self.host = host
            self.port = port
    
        async def handle_server(self, reader, writer):
            addr = writer.get_extra_info('peername')
            print(f'Connected to from {addr!r}')
    
            request = input('Enter Request: ')
    
            while request.lower().strip() != 'quit':
                writer.write(request.encode())
                await writer.drain()
                print(f'Sent {request!r} to {addr!r}')
    
                response = await reader.read(1024)
                print(f'Received {response.decode()!r} from {addr!r}')
                print('----------')
    
                request = input('Enter Request: ')
    
            writer.write(request.encode())
            await writer.drain()
            print(f'Sent {request!r} to {addr!r}')
    
            response = await reader.read(1024) # read the final response
            print(f'Received {response.decode()!r} from {addr!r}')
            
            writer.close()
            await writer.wait_closed()
            print(f'Closed the connection with {addr!r}')
    
        async def start(self):
            reader, writer = await asyncio.open_connection(host=self.host, port=self.port)
    
            await self.handle_server(reader, writer)
    
    async def main():
        client = Client(socket.gethostname(), 5000)
        await client.start()
    
    if __name__ == '__main__':
        asyncio.run(main())