unit icon indicating copy to clipboard operation
unit copied to clipboard

Django 5.1, ASGI, SSE

Open mamashin opened this issue 1 year ago • 6 comments

Hello.

Sorry, I have a question about Django and ASGI. I saw that a similar topic has already been discussed, but I couldn't find a solution for myself.

Unit 1.33 Django 5.1 (support async handling disconnects) Python 3.11.2 Debian 12

Test Django code:

def sse(request: HttpRequest) -> HttpResponseBase:
    """Small demo of the basic idea of SSE without any redis or other complexity"""
    async def stream(request: HttpRequest) -> AsyncGenerator[str, None]:
        try:
            counter = 0
            while True:
                counter += 1
                if counter != 1:
                    await asyncio.sleep(1.0)
                yield f"data: <div>{counter}</div>\n\n"
        except asyncio.CancelledError:
            logger.debug("SSE Client disconnected")
            raise

    return StreamingHttpResponse(
        streaming_content=stream(request),
        content_type="text/event-stream"
    )

When I run this code under the Uvicorn ASGI server, after closing the connection, I see "SSE Client disconnected" in my dubug log. However, under Unit, nothing happens, with only in unit log:

2024/12/01 11:29:31 [info] 40085#40121 *11 writev(44, 2) failed (32: Broken pipe)

It seems that Django does not detect that the connection is closed, and this is only apparent when I restart Unit. In the logs:

2024/12/01 11:02:55 [warn] 34253#34253 [unit] #516: active request on ctx quit
2024/12/01 11:02:55 [warn] 34253#34253 [unit] #404: active request on ctx free

In a production environment when I use Redis, I see many connections with Unit and Redis in the ESTABLISHED state, even though many connections are already closed. This is a problem for me.

What am I doing wrong 🤔 ?

mamashin avatar Dec 01 '24 09:12 mamashin

small addon.

this is my debug ASGI entry point:

import os
from loguru import logger
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'conf.settings')
django_application = get_asgi_application()

async def application(scope, receive, send):
    if scope['type'] == 'http':
        # Let Django handle only ASGI HTTP requests
        async def receive_wrapper():
            message = await receive()
            logger.debug(f'ASGI application receive message: {message}')
            await logger.complete()
            return message
        await django_application(scope, receive_wrapper, send)
    else:
        pass

When I connected, I see:

ASGI application receive message: {'type': 'http.request'}

But when I disconnected - log is empty.

As I said, if I run this test under Uvicorn, I see:

ASGI application receive message: {'type': 'http.disconnect'}

Where I lost 'http.disconnect' while working with Unit ?

mamashin avatar Dec 01 '24 20:12 mamashin

@ac000 I have opened a draft pr for this issue. Not sure, it is right approach or not but have tried to come up with a possible solution. Could you please review it once. Have added more details as description and commit msg https://github.com/nginx/unit/pull/1556/files

gourav-kandoria avatar Feb 04 '25 18:02 gourav-kandoria

@mamashin

~~Do you have a simple purely ASGI reproducer (no frameworks involved) and steps to reproduce?~~

Nevermind, I have one from @gourav-kandoria

ac000 avatar Feb 07 '25 18:02 ac000

I.e. something that simply shows the Python app is not getting notified when clients close the socket?

ac000 avatar Feb 07 '25 18:02 ac000

Hmm, I'm seeing something slightly different...

With

async def application(scope, receive, send):                                    
    while True:                                                                 
        m = await receive()                                                     
        if m['type'] == 'http.disconnect':                                      
                print("Client Disconnect")                                      
                break                                                           
                                                                                
        await send(                                                             
            {                                                                   
                "type": "http.response.start",                                                                  
                "status": 200,                                                  
                "headers": [[b"content-type", b"text/plain"]],                  
            }                                                                   
        )                                                                       
        await send({"type": "http.response.body", "body": b"Hello, world!"})

I get the "Client Disconnect" message after every request, even though Firefox is using keep-alive.

Indeed, unlike with WebSockets where Firefox will close the connection if you close the browser tab, Firefox seems to keep the socket open even if you close the tab, if you restore the tab, it continues using the same socket.

Not sure why we're sending a http.disconnect after request though...

ac000 avatar Feb 08 '25 02:02 ac000

Seems to be something peculiar to the way Server-Sent Events are handled...

ac000 avatar Feb 08 '25 17:02 ac000