djust 1.3.0rc5

Pre-release Released

The fifth 1.3 release candidate. It ships the 19 task-shaped tutorials in docs/website/guides/ (#3173), so the docs site can render them from djust itself, plus rounds 2 and 3 of the open-issue drain (#3204).

  • Explicit exposure: a state save no longer times out under load (#3200), a view whose session vanished mounts fresh (#3201), and the SSE and enable_state_snapshot follow-ups (#3211, #3212, #3221).
  • Presence: broadcast_to_presence() reaches a templated presence_key (#3202), and Redis list() costs two commands (#3203).
  • Transport and render: the WebSocket honors FORCE_SCRIPT_NAME (#3186), SSE session-registry fixes (#3164), off-loop set_layout() renders (#3178), and the dj-root template-comment fix (#3187).
  • Docs: a new Scaling djust guide (#3216) and aligned deployment docs (#3219, #3210).
  • Tooling: djust deploy logs (#3195), djust deploy without a TTY (#3112), and djust's config following override_settings (#3217, #3218).

Behaviour changes to check when upgrading from rc4:

  • New system check djust.C023 warns about a channels_redis layer that redis-py 8 would time out (#3199).
  • The djust theme preset's light-mode link colour is darker, for WCAG AA contrast (#3165).
  • T012 accepts dj-root on the root element (#3171).
  • djust's config reloads on setting_changed; values set with config.set() survive the reload (#3217).

Added

  • djust deploy logs [<slug>] [--deployment <id>] [--follow] prints a deployment's build/deploy log (#3195). Until now the log of a failed deploy was only visible in the djustlive dashboard. The slug defaults to [tool.djust.deploy].project in pyproject.toml, and without --deployment the project's most recent deployment is shown. --follow keeps printing new lines until the deployment finishes and exits 1 if it failed or was cancelled; connection errors, timeouts and 5xx responses are retried up to five times in a row with backoff. Log lines go to stdout and the deployment's status to stderr. Credentials resolve the way djust deploy resolves them: an expired access token is refreshed, each time it expires during a long --follow, and a log query never opens a browser login. These are the platform's build and rollout lines, not the running app's own output. logs joins login, logout and status as a reserved word after djust deploy: a project with the slug logs is deployed with djust deploy deploy-dir logs. Ctrl-C in any djust deploy command now prints Aborted. and exits 130 instead of printing an empty Error: and exiting 1. Needs a djustlive server with the build-log API; older servers answer 404. New cases in TestLogsCommand.
  • System check djust.C023 warns when a channels_redis core layer would drop idle WebSockets under redis-py 8 (#3199). redis-py 8 lowered its default socket_timeout from None to 5 s, the same value channels_redis.core.RedisChannelLayer passes to BZPOPMIN, so an idle consumer's read times out and its WebSocket closes every few seconds (django/channels_redis#422). The check fires when a CHANNEL_LAYERS entry uses that backend, redis-py 8 or later is installed, and a host does not set socket_timeout above 5 s (or None); URL and (host, port) hosts count as the default, a ?socket_timeout= in the URL overrides the dict's key (as in redis-py), subclasses of the core layer are checked, and settings shapes channels_redis itself rejects are skipped instead of crashing manage.py check. djust doctor now probes a dict host at its address (or host/port) instead of pinging localhost under the configured host's label. The channel-layer snippets in the deployment, server-push and error-codes guides now use the dict host form {"address": REDIS_URL, "socket_timeout": 20} (20 since #3210). New cases in test_c023_redis_socket_timeout_3199.py.

Fixed

  • djust deploy no longer fails with a bare Error: when run without a TTY, and djust deploy status refreshes an expired login (#3112). Deploying to a project that did not exist yet asked "Create it now? [Y/n]", which read end-of-file from a script or CI job and aborted with no explanation; it now stops before prompting and says to pass --yes (or --no-create). The slug prompt, reached when no slug is given and none is saved in pyproject.toml, likewise fails with its "pass it as a positional argument" message. djust deploy status sent the saved access token without checking it, so an hour after logging in it failed with a raw API error 401; it now resolves credentials the way djust deploy does, refreshing an expired access token silently and, when the refresh token is dead too, asking you to run djust deploy login rather than opening a browser. New cases in TestNoTtyProjectCreation and TestStatusTokenRefresh.

  • SSE session registry: a reused session_id no longer replaces another owner's live session, the caps hold under concurrent GETs, and an early disconnect no longer leaks a registration (#3164). A stream GET with a client-chosen id that was already live under a different owner overwrote it, so the victim's POSTs hit the new session (403/404), and the replaced stream's finally then popped the NEW session. That GET is now refused with 409, checked before the mount and again, atomically, at registration. Only a live session blocks: one whose stream has closed or is closing (in its linger window, or shut down) is replaced, and the same owner re-using its id (EventSource auto-reconnect) always replaces its own. A tab whose owner changed while its stream was still live (a login or logout in another tab) gets the 409; since EventSource never retries a non-200, the client now retries once with a fresh session id when a connection, or EventSource's own reconnection after a drop, fails before its sse_connect ack. A stream's finally removes the registry entry only if it is still that stream's session. The per-client (429) and global (503) caps counted, awaited the mount, then registered, so concurrent GETs overshot them, truly in parallel under djust serve --loops N; a GET now reserves its slot under a threading.Lock before the mount, and every exit converts the reservation into the registration or releases it, including a failed, unauthorized or raising mount. A second release of one reservation is logged as an error instead of being clamped, and the 503 log counts in-flight reservations. Separately, the sse_connect ack was sent before the try whose finally unregisters, so a client that read the ack and disconnected left its session registered forever, and a loop of those filled the global cap; the ack is now inside the try, and a stream whose response Django closes before it starts, or that has not started 30 seconds after registration (the handler was cancelled before it could close the response), unregisters and is shut down too; the start-deadline timer is cancelled as soon as the stream starts or is abandoned, so it never holds a session in memory. The one fresh-id retry is re-armed by a navigation (a new mount or live_redirect). New tests in python/djust/tests/test_sse_registry_3164.py and tests/js/sse_fresh_id_retry_3164.test.js.

  • The djust theme preset's light-mode link colour passes WCAG AA (#3165). link was the bright brand orange fill (28 80% 53%), 2.66:1 on the page background, and .link / .text-link render it as body text. It is now 28 80% 35% (5.3:1 on the background, 5.5:1 on cards, 4.7:1 on muted), with link_hover at 28 80% 29%; the hue is unchanged and dark mode already passed. primary keeps the bright fill because it carries the dark ink labels from #2996, so primary used as text on the background still measures 2.66:1. The link/background pair is not yet in CONTRAST_PAIRS: 46 of 136 preset-modes still fail it (54 for primary), and that matrix change belongs with the legacy-palette remediation in #2885. New cases in test_djust_preset_link_contrast_3165.py.

  • The Python Toast class is fully styled by djust_components/components.css (#3166). Since #3161 its .dj-toast root picked up the card styling, but dj-toast-<type>, dj-toast-message and dj-toast-dismiss had no rules and no stylesheet read the --dj-toast-* properties its docstring lists. Each type now takes its tone on the border (error uses the theme's --destructive), the message and dismiss button share the {% toast_container %} rules, and --dj-toast-bg/-fg/-border/-radius/-padding/-shadow and the per-type --dj-toast-<type>-bg/-fg/-border are read first, each falling back to a theme token. The text stays on the card foreground, so no label's contrast depends on a status hue. New Toast-<type> cases in test_every_class_has_a_rule_in_a_linked_stylesheet and test_the_toast_class_properties_are_read_by_the_stylesheet.

  • djust init said the system check found "no issues" when it had printed warnings (#3170). manage.py check exits 0 for warnings, and init only looked at the exit code. It now reads Django's System check identified N issues summary, reports check done N issues reported (see below), and prints the check output under the table. The exit code is unchanged, since warnings do not fail the check. New cases in python/djust/tests/test_djust_init.py run the real manage.py check on a startproject tree.

  • System check djust.T012 now recognises dj-root on the root element (#3171). The warning no longer fires when dj-root is present without dj-view, matching the documented LiveView pattern where dj-root is authored and dj-view is injected at runtime. The warning message and hint now recommend adding dj-root.

  • djust init --dry-run labelled the settings and ASGI rows done (#3172). The table under "Dry run: nothing was written" read done djust block appended and done replaced Django's default. Those rows now read would change with what would happen (append djust block, replace Django's default, create). Rows that are unchanged or need attention keep their status, and a real run still reports done.

  • A set_layout() swap renders off the event-loop thread (#3178). Both _flush_pending_layout twins ran view.get_context_data() on the loop thread (the consumer's also ran render_to_string there), so a layout turn did sync context and template work on the per-process bottleneck, outside the session's worker thread (ORM access in get_context_data saw a different thread and DB connection, or raised SynchronousOnlyOperation). Both now call one shared render_pending_layout(view, path) in a single sync_to_async hop, like the other render paths: on the session's pinned worker with LIVEVIEW_CONFIG["worker_threads"], else on asgiref's thread-sensitive thread. Error handling is unchanged. New tests in python/djust/tests/test_layout_render_off_loop_3178.py.

  • The version-sync check now covers the shipped SBOM (#3184). scripts/check-lockfile-versions.py (run by make release, the CI test job and a pre-commit hook) now also fails when the root djust component of python/djust/djust.cdx.json names a version other than pyproject.toml's. The 1.3.0rc4 cut needed a manual commit because the SBOM still said rc3. make version already regenerates the SBOM (added with it in #3136); a test now pins that. New cases in TestSbomVersion.

  • The client's WebSocket honors FORCE_SCRIPT_NAME / SCRIPT_NAME (#3186). connect() always opened ws[s]://<host>/ws/live/, so an app mounted under a path prefix connected to whatever served /ws/live/ at the host root. {% djust_client_config %} now also emits <meta name="djust-ws-path" content="<script prefix>ws/live/"> (both template engines), the client resolves it once at bootstrap as window.djust.wsPath (an explicit window.djust.wsPath set before the bundle wins), and connect() builds the socket URL from it with window.djust.wsUrl(), falling back to /ws/live/ when the tag is absent. Only a root-relative path is honored, so the socket always targets the page's own host. A new DJUST_WS_PATH setting, when set, is emitted verbatim instead. The server must answer at the prefixed path: the deployment guide's new "Serving the app under a path prefix" section covers a prefix-stripping proxy, uvicorn --root-path (Channels' URLRouter removes root_path), or an explicit path("app/ws/live/", ...) route. wsPath and wsUrl() are declared in djust.d.ts. New tests in python/djust/tests/test_ws_path_script_name_3186.py (through Django's WSGIHandler, since the test client never sets the script prefix), tests/js/ws_path_script_name_3186.test.js, and the 5c cases in python/djust/tests/test_client_config_tag.py.

    Upgrade note. A deployment that already sets FORCE_SCRIPT_NAME (or passes SCRIPT_NAME / an ASGI root_path) and uses {% djust_client_config %} has so far only been able to use the host-root /ws/live/, so its proxy may route only that. After upgrading, the client connects to <prefix>/ws/live/. If that first handshake fails before the socket has ever opened, the client tries /ws/live/ once and logs a console.warn naming the URL that failed. The root path is adopted for the rest of the page only if that socket opens; if it fails too, the reconnect backoff goes back to the prefixed path. So the page keeps its WebSocket at the cost of one failed handshake per load. To remove that cost, route <prefix>/ws/live/ to djust, or set DJUST_WS_PATH = "/ws/live/" to keep the old path; system check djust.C025 reports a DJUST_WS_PATH that is not a path starting with a single /, which the client ignores with a console.warn. A correctly routed deployment makes the same one attempt when its first handshake fails for an ordinary reason (a restart, a rollout); on a shared host where another app answers at /ws/live/, that attempt can open there, and the page stays on it. A per-site shim that rewrote the socket URL to add the prefix (djust-docs' ws-prefix.js) is now redundant, and harmless, since it rewrites only an exact /ws/live/.

  • A <div dj-root> written inside a Django template comment no longer doubles the page (#3187). A LiveView whose template {% extends %} a base and mentions <div dj-root> in a {% comment %} block (or {# ... #}), before the real root, rendered a second full HTML document inside the base's <main> on the initial GET: locating the root in the template source took the commented tag as the root, so the VDOM template became the slice from the comment to the real root's close. docs.djust.org/search/ hit this with a comment explaining why its dj-view is written out; it did not depend on the explicit dj-view. The root search in template source (get_template(), _extract_liveview_root_with_wrapper, _extract_liveview_template_content) now masks Django template comments, length-preserving, so a comment neither selects the root nor moves the close-tag balance inside it. The masking tokenizes as Django's lexer does: {% verbatim %} blocks are text, the tag name must be exactly comment ({% comment-box %} is not one), {{ }} is taken before {#, and {# #} does not span lines. Rendered-HTML searches are unchanged. New tests in python/djust/tests/test_extends_double_document_3187.py.

  • An explicit view's per-event state save no longer times out under load and tells users to reload (#3200). commit_explicit_turn gave the save a 150 ms asyncio.wait_for that also counted two or more sync_to_async hops queueing behind every other session's work on the shared sync thread, so a busy process answered ordinary events with "State unavailable. Please reload the page." The root and child-tree saves now run as one sync call in one Django-thread hop, and the deadline starts when that call starts running, so queue time is not counted. The deadline is configurable with DJUST_EXPLICIT_STATE_SAVE_TIMEOUT (seconds, greater than 0 and at most 10; default 0.15), and new system check djust.C024 reports an invalid value. Saves of one runtime are now ordered: a save still running past its deadline delays the next one, so a late write can no longer overwrite a newer, already acknowledged one (it could before, when turns ran on different threads, as SSE requests do). A save that still outruns the deadline keeps ADR-038's guarantee that the browser never shows a state storage does not have: the success frame is withheld and the next update is full HTML. But the error is now marked transient and says the change is still being saved, and once storage answers ONE catch-up turn re-authorizes, commits the kept values with a 10 s deadline and sends full HTML without waiting for the user. The catch-up never schedules another one. If it cannot deliver (storage still not answering, authorization refused, its own save or render failing), if three deferrals come in a row, or if a save has run for 10 s, the error is the terminal reload error, as before. At mount, where there is no page to catch up, a slow child save is still the reload error. A refused save (the session identity changed) or a storage error is still the terminal state_error, and a save failure nobody is waiting for any more is now logged. Also fixed (#3097): a start_async result never rendered over SSE, because the background task inherited the event POST's asgiref executors, which die when the POST returns ("CurrentThreadExecutor already quit"); root and child-view background tasks, and the catch-up turn, now run their sync work on the default sync thread, and the ADR-038 E5 browser matrix now requires the SSE result to render. Storage-failure tests now inject through the sync session save. New cases in python/djust/tests/test_explicit_save_deadline_3200.py and python/djust/tests/test_exposure_sse_resilience_3200.py.

  • An explicit view mounts fresh over the WebSocket when its session no longer exists (#3201). After a Redis flush or restart with cache sessions, eviction, expiry or clearsessions, the browser still presents the old session cookie. The mount's first storage read cleared that key, and binding the view's state to it then failed with "Protected view operation failed" on every reconnect, so the page was stuck until a manual reload. The mount now creates a replacement server session in that case, re-derives the user from it (anonymous, since a lost session cannot vouch for the user the socket connected as) and runs mount() with nothing restored. Only a key that was presented and then found missing is replaced: a socket with no session cookie is refused as before and creates nothing, a session that still exists keeps its identity, and repeated mounts on one socket reuse one replacement. A WebSocket replacement never reaches the browser, so it expires after DJUST_SERVER_STATE_MAX_AGE; an SSE mount runs on the real stream request, whose response issues the replacement as a cookie with Django's normal lifetime, so a later login does not inherit a short one. With cache sessions Django treats a failed cache read as a missing session, so a transient read error also mounts anonymous (the real session survives). A live socket whose session disappears is still refused with 4403, because that is how a logout revokes it; the reconnect that follows now mounts fresh. New cases in python/djust/tests/test_exposure_stale_session_mount_3201.py.

  • broadcast_to_presence() never arrived for a templated presence_key (#3202). The WebSocket transport joined the presence group before mount(), so presence_key = "chat:{room}" could not be formatted yet: every session joined the unformatted group djust_presence_chat:{room} (shared by every room), each mount logged Presence key format error, and broadcast_to_presence() sent to the formatted group, which nobody had joined. The presence group is now joined after mount() or a session restore, from on_mount_render_ready, with the key read on the session's thread. The consumer records every presence group it joined, so a mount_batch with several presence views leaves all of them on disconnect, and a live_redirect now leaves the old view's presence group (before, a session that navigated from one room to another kept receiving the old room's broadcasts and stayed in its group after disconnect). Only PresenceMixin views join a presence group; a TenantMixin view without presence no longer joins one. LiveCursorMixin cursor broadcasts go through broadcast_to_presence() and are fixed with it. Behaviour change: because the join now follows mount() and the first render, a presence broadcast a peer sends while a session is mounting is not delivered to that session; the online count is unaffected, since it refreshes through server push. A session whose presence key changes after mount (for example by a handler switching rooms) still stays in the group it joined at mount. 5 regression tests in python/djust/tests/test_presence_templated_key_3202.py.

  • Redis presence list() cost 2 + N Redis commands on every render (#3203). Each call ran cleanup_stale (a ZRANGEBYSCORE, plus a MULTI/EXEC write pipeline when anything was stale), a second ZRANGEBYSCORE, and one HGET per member in a MULTI/EXEC pipeline; list_presences() in get_context_data and online_count call it on every render of every session, which made presence the largest Redis load in a multi-pod chat. In both RedisPresenceBackend and djust.tenants' TenantAwareRedisBackend, a read is now one ZRANGEBYSCORE and one HGETALL, pipelined without a transaction, whatever the member count. list() runs cleanup_stale at most once per cleanup interval per presence key per process, on the monotonic clock; the interval defaults to 30 s and is set with DJUST_CONFIG['PRESENCE_CLEANUP_INTERVAL'] (or the backends' new cleanup_interval argument). A stale member is still left out of the list, because the read filters on the heartbeat score. Calling cleanup_stale() directly is unchanged. 12 regression tests (test functions; most are parametrized over both backends) in python/djust/tests/test_redis_presence_list_cost_3203.py, which count the commands fakeredis receives.

  • Custom and djust filters could disappear from root LiveView renders for the rest of the process (#3208). The one-time bridge that sends every Django library filter to the Rust engine runs on the first render. When that render went through a separate DjustTemplateBackend (a second template backend, or a from_string on one), the filters were registered in that backend's registry namespace only. After that, {{ view|field_value:"name" }} in a root LiveView failed with Invalid filter: 'field_value'.

    • The bridge now always writes the global namespace. That is where DjustConfig.ready()'s startup warm-up already put the filters, so this changes behaviour only for projects with LIVEVIEW_CONFIG['filter_bridge_warm'] = False. For those projects, app template libraries' filters now reach every backend, not only the backend whose render ran first.
    • If a bridged filter later goes missing from the global registry (for example after clear_custom_filters()), the bridge re-registers only the missing names, each with the callable it originally registered. It never touches a name that is still present, so a same-named filter that a {% load %} registered is not swapped out.
    • New cases in python/djust/tests/test_filter_bridge_global_registry_3208.py.
  • Theme colours that browsers dropped or misread because a bare HSL token was used as a colour (#3209). Theme tokens are bare HSL triplets (--link: 28 80% 35%), so color: var(--link, var(--primary)) computes to a string that is not a colour and the declaration is discarded.

    • Every such use now goes through hsl(): .page-link and the auth-page footer links (the cited pages.css:70 and :87), nine more rules in pages.css (page title, descriptions, back link, error code, ETA, social divider) and the catalogue's .dc-version.
    • .hover-glow's rgba(var(--primary) / 0.3) in design_system_css.py was not dropped but computed to a wrong, yellowish colour; it is now hsl(var(--primary) / 0.3).
    • The outlined button (the neo_brutalist and retro design systems) is readable on hover. Its hover rule set background: currentColor with a color that was dropped, so the hovered button was one solid block of the inherited text colour. It now sets the inverse explicitly: background and border-color hsl(var(--foreground)), text hsl(var(--background)).
    • The Meter docstring example passes hsl(var(--primary)), since Meter writes the string straight into a style attribute.
    • python/djust/tests/test_theming_hsl_token_usage_3209.py scans every stylesheet, template, JS file and CSS-emitting Python file in the package (inline <style> blocks and quoted "color": "…" mappings included) and fails on a bare var(--<token>) outside hsl()/hsla(), with the token list read from the theme generator's own output. python/djust/tests/test_theme_button_hover_contrast_3209.py resolves each design system's hovered .btn against every preset, light and dark, and fails if its text colour equals its background.
  • An explicit view that also sets enable_state_snapshot = True no longer acknowledges a failed state save (#3211). The flag is the legacy snapshot opt-in, but on the event path it sent an explicit view to the legacy best-effort save, which logs a failed or slow write and answers the event with a patch anyway. That bypassed ADR-038 E3 (the browser was shown a state storage did not have) and the #3200 deadline fix. Explicit views now always commit through commit_explicit_turn, whatever the flag says, as mount already did. The same applies to the parallel paths:

    • Component events on explicit views. Every event routed to a component of an explicit view (by component_id) now commits the view's declared state before its frame, with or without the flag. That covers a component declaration's output handler (for example an ADR-034 DropdownMenu) and a component whose handler writes the view's fields through send_parent. Before, such a write was saved best-effort only with the flag and not at all without it. Visible change: every component event on an explicit view, including a click that changes nothing, now writes storage before answering, and a failed or slow write now answers state_error (or the transient "still being saved" error) instead of an html_update. Waiters see the event even when its save fails, as on the view-event route.
    • Sticky children. The sticky-child save predicate now also requires both the child and the parent to use the legacy policy, since a legacy save under an explicit parent can never be restored.

    Legacy views are unchanged. New cases in python/djust/tests/test_exposure_snapshot_opt_in_3211.py.

  • Three follow-ups to the explicit-exposure save work (#3212).

    • An SSE event whose save outran its deadline no longer blocks the event loop. Django serves each request inside a per-request ThreadSensitiveContext, and when the request ends that context shuts its single-thread executor down and waits for it. An explicit save that missed its deadline (#3200) kept running on that executor after the event POST answered, so the whole event loop stopped until storage answered; under sync middleware the request's bridge thread was held instead. A save made inside a request's executors now runs on a small pool of long-lived threads (djust-state-save, min(32, 4 × CPUs)), so the request ends when its turn does. Each save job closes stale database connections before and after it runs, as a request does, so CONN_MAX_AGE and connection health checks apply and a connection broken under the thread (a database failover) is replaced. One hung store write holds one thread, not every other session's saves, and a request-scoped save that cannot get a pool thread within 5 s is deferred with the transient "still being saved" error, like a slow one, rather than waiting indefinitely. The escalation to the reload error counts only the time a save has actually been running, not time spent queued. WebSocket saves are unchanged: they still wait for their session's thread without a bound (#3206). A navigation or reconnect over SSE builds a new runtime; its first save now waits for the old page's still-running save, so that one can never land over the new page's writes. A start_async callback spawned by the POST had the same problem and is now detached from the request's context too, not only from a sync bridge. WebSocket sessions (including worker-pool slots) and PooledHTTP requests are unchanged.
    • live_redirect re-checks sticky children as the user the new mount runs as. The re-check used the socket's connect-time user. Since #3201 an explicit mount whose session has vanished runs as anonymous, so a login_required sticky child survived into a page that mounted anonymous. The re-check now derives the user the same way the mount does, from the policy of the view that will actually be mounted (chosen and resolved as the mount does it: the URL's view, or the client's view for a back-navigation carrying a state snapshot; a legacy page redirecting to an explicit one included), and shares the mount's replacement session. A redirect to a legacy page keeps the connect-time user and creates no session.
    • A legacy view's best-effort state save no longer counts thread queueing against its 150 ms. _persist_state_after_event and _persist_sticky_child_after_event (views with enable_state_snapshot = True) wrapped several sync_to_async hops in one 150 ms timeout, so a busy process dropped saves that storage would have finished in milliseconds. Each is now one sync call in one Django-thread hop, with the deadline starting when it starts running, as #3200 did for explicit saves. A timeout or failure is still only logged. A save that times out is no longer cut short and may land late, so the saves of one view are now ordered: a late save can never overwrite a newer one.

    New cases in python/djust/tests/test_sse_save_teardown_3212.py, python/djust/tests/test_state_save_pool_3212.py, python/djust/tests/test_exposure_live_redirect_user_3212.py and python/djust/tests/test_legacy_save_deadline_3212.py.

  • djust's config now follows override_settings for every setting it reads (#3217). Those settings are LIVEVIEW_CONFIG, DJUST_CONFIG, DJUST_WS_COMPRESSION, DJUST_VDOM_CACHE_* and DJUST_STATE_SNAPSHOT_ENABLED. The config singleton read them once and cached them, so an override did not take effect until something called config.reset(). A reset done inside an override then outlived it: in djust's own suite, reauth_on_event=True leaked into later tests on the same worker and closed their sockets. The config now reloads on Django's setting_changed signal, on entry and on exit.

    • Values set in code with config.set() / config.update() (for example in a project's AppConfig.ready()) survive the reload. An override that sets the same key wins while it is active, and the code-set value returns when it exits. config.reset() still discards them.
    • The reload builds the new config and swaps it in with one assignment, so readers on other threads never see defaults partway through.
    • DJUST_VDOM_TRACE is unset again when a debug_vdom override exits, unless the environment set it.
    • Tests in projects that override LIVEVIEW_CONFIG no longer need a manual config.reset().
    • New cases in python/djust/tests/test_config_follows_override_settings_3217.py.
  • Follow-ups from #3213: filter restore and config edges (#3218).

    • After clear_custom_filters(), a bridged filter that a {% load %} had taken over is now restored from the library that owns it. Before, the restore skipped it, so a template using that filter without {% load %} failed with Invalid filter until the next {% load %}.
    • config.get() and config.as_dict() return copies of dict, list and set values, and set()/update() store copies. An in-place edit of a returned dict used to change the live config without being recorded, so the next reload (any override_settings of a config setting) silently undid it. Nothing in djust edited returned values in place; use config.set().
    • set()/update() write and record under one lock shared with the reload, so a reload on another thread can no longer land between the two and drop the value. Every writer is copy-on-write (builds a new dict and swaps it in), so the lock-free get()/as_dict() copy never races an in-place change (dictionary changed size during iteration).
    • The test tying _CONFIG_SETTINGS to the settings the config reads now scans the whole of python/djust/config.py (helpers, aliased imports, and the literal alias table) instead of one method, and fails on a read it cannot resolve. Covered by new cases in python/djust/tests/test_config_follows_override_settings_3217.py and python/djust/tests/test_filter_bridge_global_registry_3208.py.
  • A normal SSE stream close now disposes an explicit view (#3221). When the client went away, the stream lingered and removed the session from the registry but never called session.shutdown(), so an explicit view and its children were never disposed: start_async work kept running and rendering into a dead queue, waiters stayed pending, upload temp files stayed behind and unregister hooks never ran. The WebSocket path tears all of this down on disconnect. The stream now shuts its own session down after the linger, once any event POST still being dispatched on it has finished (waited for up to 10 s, then disposed anyway). When an EventSource reconnect has already replaced the session under the same id, the old session is still disposed, since nothing routes to it any more, and the reconnect's session and view are untouched. Two things to know: SSESession.shutdown() disposes only explicit views, so a legacy view's background work is still not cancelled on close (tracked separately); and an explicit view's app hooks (_cleanup_on_unregister, _on_sticky_unmount) now run after the linger, about 5 s after a reconnect's mount, the reverse of the WebSocket order, so a hook keyed by user or session identity (an "editing" lock, app-level presence) can undo what the new mount just set up. New cases in python/djust/tests/test_exposure_sse_close_3221.py.

Documentation

  • The 19 task-shaped tutorials now ship with djust (#3173). They lived only in the docs site's second content root, so the tutorial links in docs/website/getting-started/first-liveview.md and docs/website/getting-started/core-concepts.md were dead on GitHub. They are now in docs/website/guides/ (tutorial-*.md), listed in the Guides navigation, and checked against the current API: code that had drifted since 1.2 is corrected.
  • New guide: Scaling djust (#3216). docs/website/guides/scaling.md is the entry point for scaling decisions: cost per frame, one process across cores (linking into the across-cores reference), memory settings, several processes or pods with Redis, failover and rolling deploys, a capacity table and a troubleshooting checklist. For multi-pod deployments it states what a measured run on CPython 3.12 needed: shared, durable sessions; enable_state_snapshot or state(..., persist="server") for state that must survive a reconnect, because STATE_BACKEND = "redis" alone restores nothing; socket_timeout above 5 s on the channel layer with redis-py 8 (#3199); one pod of headroom, because connections don't rebalance after a failover; a free pod slot or maxUnavailable: 1 for rolling updates; and readiness probes that don't check Redis. deployment.md and scaling-across-cores.md link to it.
  • Deployment and scaling docs no longer contradict the Scaling djust guide (#3219, #3210, #3171). deployment.md now says the Redis state backend caches the compiled view as a diff baseline and does not restore a view's state on another process; that the Redis channel layer is required when more than one process serves WebSockets or anything pushes from another process (Celery, management commands, cron), with djust.layers.InMemoryChannelLayer right for one process with no out-of-process pushes; that djust serve --loops N > 1 needs MultiLoopInMemoryChannelLayer or channels_redis' pub/sub layer; that each --workers / -w worker is a separate process with everything Option B needs; and that uvicorn, gunicorn's UvicornWorker and djust serve use uvloop by default when it is installed, with --loop asyncio (or a UvicornWorker subclass) to opt out and #3095's trade-off (13% less loop CPU, 201 of 384 connections lost in one macOS ramp). Redis is advised noeviction rather than allkeys-lru when it also carries the channel layer or presence, ALB stickiness is marked optional, and the example TTLs are 120 s. Its session section gives the SESSION_TTL sizing rule (entries per session and LiveView page) instead of a per-use-case table and drops the hourly cleanup_expired_sessions cron (unneeded since #3080), pointing db / cached_db users at Django's clearsessions with a Celery task body; the worker_threads and --loops sections are summaries that link to the across-cores guide. scaling-across-cores.md replaces its Redis section with a link to Scaling djust, corrects its per-client memory arithmetic (about 2.7 MB each against the #3114 runs' 81 MB idle start, not 2.1 MB against a different setup's 116 MB), and mentions idle-time gc.collect() / malloc_trim(0). The channel layer's recommended socket_timeout is now 20 in every snippet, in the djust.C023 hint and in the docs, with #3210's explanation: it must exceed channels_redis' 5 s blocking read, and None is not recommended because a half-open connection then hangs until TCP keepalive notices. scaling.md now says #3200 makes the explicit-exposure reload error much less likely under load rather than fixed, and docs/system-checks.md describes T012 as "no dj-root or dj-view" (#3171). New cases in test_c023_redis_socket_timeout_3199.py pin the hint's value to the guides' and the demo project's snippets; the demo docs templates now set socket_timeout 20, and the T015 docstring quotes T012's current wording.

All releases · Atom feed