SIGN IN SIGN UP

fix: stop ThreadSensitiveContext.__aexit__ from blocking daphne's event loop

Django 6.0 wraps every ASGI request in `async with ThreadSensitiveContext():`
(django/core/handlers/asgi.py:169). On exit, asgiref calls
``executor.shutdown()`` with the default ``wait=True`` (asgiref/sync.py:148),
which is a synchronous ``Thread.join()`` inside an async function — so it
blocks the daphne event loop until the executor's worker thread exits.

That's normally fine: the request handler has already awaited every
``sync_to_async`` it submitted, the worker is idle, and the shutdown
sentinel makes it exit immediately. The blocking becomes catastrophic
when a client disconnects mid-request:

  * ``SyncToAsync.__call__`` shields the executor work via
    ``await asyncio.shield(exec_coro)`` (asgiref/sync.py:506) so the
    sync DB call doesn't get torn down halfway.
  * On cancellation it calls ``exec_coro.cancel()`` (line 522), which
    only flips the asyncio Future to cancelled — the underlying thread
    keeps running the SQL query.
  * Control unwinds back to ``__aexit__`` while the orphaned thread is
    still mid-query. ``shutdown(wait=True)`` blocks the event loop
    until that orphan finishes.

Under heavy SQLite write contention (the cabbage load-test scenario:
23K snapshots, 100K+ archive_results, hundreds of inflight extractor
writes) orphan queries routinely take 30s+ waiting for locks. Daphne is
single-threaded, so every orphan stalls every concurrent request. After
a few rounds of Cloudflare-side disconnects, the loop is hung,
healthchecks time out, the container goes ``unhealthy``, and the demo
goes down. py-spy on the stuck process showed every worker idle and the
main thread parked in ``concurrent/futures/thread.py:join`` from
``ThreadSensitiveContext.__aexit__``.

Switching to ``shutdown(wait=False)`` queues the sentinel and returns
immediately; the worker still exits cleanly once its current task
finishes, and asgiref's ``WeakKeyDictionary`` releases the executor as
soon as the request context is GC'd. There was no caller relying on the
per-request "thread is dead before next request starts" guarantee — the
ThreadPoolExecutor only ever has max_workers=1 and is per-context.

Patched in archivebox/core/asgi.py at module import (before
``get_asgi_application()``) so daphne picks it up on first boot.
Idempotent and safe to re-import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
N
Nick Sweeting committed
aa896d514c99a8b2e91c401acfe73548291dace4
Parent: 66259c9