Turn Middleware
LIVEVIEW_MIDDLEWARE: one async chain around every LiveView turn — mount, events, URL changes, pushes, ticks, background results and reconnects — on every transport
On this page
Django middleware runs once, for the first HTTP page load. After that a LiveView keeps running view code over the socket: events, URL changes, server pushes, ticks, background results, reconnects. Turn middleware wraps all of it.
LIVEVIEW_MIDDLEWARE lists async def mw(turn, next) callables. Every turn, on
every transport (WebSocket, SSE, the HTTP fallback and the auto-exposed HTTP API),
runs them in order, like Django's MIDDLEWARE. The design is
ADR-044.
Use it for anything that must hold for every turn: who is acting (for owner-scoped managers or Postgres row-level security), tracing spans, audit context, per-caller rate limits.
Plain Django middleware gives a false sense of coverage: it sets a context variable for the page load only. Every later event, push and background result runs without it.
Quick start
# settings.py
LIVEVIEW_MIDDLEWARE = ["myapp.turns.caller_context"]
# myapp/turns.py
import contextvars
from djust.turns import Refuse
current_caller = contextvars.ContextVar("current_caller", default=None)
async def caller_context(turn, next):
caller = await resolve_caller(turn.user, turn.session) # your code; cache it
if caller is None:
return Refuse.login()
token = current_caller.set(caller)
try:
return await next(turn)
finally:
current_caller.reset(token)
A middleware returns await next(turn) to run the rest of the turn, or a
refusal to stop it. Middleware must be async def; a sync entry fails system
check C026.
The turn object
| Attribute | Meaning |
|---|---|
turn.kind | "mount", "reconnect", "event", "params", "push", "async" or "render" |
turn.source | finer detail, see the table below |
turn.transport | "ws", "sse", "http" or "api" |
turn.view_class | the LiveView class |
turn.view | the view instance; None during a mount until djust creates it |
turn.event | the handler name (event) or task name (async) |
turn.params | read-only mapping of the frame's params |
turn.request | the HTTP or SSE request, when the transport has one |
turn.scope | the ASGI scope (WebSocket) |
turn.user, turn.session | from the request, else from the scope |
turn.state | a dict to pass data to inner middleware |
turn.started | time.perf_counter() when the turn started |
djust.turns.current_turn() returns the running turn from anywhere inside it.
Every turn kind
kind | source | When |
|---|---|---|
mount | page, slot, batch, navigation, lazy | a view is mounted: the page GET, a socket mount, a lazy view beside the page, mount_batch, a live_redirect |
reconnect | as for mount | a mount after the socket reconnected (a client hint; treat it exactly like mount) |
event | client, deferred, api, server_function | a client event, a deferred dj_activity replay, an HTTP API or @server_function call |
params | url_change | handle_params after a live_patch or back/forward |
push | server_push, db_notify, tick, presence, client_push | the server delivers data or runs a hook without a client event |
async | start, result, catch_up | start_async / @background work starts, and its result is handled |
render | recovery | the client asks for full HTML after a failed patch |
Server push, ticks, db_notify and presence exist on the WebSocket only (SSE
joins no channel groups); background work runs on WebSocket and SSE; HTTP and
the API have page mounts and events.
Order
error_boundary (built-in, always)
reauth (built-in, when LIVEVIEW_CONFIG["reauth_on_event"] is on)
LIVEVIEW_MIDDLEWARE[0] ... [n]
the turn: login_required / permission_required, on_mount hooks,
object permissions, mount() / handler / hook, render, send
- Built-ins are outermost and cannot be removed.
LIVEVIEW_MIDDLEWARElists only your middleware, so setting it never dropsreauth_on_event. - Your middleware wraps djust's view-level auth, as Django middleware wraps a view and its decorators. It also sees anonymous users and turns djust is about to refuse.
- There is no per-view opt-out. A middleware that should skip some views
checks
turn.view_classitself.
Refusing a turn
Return a refusal instead of calling next, or raise
django.core.exceptions.PermissionDenied (same as Refuse.deny()). The rest
of the chain and the turn do not run.
| Refusal | Socket (WebSocket / SSE) | HTTP page GET | HTTP POST / API |
|---|---|---|---|
Refuse.deny(message=None) | permission_denied error frame (with the event ref); a refused mount closes 4403 | the project's 403 handler | 403 JSON |
Refuse.login(next_url=None) | navigate to LOGIN_URL, close 4403 | redirect to LOGIN_URL?next=... | 403 {"redirect": ...} (API: 401) |
Refuse.redirect(url) | navigate frame | redirect | 403 {"redirect": ...} |
Refuse.flash(message, level="error") | flash frame; the socket stays open | 403 | 403 JSON |
Refuse.rate_limited() | rate_limit_exceeded frame | 429 | 429 |
Redirect targets must stay on your site; anything else falls back to /. A
refused server turn (push, async) is dropped: nothing is sent unless the
refusal navigates or flashes. Pass close=True / close=False to override
whether a socket turn closes the connection.
Context reaches background work
A context variable set before await next(turn) is visible to the handler, to
sync code djust runs in a thread, and to every task the turn starts:
start_async, @background, assign_async, the tick loop a mount starts.
@event_handler()
def generate(self, **kwargs):
self.start_async(self._generate) # current_caller is still set in there
def _generate(self):
return Report.objects.for_caller().build() # owner-scoped, no caller argument
A task a view starts with self.create_task() inherits the context the same
way; it renders nothing, so it is not a turn of its own (a push it sends runs
the chain on the receiving socket).
Background work also runs the chain itself: once before the callback starts
(async/start) and again before its result is handled and rendered
(async/result), so a caller who signed out in between is refused.
A transaction per sync step: sync_turn_wrapper
A middleware wraps the whole turn, which is async: the turn runs the view's
sync code in separate hops (the handler, mount, handle_params, a
start_async callback and its handle_async_result, handle_tick, the
render's get_context_data(), where the JIT evaluates the view's QuerySets,
the authorization checks, the on_mount hooks, connected() and, off the event
loop, a released view's teardown hooks) with awaits between them. With
worker_threads a pool thread runs other sessions' hops in those gaps, and
Django's database connection belongs to the thread. So a transaction.atomic()
opened in a middleware would span awaits and take in other sessions' queries.
LIVEVIEW_CONFIG["sync_turn_wrapper"] is the place for thread-bound state such
as a transaction. It names a callable wrapper(view, step, turn) returning a
context manager; djust enters it on the thread that runs the step, around
the step, so what it opens closes inside one sync call:
# settings.py
LIVEVIEW_CONFIG = {"sync_turn_wrapper": "myapp.db.caller_scope"}
# myapp/db.py
from contextlib import contextmanager, nullcontext
from django.db import connection, transaction
def caller_scope(view, step, turn):
if turn is not None and turn.transport == "http":
return nullcontext() # the request's own transaction holds
user = turn.user if turn is not None else getattr(view.request, "user", None)
return _as_caller(getattr(user, "pk", None))
@contextmanager
def _as_caller(user_id):
with transaction.atomic(), connection.cursor() as cursor:
cursor.execute("SELECT set_config('app.user_id', %s, true)", [str(user_id or "")])
yield
-
stepis one of:stepRuns "event"an event handler; also a time-travel replay of one "mount","handle_params"mount(),handle_params(). A view that setsparams_formenters"handle_params"twice for a URL: once to validate it (the form's validators and querysets run there), once forhandle_params()orhandle_invalid_params()"hook"another sync view hook: a push handler (in the stock turn and in the worker-pool one-hop turn), handle_info,handle_cursor_move(),update_presence_heartbeat(), and theget_presence_key()/untrack_presence()reads"async"a start_async/@backgroundcallback and thehandle_async_resultcall that receives its outcome (two separate entries)"tick"handle_tick()"render"every call djust makes to get_context_data(), on every transport"auth"an authorization decision that runs your code: check_permissions(), Django's access mixins and replayeddispatchdecorators (test_func),@permission_requiredon a handler, and the object-permission check (get_object()+has_object_permission())"lifecycle"the on_mounthooks (the whole chain is one entry),connected()anddisconnected(), the snapshot overrides (_should_restore_snapshot(),_restore_snapshot(),_capture_snapshot_state(), including the per-event signed-snapshot refresh), the restore of private state (_restore_private_state(), which re-fetches private model references; a guarded step, so a wrapper that swallows a failure cannot leave a half-restored view), and a released view's teardown hooks only when they run on a worker thread; on the event loop they run with no step (see Not wrapped) -
viewis the view or component whose code runs;turnisdjust.turns.current_turn(), orNonewhen the step runs outside a turn (astart_asynccallback runs between its start and result turns). -
A step that runs inside another step on the same thread (a handler that calls
get_context_data()) does not enter the wrapper again. -
Accept every step name. The list of steps grows (
"auth"and"lifecycle"are new with #3637). A wrapper that ends inelse: raise ValueError(step)fails every mount and event after an upgrade. Let a name you do not know pass through unchanged (example below). -
An
"auth"step fails closed, when it is the outermost step. If the wrapper raises (on entry or on exit, for example a failed commit) the check is refused, and the object-permission cache (view._object) is only written after the step exits cleanly. If the wrapper's__exit__swallows an exception that the check or anon_mounthook raised, djust raisesPermissionDeniedinstead of treating the missing verdict as "allowed". The same guard covers theon_mountchain, the_restore_snapshotoverride and_restore_private_state, because their outcome is a verdict too (a swallowed failure in the last would otherwise leave a silently half-restored view). The guard belongs to the step djust enters: a check that runs inside another step on the same thread (an"auth"check called from amount()that is itself being wrapped) does not enter the wrapper again, so a wrapper that swallows exceptions on the outer"mount"/"event"step is not guarded here. Do not suppress exceptions in a wrapper. -
Entries per request. A request enters the wrapper several times on every transport. These sequences were measured with a counting wrapper against a view that overrides
handle_params()andget_object()(the testtest_the_guides_entries_per_request_*pins them, so this list cannot drift):Request Entries, in order HTTP page GET "auth"(view-level),"lifecycle"(theon_mountchain, only when the view has one),"mount","handle_params","auth"(object check, whenget_object()is overridden),"render"(one step: the GET's ownget_context_data(); the render that follows reads that context instead of entering a second step, #3725)HTTP POST event, no saved state "auth","mount","handle_params","auth","event","render","render"(a legacy view evaluates once to save the state and once to render, #3734)HTTP POST event, saved state restored "auth","lifecycle"(the private-state restore),"auth","hook"(handle_restore()),"event","render","render"Each is one entry on the thread that runs it. Keep the wrapper cheap.
-
disconnected()runs when a connection ends, outside any turn: the wrapper getsturn=None. It runs the hook with the tenant bound and a failing wrapper is logged, never raised, like a failing hook. -
The wrapper is entered on every transport for the
"auth"steps andon_mounthooks (the HTTP page GET and API included), as it already is for"render"; a wrapper that opens a transaction forturn.transport == "http"should returnnullcontext()as in the example above. -
An
async defhandler or hook runs on the event loop and is not wrapped; its ownsync_to_asynccalls are yours to scope. Async_safeview renders on the loop, so its wrapper runs there too. -
LiveViewTestCliententers the same steps for sync code, so a test of a handler that reads rows runs inside the same scope as the socket:send_event()("event"),send_push()andtrigger_info()("hook"), and the callbacks andhandle_async_resultcalls thatrender_async()drains ("async");LiveComponentTestClient.send_event()is an"event"step too. Anasync defhook is run to completion unwrapped, as on the socket (inside a running event loop, on a helper thread). The one gap:send_event()with anasync defhandler still only calls it and drops the coroutine, so that test does not run the handler at all. The client does not tick, sohandle_tickis called by your own test. -
A
handle_async_result(or, on the default tick,handle_tick) written asasync defis refused withTypeError: sync_to_async can only be applied to sync functions, with or without a wrapper: both are called off the loop. -
One entry per hop, not per task: a
start_asynccallback and thehandle_async_resultthat receives its outcome are two entries. If the wrapper's own exit raises afterhandle_async_resultsucceeded, the failure is reported as the task's error andhandle_async_resultis called a second time witherror=. Keep the wrapper's exit from raising (let the transaction's commit failure surface in the handler instead), or make the hook safe to run twice. -
Unset (the default), nothing is wrapped and nothing is paid.
A wrapper that accepts every step name, today's and any added later:
from contextlib import nullcontext
KNOWN = {"event", "mount", "handle_params", "hook", "async", "tick", "render", "auth", "lifecycle"}
def caller_scope(view, step, turn):
if step not in KNOWN:
return nullcontext() # a step added by a later djust
return open_scope(view, turn) # your scope, as before
Not wrapped
Everything above is the sync code djust runs on a worker thread through
sync_to_async. What still runs without the wrapper, and why:
-
An
async defhook of any kind (mount, a handler,check_permissions,handle_tick, a push hook,handle_cursor_move,get_presence_key): it runs on the event loop, so a thread-bound scope cannot be open in it. Its ownsync_to_asynccalls are yours to scope. djust refuses anasync defwhere only a sync hook is allowed (TypeError), with or without a wrapper. -
Tenant resolution (
TenantMixin_ensure_tenant, run beside the auth check): it decides which tenant the turn is for, the input of the scope, so it cannot run inside it. -
Teardown hooks on the event loop.
_on_sticky_unmount(),_cleanup_on_unregister()and_cleanup_uploads()run when a view or child is released. Which thread runs them depends on the transport:Release Thread Step WebSocket disconnect, a second mountframe, the teardown of alive_redirectthe event loop none SSE: the stream's own close, a rate-limit disconnect the event loop none SSE navigation, the release of a view mounted beside the page, and response.close()of a stream that never starteda worker thread "lifecycle"WebSocket live_redirectdiscarding a sticky child whose auth now failsa worker thread "lifecycle"On a worker thread each hook is its own
"lifecycle"entry (viewis the object whose hook runs,turnisNone, as fordisconnected()). A wrapper that raises on entry does not skip the hook: the failure is logged and the hook runs without the scope; a wrapper that raises on exit after the hook ran is logged, not raised, because the view is already released. On the event loop the hooks are not wrapped: a wrapper that opens a transaction would itself raise there, and a hook that queries on the loop fails with or without a wrapper. Do queries indisconnected()instead, which always runs on a worker thread as a"lifecycle"step. -
Hooks that run inside a hop that is already a step. These were run, with a recording wrapper, and the step that was open when the hook body ran is the one listed (
test_sync_turn_inside_audit_3646.py):handle_component_event(),LiveComponent.update()and a component's_handle_event()run under the"event"step of the handler that reaches them;LiveComponent.mount()under the"mount"step of the view that creates the component;get_render_only_context()under the"render"step. TheDataTableMixin.handle_*overrides run under"event", and the form hooksget_form_class(),get_form_kwargs()and_create_form()under"mount"and"event".serialize_for_template()is an app helper the framework never calls, so it runs wherever your code calls it.get_http_status()runs in the HTTP response phase. The other component and tag hooks are not here: see template-time hooks. -
State restores that assign with
setattr. The time-travel jump (restore_snapshot,restore_component_snapshot) and a component restore (_restore_component_state) assign saved fields withsafe_setattr/setattr, so a property setter you wrote would run there without the scope. They call no hook by name and read no rows. (_restore_private_stateis different: it re-fetches private model references, so it is a"lifecycle"step.) -
Identity resolution before the scope exists.
establish_mount_sessionandfresh_socket_requestcall Django'sget_user(), which runs your authentication backend'sget_user()to find the caller; the scope is built from that caller, so it cannot be open yet. -
Preserving sticky children across a
live_redirect(_preserve_sticky_children) runs on a worker thread and calls two things that are covered, not unwrapped: the auth re-check goes through the"auth"leaves, and discarding a child whose auth now fails runs its teardown hooks as"lifecycle"steps (see above). -
The render plumbing around
get_context_data()(template build, Rust state sync, HTML post-processing). Only theget_context_data()calls inside it are"render"steps. -
get_template()inside the render (while the template is built for a view render) runs in the framework's render plumbing with no step open; an HTTP GET shows none. Only the two direct calls (the actor mount, a hot reload) are"render"steps. -
A task you start yourself with
create_task(): it inherits the context variables of the turn but is not a sync step. Handlers run inside an"event"step, so what they call directly is covered; a task they start is not.
Not wrapped: template-time form and tag hooks
The "render" step covers the call to get_context_data(). The template engine
runs after it returns, and nothing around the template engine is a step. Any
hook that a template tag, a filter or a component's {{ component }} calls therefore
runs with no step open and no wrapper scope, on every transport. Checked by
running each one with a recording wrapper (test_sync_turn_inside_audit_3646.py):
| Hook | Called by | Step open |
|---|---|---|
FormMixin.as_live() / as_live_field() | {% live_form %} / {% live_field %} | none |
get_field_value() (including AdminFormMixin) | the field_value filter | none (it also runs under "render" when an admin view's own get_context_data() calls it) |
_ensure_form_state() | the same tags and filters | none (under "event" when submit_form() calls it) |
form adapters' render_field() and _merge_widget_attrs() | as_live_field() | none |
Component.get_context_data() and _render_custom() | {{ component }} | none |
_on_sticky_update() | {% live_render ... sticky=True %} while the parent renders | none |
A row read in one of these runs without the wrapper scope. The clearest case
is a ModelChoiceField: {% live_field view "group" %} evaluates the field's
queryset while it renders the <select>, and a test shows that read happens with no
step open. Under row-level security that read has no per-turn scope, whatever your
wrapper does for get_context_data(). Read the rows in mount(), a handler or
get_context_data() (all steps) and hand the template the result, rather than
letting a tag or a field evaluate a queryset.
The snapshot-restore overrides are covered: _should_restore_snapshot(),
_restore_snapshot() and _capture_snapshot_state() are "lifecycle" steps (the
per-event refresh of the signed snapshot as well as the mount), and
handle_restore() is a "hook" step. A {% live_render lazy=True %} child's
mount() and get_context_data() are "mount" and "render" steps when its slot
is filled. PresenceMixin entering its broadcast from
async code (track_presence() under a running loop) is a "hook" step as well.
Hot path
Turn middleware runs on every event, tick and push. Keep it cheap:
- Cache anything that does I/O (a backend session check, a permission lookup). A ticking view runs the chain every tick.
- A server turn may run while the session's render lock is held; a slow middleware delays that session's other turns.
- The chain itself is cheap: about 2 µs per turn with no
LIVEVIEW_MIDDLEWARE(the built-ins plus timing), and about 0.5 µs more per pass-through middleware (measured on an Apple-silicon laptop, Python 3.12). HTTP and API requests skip the chain entirely whenLIVEVIEW_MIDDLEWAREis empty; with it set, they pay one event-loop hop per request.
Per-turn timings (the whole chain plus the turn) are recorded alongside handler
timings. In development, read them from
/_djust/observability/turn_timings/ (?kind=push filters), or use
turn.started for your own spans.
Built-in behaviour on the chain
reauth_on_eventruns as thereauthbuilt-in for legacy-exposure views withlogin_required/permission_required: client turns re-check every turn, server turns at most everyreauth_server_turn_intervalseconds.url_changeand background results are now re-checked too.- Errors:
PermissionDeniedanywhere in a turn becomes a deny; any other exception escaping a socket client turn is answered with the value-free error envelope. Server turns keep their own error handling. - Rate limiting stays at transport ingress: every WebSocket and SSE frame is
charged when it arrives, before it can queue behind a busy view.
Refuse.rate_limited()lets your middleware add per-caller limits.
Testing your middleware
djust.turns.run_turn runs a turn without a transport:
from djust.turns import Turn, run_turn
async def test_anonymous_callers_are_sent_to_login():
async def body(turn):
raise AssertionError("must not run")
refusal = await run_turn(Turn("event", "ws", view=MyView()), body)
assert refusal.kind == "login"
See also
- on_mount hooks: mount-only logic per view
- Authentication:
login_required,reauth_on_event - ADR-044