Skip to content

Possible generator entry race condition #156

@stanislaushimovolos

Description

@stanislaushimovolos

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

  1. Application logic may heavily depend on generator finally blocks always executing (releasing DB connections, cleaning up locks, etc.).

  2. However, the protection currently comes from Uvicorn's implementation detail (synchronous transport.write()), not from a guarantee in sse-starlette itself.

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions