Skip to content

Commit 3afdae3

Browse files
committed
Enable asyncio debug mode in development, document async view safety
In DEBUG mode, enable loop.set_debug(True) with a 100ms slow callback threshold. This surfaces blocking calls in async views (SSE, future WebSocket handlers) without false positives — sync views run in the thread pool and don't trigger the warning. Update _finish_pipeline docstring to document thread affinity behavior for async vs sync views. Add async view safety section to views README and request_finished timing note to signals README.
1 parent 8457914 commit 3afdae3

File tree

4 files changed

+42
-7
lines changed

4 files changed

+42
-7
lines changed

plain/plain/internal/handlers/base.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,19 @@ def _finish_pipeline(
239239
) -> ResponseBase:
240240
"""Run after-middleware and send request_finished signal.
241241
242-
request_finished is sent here (on the same thread as request_started)
243-
rather than from response.close(), which runs after the response body
244-
is written and may land on a different thread. This means the signal
245-
fires before streaming responses are iterated — handlers like
246-
close_old_connections should not affect in-progress streams since
247-
request_started on the next request also handles stale connections.
242+
For sync views, this runs on the same thread as request_started
243+
(part of the single _run_sync_pipeline call).
244+
245+
For async views, this runs in a separate executor call and may
246+
land on a different thread than request_started. Thread-local
247+
state from request_started is not guaranteed to be available.
248+
In practice this is safe because close_old_connections (the main
249+
signal handler) is idempotent per-thread.
250+
251+
The signal fires before streaming response bodies are transmitted.
252+
Handlers like close_old_connections should not affect in-progress
253+
streams since request_started on the next request also handles
254+
stale connections.
248255
"""
249256
response = self._run_after_response(request, response, ran_before)
250257
signals.request_finished.send(sender=self.__class__)

plain/plain/server/workers/worker.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ def changed(fname: str) -> None:
144144
async def run(self) -> None:
145145
loop = asyncio.get_running_loop()
146146

147+
# Enable asyncio debug mode in development to detect blocking calls
148+
# in async views. Logs a warning when a callback takes > 0.1s.
149+
from plain.runtime import settings
150+
151+
if settings.DEBUG:
152+
loop.set_debug(True)
153+
loop.slow_callback_duration = 0.1
154+
147155
# Signal handlers
148156
loop.add_signal_handler(signal.SIGTERM, self._signal_exit)
149157
loop.add_signal_handler(signal.SIGINT, self._signal_quit)

plain/plain/signals/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ request_finished.connect(on_request_finished)
3232
Plain provides two built-in signals:
3333

3434
- `request_started` - sent when a request begins processing
35-
- `request_finished` - sent when a request finishes processing
35+
- `request_finished` - sent when a request finishes processing (after middleware completes, before the response body is transmitted)
36+
37+
Note: for streaming responses (SSE, large downloads), `request_finished` fires while data is still being sent. Use `response.close()` or `_resource_closers` if you need a hook that runs after transmission completes.
3638

3739
Your receiver function must accept `**kwargs` because signals may pass additional arguments in the future.
3840

plain/plain/views/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,24 @@ class AppRouter(Router):
301301

302302
You can also redirect to a named URL using `url_name`, or preserve query parameters with `preserve_query_params=True`.
303303

304+
## Async views
305+
306+
Any view method defined with `async def` runs directly on the worker's event loop. This enables non-blocking I/O patterns like SSE, WebSockets, and async HTTP clients.
307+
308+
**Important:** Blocking calls in async views freeze the entire worker process — no other requests can be processed until the blocking call returns. Plain's ORM, sessions, and auth layers are all synchronous and must not be called directly from async views.
309+
310+
Common mistakes:
311+
312+
- `User.query.get(pk=1)` — blocks the event loop
313+
- `time.sleep(1)` — use `await asyncio.sleep(1)` instead
314+
- `requests.get(...)` — use an async HTTP client instead
315+
316+
To wrap a blocking call safely: `await asyncio.get_running_loop().run_in_executor(None, blocking_fn)`
317+
318+
Use async views only for true async I/O (SSE, async HTTP clients). For standard request/response views that use the ORM, use regular sync views — they run in the thread pool and don't block other connections.
319+
320+
In development (`DEBUG=True`), the server enables asyncio debug mode which logs warnings when a callback blocks the event loop for more than 100ms.
321+
304322
## ServerSentEventsView
305323

306324
[`ServerSentEventsView`](./sse.py#ServerSentEventsView) provides [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) streaming. Subclass it and implement `stream()` as an async generator.

0 commit comments

Comments
 (0)