starlette icon indicating copy to clipboard operation
starlette copied to clipboard

Handle websocket disconnect in event loop

Open benfasoli opened this issue 5 years ago • 10 comments

It seems that the starlette.websockets.WebSocketDisconnect exception is not raised when a client disconnects.

This can cause orphaned async tasks as multiple clients connect/disconnect. As someone first referenced on StackOverflow, it can also result in Uvicorn's shutdown/reload to block.

Example

#!/usr/bin/env python

import asyncio

from starlette.applications import Starlette
from starlette.responses import HTMLResponse

app = Starlette()


@app.route('/')
async def index(req):
    """Return basic websocket connection template at base route"""
    return HTMLResponse('''
        <!DOCTYPE html>
        <html>
            <head>
                <title>WebSocket Example</title>
            </head>
            <body>
                Intentionally empty. Messages echoed to js console.
                <script>
                ws = new WebSocket("ws://localhost:8000/ws");
                ws.onmessage = e => {
                    console.log(e);
                };
                </script>
            </body>
        </html>
        ''')


@app.websocket_route('/ws')
async def ws(websocket):
    await websocket.accept()

    try:
        while True:
            # Expected behavior: print "Looping..." until client
            # disconnects, then close websocket
            print(f'Looping. Client state: {websocket.client_state}')
            # await websocket.send_text('Hello world!')
            await asyncio.sleep(1)
    finally:
        await websocket.close()


if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, log_level='debug')

After accessing the base route to create a websocket connection, closing the window/connection does not raise starlette.websockets.WebSocketDisconnect, leaves the loop running indefinitely, and does not modify websocket.client_state. Additional connections start additional loops.

If you add the line to send the Hello world! message over the websocket after the client has disconnected, websockets.exceptions.ConnectionClosedOK will be raised and can be handled. However, this likely won't be a workaround for all use cases.

benfasoli avatar Dec 17 '19 23:12 benfasoli

you can subclass WebSocketEndpoint @benfasoli , here's an example of a dummy counter

@app.websocket_route("/counter")
class WebSocketTicks(WebSocketEndpoint):
    async def on_connect(self, websocket: WebSocket) -> None:
        await websocket.accept()
        self.ticker_task = asyncio.create_task(self.tick(websocket))
        logger.debug("connected")

    async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
        self.ticker_task.cancel()
        logger.debug("disconnected")

    async def on_receive(self, websocket: WebSocket, data: typing.Any) -> None:
        await websocket.send_json({"Message: ": data})

    async def tick(self, websocket: WebSocket) -> None:
        counter = 0
        while True:
            logger.debug(counter)
            await websocket.send_json({"counter": counter})
            counter += 1
            await asyncio.sleep(1)

euri10 avatar Dec 18 '19 08:12 euri10

@euri10 Thanks for the example.

Small issue: There's a solid 50-second delay between the actual (physical) disconnect and on_disconnect() being called. Any way to make sure on_disconnect() is called instantly?

I tested with the websockets library, and it has the same delay as well.

devxpy avatar Feb 13 '20 22:02 devxpy

Seems very odd to me, I don't see any reason why there would be a 50s delay

Le jeu. 13 févr. 2020 à 11:24 PM, Dev Aggarwal [email protected] a écrit :

@euri10 https://github.com/euri10 Thanks for the example.

Small issue: There's a solid 50-second delay between the actual (physical) disconnect and on_disconnect() being called. Any way to make sure on_disconnect() is called instantly?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/encode/starlette/issues/759?email_source=notifications&email_token=AAINSPT5PCHK4DL22ASX5XLRCXCDBA5CNFSM4J4C7EYKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOELW23YA#issuecomment-586001888, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAINSPTIMB6XB2VMFZEUSDDRCXCDBANCNFSM4J4C7EYA .

euri10 avatar Feb 13 '20 22:02 euri10

Here's an MWE -

Grab 2 computers, run the server on one and client on the other. Then just disconnect the wifi on the client.


# testserver.py
from time import time

from starlette.applications import Starlette
from starlette.endpoints import WebSocketEndpoint
from starlette.websockets import WebSocket

app = Starlette(debug=True)


@app.websocket_route("/")
class MessagesEndpoint(WebSocketEndpoint):
    async def on_connect(self, websocket):
        await websocket.accept()
        self.last_time = time()
        print(f"[{self.last_time}] connected: {websocket.client}")

    async def on_receive(self, websocket: WebSocket, data) -> None:
        self.last_time = time()
        print(f"[{self.last_time}] {data}")

    async def on_disconnect(self, websocket, close_code):
        print(f"[{time()}] disconnected: {websocket.client}")
        print("delay:", time() - self.last_time)
$ uvicorn testserver:app --host 0.0.0.0

# testclient.py
import sys
from time import sleep

import websocket

ws = websocket.WebSocket()
ws.connect("ws://" + sys.argv[1])

while True:
    ws.send("ping")
    sleep(0.5)
$ python testclient.py <server ip>:8000

Logs from my server -

$ uvicorn testserver:app --host 0.0.0.0
INFO:     Started server process [98786]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     ('192.168.31.50', 55556) - "WebSocket /" [accepted]
[1581665609.820539] connected: Address(host='192.168.31.50', port=55556)
[1581665609.824685] ping
[1581665610.420501] ping
[1581665610.929983] ping
[1581665611.446839] ping
[1581665611.830563] ping
[1581665612.363434] ping
[1581665612.975849] ping
[1581665613.387729] ping
[1581665613.900246] ping
[1581665614.4310899] ping
[1581665614.925177] ping
[1581665615.3376231] ping
[1581665615.9478881] ping
[1581665616.344498] ping
[1581665616.8673742] ping
[1581665659.828591] disconnected: Address(host='192.168.31.50', port=55556)
delay: 42.96127676963806

I was testing earlier with a flutter app, and the delay seems to be higher on that.

The delay does not exist on localhost, or if the client is gracefully shutdown with ctrl+c.

devxpy avatar Feb 14 '20 07:02 devxpy

Then just disconnect the wifi on the client.

I'm not sure but is it the best way to disconnect the client ?

just in case I took your example, adapted it a bit (I didn't know what websocket lib it is in the client, so I rewrote a dummy client using websockets < note the s :dragon: ) and with it I seems to be ok after I hit ctrl+c on the client, which to me looks like a disconnection but maybe that's not what you have in mind.

testserver.py

# testserver.py
from time import time

import uvicorn
from starlette.applications import Starlette
from starlette.endpoints import WebSocketEndpoint
from starlette.websockets import WebSocket

app = Starlette(debug=True)


@app.websocket_route("/")
class MessagesEndpoint(WebSocketEndpoint):
    encoding = "text"
    last_time = 0

    async def on_connect(self, websocket):
        await websocket.accept()
        print(f"[{time()}] connected: {websocket.client}")

    async def on_receive(self, websocket: WebSocket, data) -> None:
        self.last_time = float(data)
        print(self.last_time)

    async def on_disconnect(self, websocket, close_code):
        print(f"[{time()}] disconnected: {websocket.client}")
        print("delay:", time() - self.last_time)


if __name__ == '__main__':
    uvicorn.run(app)

client

import asyncio
from time import time

import websockets

async def hello():
    uri = "ws://127.0.0.1:8000"
    async with websockets.connect(uri) as ws:
        while True:
            t = str(time())
            await ws.send(t)
            print(t)
            await asyncio.sleep(0.5)

if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(hello())

output:

INFO:     Started server process [42018]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
[1581666736.198393] connected: Address(host='127.0.0.1', port=59714)
1581666736.1990578
INFO:     ('127.0.0.1', 59714) - "WebSocket /" [accepted]
1581666736.6998987
1581666737.2009335
1581666737.701966
[1581666737.944619] disconnected: Address(host='127.0.0.1', port=59714)
delay: 0.24270367622375488
INFO:     ('127.0.0.1', 59760) - "WebSocket /" [accepted]
[1581666741.0054376] connected: Address(host='127.0.0.1', port=59760)
1581666741.0062165
1581666741.5072074
1581666742.008333
1581666742.509547
1581666743.0107918
[1581666743.2451403] disconnected: Address(host='127.0.0.1', port=59760)
delay: 0.23444056510925293
INFO:     ('127.0.0.1', 59794) - "WebSocket /" [accepted]
[1581666744.2906296] connected: Address(host='127.0.0.1', port=59794)
1581666744.2912686
1581666744.792231
1581666745.2934177
[1581666745.720352] disconnected: Address(host='127.0.0.1', port=59794)
delay: 0.4269826412200928

euri10 avatar Feb 14 '20 07:02 euri10

Sorry, here's the library - websocket_client

$ pip install websocket_client

ctrl+c is not really representative of a real-world client IMO, because mobile clients may have bad internet connection, roam from one connection to other, or just plain disconnect WiFi anytime they want.

If there's no reliable way to tell if a client is connected or not, then even halfway-reliable message delivery can't be guaranteed :( (without a performance penalty, at least)

devxpy avatar Feb 14 '20 08:02 devxpy

understood , the 40 to 50s you see is exactly what is described here: https://github.com/aaugustin/websockets/blob/3bab7fd155636c73b79b258de752b36687bba347/src/websockets/protocol.py#L803-L810

close_timeout default to 10s so maybe setting it to a lower value might help

euri10 avatar Feb 14 '20 08:02 euri10

It would be in uvicorn, not sure there's a way to override the timeout, @tomchristie might know

euri10 avatar Feb 14 '20 09:02 euri10

Following https://github.com/encode/uvicorn/issues/68, by using daphne, I was able to get the delay under 5 sec.

$ daphne -b 0.0.0.0 --ping-interval 1 --ping-timeout 1 testserver:app
2020-02-14 15:22:08,873 INFO     Starting server at tcp:port=8000:interface=0.0.0.0
2020-02-14 15:22:08,873 INFO     HTTP/2 support enabled
2020-02-14 15:22:08,873 INFO     Configuring endpoint tcp:port=8000:interface=0.0.0.0
2020-02-14 15:22:08,874 INFO     Listening on TCP address 0.0.0.0:8000
192.168.31.180:41250 - - [14/Feb/2020:15:22:33] "WSCONNECTING /" - -
192.168.31.180:41250 - - [14/Feb/2020:15:22:33] "WSCONNECT /" - -
[1581673953.148614] connected: Address(host='192.168.31.180', port=41250)
[1581673953.212526] ping
[1581673953.733991] ping
[1581673954.274146] ping
[1581673954.749561] ping
[1581673955.295857] ping
[1581673955.8084538] ping
[1581673956.322674] ping
[1581673956.8348138] ping
192.168.31.180:41250 - - [14/Feb/2020:15:22:39] "WSDISCONNECT /" - -
[1581673959.3946931] disconnected: Address(host='192.168.31.180', port=41250)
delay: 2.5599260330200195

Thank you for your time and attention BTW :)

devxpy avatar Feb 14 '20 09:02 devxpy

Is there something we can do here in Starlette tho?

Kludex avatar Aug 15 '22 06:08 Kludex

Thanks all for the discussion 🙏

Going to close this, since it seems out of scope for starlette to implement the requisite ping/pong methods to detect a websocket disconnect.

benfasoli avatar Nov 18 '22 23:11 benfasoli

Hi all that how have the same problem with this code disconnect ... 1006 It helps me enourmous, anytime i have this problem and can´t work with ControlNet, now it works. Big Thankx to this community

Duane1756 avatar Jul 07 '23 16:07 Duane1756