-
Notifications
You must be signed in to change notification settings - Fork 58
Description
Summary
There's a theoretical race condition where if a client disconnects before _stream_response begins iterating body_iterator, the generator's finally block never executes. The race is prevented by Uvicorn's implementation details but may be exploitable with custom middleware or alternative ASGI servers.
The Race
In EventSourceResponse.__call__(), tasks are scheduled concurrently:
async with anyio.create_task_group() as task_group:
task_group.start_soon(cancel_on_finish, lambda: self._stream_response(send))
# ...
task_group.start_soon(cancel_on_finish, lambda: self._listen_for_disconnect(receive))If _listen_for_disconnect cancels the scope before _stream_response calls async for data in self.body_iterator, the generator's __anext__() is never invoked. Python async generators don't enter their body until __anext__() is called, so no try/finally blocks are registered.
Why It Doesn't Happen with Standard Uvicorn
The first operation in _stream_response is:
await send({"type": "http.response.start", ...})In Uvicorn, this resolves to a synchronous transport.write() call (no event loop yield). The await drain() only executes if buffer exceeds 64KB, which never happens for headers.
Result: No context switch occurs between await send() and async for, so the disconnect listener can't cancel before iteration begins.
Reproduction with Middleware
class SlowSendMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def slow_send(message):
if message["type"] == "http.response.start":
await asyncio.sleep(0.3) # ← Disconnect happens here!
await send(message)
await self.app(scope, receive, slow_send)
app = SlowSendMiddleware(Starlette(routes=[Route("/sse", sse_endpoint)]))Practical Consideration
-
Application logic may heavily depend on generator
finallyblocks always executing (releasing DB connections, cleaning up locks, etc.). -
However, the protection currently comes from Uvicorn's implementation detail (synchronous
transport.write()), not from a guarantee in sse-starlette itself. -
That said, this is admittedly a strange edge case that may not be worth handling—I'm documenting it for awareness rather than demanding a fix. Happy to hear your thoughts.
Environment
- Python 3.13
- sse-starlette 3.1.2
- uvicorn 0.40.0
- starlette 0.50.0