Anthias application & viewer architecture knowledge
Durable engineering findings on the viewer/server internals and the subtle bugs that bit them. Each bullet carries the mechanism plus the fixing PR/CalVer where known; OPEN items are flagged.
Application architecture (current layout)
- The web UI is server-rendered Django templates (
src/anthias_server/app/templates/) with Alpine.js + htmx + flatpickr — there is no React/Redux frontend. TypeScript is thin page bundles (home.ts,apps.ts,splash.ts,vendor.ts) undersrc/anthias_server/app/static/src/, built by bun. Don't grep for React components — look in_asset_modal.html/home.ts, etc. - Layout: server code under
src/anthias_server/(app,api,django_project,lib); shared code insrc/anthias_common/; viewer insrc/anthias_viewer/; webview C++ insrc/anthias_webview/; host agent insrc/anthias_host_agent/. - Integration tests (
tests/test_app.py, Playwright sync API) run inside thedocker-compose.test.ymlanthias-testcontainer against uvicorn onlocalhost:8080, started bybin/prepare_test_environment.sh -s. uvicorn does not auto-reload — restart it after editing server code, and neverpkill -f uvicornfrom a wrapper whose own cmdline matches. anthias_common.boardis the canonical low-RAM system:is_low_ram_device()+LOW_RAM_THRESHOLD_KB = 1_572_864(1.5 GiB), reading host_agent-publishedhost:total_mem_kbfrom Redis (keeps server/viewer consistent). Reuse it — do NOT reintroduce a private/proc/meminforeader. It gates the processing.py 1080p video pixel cap and the Low-RAM badge, on both eglfs and cage/wayland stacks.
Streaming under ASGI (backup download / large responses)
- A sync
StreamingHttpResponsegenerator does NOT stream under Django ASGI/uvicorn. Django 5.2's__aiter__hits the sync-iterator branch and doesawait sync_to_async(list)(self.streaming_content)— draining the WHOLE generator into a RAM list before the first body chunk. The browser shows a download dialog stuck at 0 B until the entire build finishes, then times out. Also an OOM risk on 1 GB SBCs. - This was issue #3073: the #3005 backup-streaming fix (shipped v2026.06.3) was fully neutralized because
stream_backup()(src/anthias_server/lib/backup_helper.py) is a sync generator. Unit tests called it directly and never traversed ASGI__aiter__, so they passed while production buffered everything. - Fix: the iterator must be async so Django sets
is_async=Trueand uses theawrapper()streaming path. Wrap a sync producer withasync def+await sync_to_async(next, thread_sensitive=False)(it, sentinel)per chunk. Regression tests MUST drive the real ASGI view viaaiter(response)and assert the first chunk arrives before the generator is exhausted. - Backup E2E validated (2026-07-08) on three 1 GB boards with a real ~640 MB archive: RSS peaked ~70-170 MiB, TTFB ~30 ms. Two surfaces:
- #3074 backup DOWNLOAD =
POST /settings/backup/(HTML view, CSRF-gated → fetch csrftoken fromGET /settings/first;StreamingHttpResponse(astream_backup())). NOT the v2 API. - #3143 recover UPLOAD =
POST /api/v2/recover(DRF, CSRF-exempt), streams tostatic/<uuid>.tar.gzviafile_upload.chunks().
- #3074 backup DOWNLOAD =
Viewer ↔ Redis pub/sub messaging
- Command channel is
anthias.viewer(Redis pub/sub); server↔viewer request-reply usesanthias.viewerpublish + BLPOP onanthias.reply.<correlation-id>. - Viewer subscriber can die silently and keep playing (OPEN). Seen on a 1 GB arm64 board (~16 h uptime):
redis-cli pubsub channelslisted onlyhostcmd, noanthias.viewer, sopublish anthias.viewer "viewer blank"returnedsubscribers=0and went nowhere — board deaf to next/previous/stop/blank, yet still cycling assets.docker restartrestores it. Diagnose withredis-cli pubsub channels, not the ready flag.viewer-subscriber-readylies: set once on subscribe, only cleared onredis.ConnectionError, so it stays1when the thread dies any other way. Suspected:_consume()raising anything that isn'tredis.ConnectionErrorescapesrun()'s except and killsViewerSubscriber.run()for the process lifetime. No issue filed. - Subscriber topic-prefix trap: the viewer subscriber splits commands via
data.partition(' ')and drops anything without theviewertopic prefix.processing.pycelery publishes raw'reload'(no prefix) which would be dropped — unverified suspect. - stop/blank pause path — NOT a bug (issue #3136). QA reported
stop/blanknot halting rotation on headless pi5, but it did not reproduce on the real pi5 RC (2026-07-08). Theglobal loop_is_stoppedfix (#3065) is an ancestor of the RC. The QA symptom was a measurement artifact (4 s durations → the naturalShowing asset...line fires within ~20 ms of the publish). Recommend closing #3136 as not-reproducible.
Playlist reload / SQLite WAL
- First asset on a fresh install never auto-displays (out-of-the-box bug, RESOLVED #3062).
scheduling.pyrefresh_playlist()only callsupdate_playlist()whenget_db_mtime() > last_update_db_mtime.get_db_mtime()stat'd the mainanthias.dbfile only, but the DB runs in WAL mode — writes land inanthias.db-wal, so the main file's mtime stays frozen until a checkpoint → the trigger never fires. With an empty starting playlist_compute_deadline()returnsNoneso the deadline trigger never fires either._handle_reload()reloads settings only, NOT the playlist. And v2 create returned 201 WITHOUTsend_to_viewer('reload'). Net: first asset never picked up until viewer restart or a main-db mtime bump (touch anthias.dbinstantly fixed it). Fix #3062:get_db_mtime()now takes max mtime across.db/-wal/-shm. - An asset's
uriis immutable by design. The edit modal renders#edit-urireadonlywith nonameattr (not submitted); neitherassets_updatenorUpdateAssetSerializerwritesinstance.uri. To change content you delete + re-create. "Edit the URL" silently no-ops — intended.
Viewer memory / OOM behavior
- Viewer stdout accumulation (issue #3138, fixed #3147, 2026-07-08).
src/anthias_viewer/__init__.pyspawnsAnthiasViewerviash.Command(...)(_bg=True, _err_to_out=True, ...)with no_outsink. sh (2.3.0) accumulates the subprocess's entire merged stdout+stderr in viewer-process RAM for the whole session. Chatty decoder spam (e.g. ffmpeg AACchannel element 0.0 duplicateper frame) → unbounded RSS growth → swap thrash on 2 GB → multi-minute blank. Fix: a bounded_BoundedWebviewOutputas_outkeeping the last 64 KiB (a deque of chunks, notbuf += chunkreslice). Measured: OLD +76 MB RSS for 12 MB of spam; NEW +0.5 MB. - #3147 is NOT the fix for web-page OOM (forum thread 6731). On a real 1 GB Pi3B+ (787 MB total, no swap) cycling web pages,
AnthiasVieweremits304 KB one-time Qt startup dump then **0 B/s**. The stdout leak needs continuous chatty stderr; bounding it changes nothing for web-page workloads. Real pressure = Qt6 QtWebEngine footprint on a tiny board — a 64-bit Pi3 OS now lands on the heavier Qt6pi3-64stream (#2985, gated on userspace arch by #3076). Fix #3178: force Chromium's low-memory profile, gated on measured RAM viais_low_ram_device()(NOT board/DEVICE_TYPE — a 1 GB Pi5 exists). Prepend toQTWEBENGINE_CHROMIUM_FLAGS:--enable-low-end-device-mode --js-flags=--max-old-space-size=64 --renderer-process-limit=1 --process-per-site --disable-dev-shm-usage. HW-validated: Pi3B+ RSS 225→147 MB. Reverting Qt6→Qt5 is not an option. - Upgrade OOM at swapoff (issue #3165, fixed 2026-07-09).
run_upgrade.shaborted at ansiblesystem : Disable swapwithrc: -9(OOM SIGKILL) on 1 GB Pi4:swapoff --allfaults every used swap page back into RAM at once. Root cause:bin/install.sh::main()runs the ENTIREsite.yml(dist_upgrade then swapoff) while the previous-version Docker stack is still running, so swapoff fires at peak memory. Fix: (1)stop_docker_stack()in install.sh BEFORErun_ansible_playbook(guarded on the rendered compose file existing); (2) misc.yml memory guard runs swapoff only whenSwapUsed < MemAvailable - 64MiB, withretries:3 until rc==0 failed_when:false. Validated on real 1 GB Pi4. - Asset upload must never wedge the device (P0 policy). Uploading a normal asset pack must never make a device unresponsive. If it does, the asset-processor/walker hardening has a hole and the PR is not production-ready. Baseline hardening (not tunables): cgroup
cpus: 1.0, CFS quota scaling, walker--concurrency=1, nice/ionice (PR #2885, from a Rock Pi 4 wedging on 4 parallel libx265 encodes). The fix belongs in the walker/cgroup/concurrency layer, never a docs note saying "upload one file at a time."
Upload codec gate (by design, not bugs)
- The upload codec gate in
src/anthias_server/processing.py(_HW_DECODE_VIDEO_CODECSmap +_hw_decoded_codecs()) rejects any uploaded video whose codec the board can't hardware-decode — by design. It surfaces a "Failed" pill + a copy-pasteable ffmpeg re-encode recipe rather than shipping a clip that would SW-decode janky.'pi5': frozenset({'hevc'})— Pi 5 has no H.264 HW decode, so H.264 is rejected (Sentry ANTHIAS-1J). Unknown DEVICE_TYPE (catch-allarm64, host_agent never publishedhost:board_subtype) → empty set → every video rejected (Sentry ANTHIAS-20). UnsupportedVideoCodecErroris a handled user-facing outcome and should NOT reach Sentry. Fix #3041: addedthrows=(UnsupportedVideoCodecError,)to thenormalize_video_assetcelery task (sentry-sdk's CeleryIntegration skipstask.throws).- Separately,
KeyError: 'WEBP'on all pi3 (Sentry ANTHIAS-1Y) was a real bug: on armv7 Pillow builds from source and linkslibwebp7+libwebpmux3+libwebpdemux2, but the runtime image only got the first two. Fix #3042: add the three webp runtime libs tobase_apt_dependenciesintools/image_builder/__main__.py.
Sentry noise vs real bugs (triage pattern)
- Operator input validation leaking into Sentry as ERROR (ANTHIAS-3D,
AuthSettingsError: New passwords do not match!). Both settings-save surfaces caught it under broadexcept Exception+logger.exception(...). Sentry's LoggingIntegration turns any ERROR-level log record into an event, so a password typo paged the team. Fix #3068: catchAuthSettingsError(aValueErrorsubclass with curated operator text) ahead of the generic handler; log at warning (no traceback → never a Sentry event) — the WARNING change is what actually stops the event; add to_sentry_before_send's drop list as a backstop. General rule: for input-validation Sentry noise, log at warning AND add tobefore_send. - Celery 30 s hard-limit SIGKILL trio (ANTHIAS-A / -9 / -B) are ONE incident fanned into three groups (9 and B carry no task identity). #3017 added
soft_time_limitonly to the asset-probe path, leavingget_display_powerandsend_telemetry_taskwith baretime_limit=30. Root cause for telemetry:requests_post(timeout=5)bounds connect+read but NOTgetaddrinfo— a wedged resolver hangs DNS past 30 s → SIGKILL. Fixed #3063 / #3180:soft_time_limit=30/time_limit=60+SoftTimeLimitExceededcatch on every periodic task; display_power/telemetry-cooldown keys use a singleSET ... ex=so a soft-limit signal can't strand them without a TTL.
Remote media (self-signed HTTPS + preview) — forum thread 6726
- Bug 1 — self-signed/untrusted HTTPS media (RESOLVED #3176). Two independent walls: the webview image loader (
view.cpp,QNetworkAccessManager) had nosslErrors/ignoreSslErrorshandler → blank; and serverurl_fails(src/anthias_common/utils.py) rejected self-signed →Asset.is_reachable=False.verify_sslwas vestigial (never in UI,verify=Trueeven when False since a 2019 regression, and only affected the server probe, never the webview). Fix: surfacedverify_sslin UI + v2 API; fixed theelse: verify=False; added PER-ASSETAsset.skip_ssl_verify(migration 0007) composed with the global; viewer passes effective skip to webviewloadImage/loadPageD-Bus slots; webviewignoreSslErrorson the image reply +certificateErroroverride/signal → acceptCertificate. Video needs nothing (Qt6 QMediaPlayer/FFmpeg already handshakes self-signed). - Bug 2 (OPEN) — remote-media backend preview blank (cert-independent, all remote jpg/mp4).
assets_preview/assets_download(app/views.py) only redirect-to-source formimetype in ('webpage','streaming'). Remote.jpg/.mp4storedmimetype='image'/'video'→ fall to_safe_local_asset_path()→ no local file → 302 to home → blank. Regression from the React→Django rewrite. Not fixed by the SSL branch.
Webview features (C++ / QWebEngine)
- Per-asset custom HTTP request headers (#2215, merged #3162). Stored in
Asset.metadata['headers']as{name: value}(no migration). Path: model helpers (CR/LF-reject to stop header splitting) → v2 serializers → edit-modal textarea → viewerview_webpage(headers=...)over a newsetRequestHeadersD-Bus slot → C++RequestHeaderInterceptor(QWebEngineUrlRequestInterceptoron the shared profile). Works identically on Qt 5.13+ and Qt 6 (one non-gated path). Interceptor is same-host scoped (exact host match) so a token never leaks to third-party CDN/analytics. Use case: private Grafana dashboard via a service-accountAuthorizationtoken. Cold-boot race: firstsetRequestHeadersafter spawn can lose to D-Bus registration;view_webpagecaches headers only on success so a single-asset playlist self-heals next tick. - pi3-64 gapless in-slot video looping (PR #3174, "reduced-seam"). Switches overlay video+audio pipelines from flushing-seek restart to GStreamer SEGMENT playback (
videoview.cpp). The original PR's bug: the init seek was issued synchronously right afterset_state(PLAYING)while still prerolling (returns FALSE) → segment mode never engaged. Fix: enter segment mode fromGST_MESSAGE_ASYNC_DONE(preroll complete), latching flags on seek SUCCESS. In segment mode the pipeline postsSEGMENT_DONE, never EOS — a failed non-flushing re-arm seek would freeze forever, so the segment-done handlers fall back togstRestartLoopon failure. Loop drop ~6-10 → ~3 frames; NOT fully gapless (VC-IV decoder re-primes).
PulseAudio / audio (Qt6 boards)
- HDMI audio dies after reboot/restart (issue #3112, follow-up to #3001). Symptom:
Daemon startup failed→ "video will play without audio". Real reason (hidden behind the generic line):pid.c: Daemon already running/pa_pid_file_create() failed— a stale${XDG_RUNTIME_DIR}/pulse/pid(/run/user/1000/pulse/pid). In Docker,/runis NOT a per-boot tmpfs — it's the container's writable layer — so the pid file survivesdocker restartAND host reboot.--daemonize=yesself-re-execs and racily reads the stale PID as a livepulseaudio→ aborts (intermittent). Fix:rm -f "${XDG_RUNTIME_DIR}/pulse/pid"instart_pulseaudio()before thepulseaudio --daemonizeinvocation. Debug:docker restartreproduces faster than a host reboot (persistent/run); capture the hidden reason with--log-target=file:... --log-level=debug.
Config file ownership (legacy upgrade)
- anthias.conf root-owned crash-loop (RESOLVED). On balena devices upgraded from an older root-running version, the viewer (uid 1000) hard crash-looped with
PermissionError: [Errno 13]writing/data/.anthias/anthias.confatsettings.pyopen(self.conf_file, 'w'): the file was created root-owned0600by the old version. Chowning only the dir is insufficient — truncate-opening an existing file checks write permission on the file. Fix:bin/start_viewer.shrunschown -Rf viewer /data/.anthias. Net: ONEPermissionError→ restart → recursive chown → comes up. One-time self-healing transient, NOT a crash-loop — don't treat the single PermissionError as a blocker.
Content import framework (inbound migration)
- Extensible inbound import (migrate content INTO Anthias from other signage platforms), under
src/anthias_server/lib/integrations/.base.py=ImportProviderABC (validate_token/list_media/import_item) + neutral dataclasses +ProviderImportError(transport-agnostic, REST or GraphQL).ingest.py= provider-agnostic download →CreateAssetSerializerV2→ idempotency viametadata.import_source = {provider, remote_id}; download auth is host-scoped (auth_host) — never forward a token to a pre-signed CDN original.http.pyuses a neutral browser UA, NOT the Anthias UA (a self-identifying UA invites vendor blocking).registry.pyadds a provider in one line. Shared create path =api/helpers.persist_new_asset. - Scope = media only (image/video/webpage); filter non-portable content (skip audio/documents, apps/internal web content).
- Shipped providers (merged 2026-07-08): Yodeck (#3144), ScreenCloud (#3145), piSignage (#3148), Xibo (#3149). OptiSigns (#3146) removed — file originals not downloadable at the tested tier. NoviSign dropped — no public read/list API. Confirmed auth formats: Yodeck
Authorization: Token <token>(/api/v2/); ScreenCloudAuthorization: Bearer <token>, region auto-detected by probinggraphql.{eu,us}.screencloud.com; piSignagePOST /session→ JWT asx-access-token; Xibo OAuth2 client-credentials (POST <cms>/api/authorize/access_token), Bearer.
Edge App CLI runtime (Screenly CLI as runtime)
- Edge App support runs the Screenly CLI (
screenly edge-app run) inside the viewer container; the webview loads the loopback URL it prints. Three gotchas (all handled in code, don't regress): (1) prebuilt CLI binaries are stale — build the CLI from the pinned git tag for all Qt6 boards; build.rs needs only build-essential/pkg-config/libssl-dev. (2)edge-app runneeds auth + xdg-open — any non-emptyAPI_TOKENworks (anthias-local); ship a no-op/usr/local/bin/xdg-openshim. (3) mock-data.yml must match the CLI'sMockDatastruct exactly —metadatarequires all of coordinates/hardware/hostname/location/screen_name/tags; CORS-bypass apps fetch via a localhost CORS proxy (edge_app_cors_proxy.py).
Provisioning
- The Anthias Raspberry Pi Imager image does NOT enable SSH by default (follows Raspberry Pi's default — off unless enabled via the Imager OS-customization gear dialog before flashing). balenaHub fleets DO have SSH (balena supervisor runs sshd). Any FAQ/docs/support copy must tell users to enable SSH via Pi Imager's OS customization before flashing.
Cross-cutting testing/CI notes
pull_requestCI (unit tests etc.) runs on the ephemeral merge SHA (refs/pull/N/merge); Copilot review runs on the branch-head SHA. Socheck-runson the head SHA shows only SonarCloud + copilot — find the test runs viagh run list. Copilot only ever COMMENTs (never approves), so merging master-protected PRs needs an admin override (enforce_admins:false+ admin token →gh pr merge --squash --admin).- SonarCloud gate blocks on
new_security_rating: anhttp://literal (even in tests) trips S5332 → usehttps://or# NOSONAR. mypy runsuv run mypy .project-wide.