diff --git a/src/anthias_server/app/templates/system_info.html b/src/anthias_server/app/templates/system_info.html index 11d06451c..ed27a6d69 100644 --- a/src/anthias_server/app/templates/system_info.html +++ b/src/anthias_server/app/templates/system_info.html @@ -82,8 +82,8 @@

Live diagnostics

{% if low_ram.active %}

- Low-RAM mode: single-QWebEngineView (no - preloaded crossfade), 1080p upload cap on video. Engages + Low-RAM mode: Chromium low-memory profile + for web pages, 1080p upload cap on video. Engages below {{ low_ram.threshold_mib|intcomma }} MiB MemTotal.

{% endif %} diff --git a/src/anthias_viewer/__init__.py b/src/anthias_viewer/__init__.py index abfd4024c..9dc671756 100644 --- a/src/anthias_viewer/__init__.py +++ b/src/anthias_viewer/__init__.py @@ -21,6 +21,7 @@ import requests import sh as sh +from anthias_common.board import is_low_ram_device from anthias_common.http import get_anthias_product_token from anthias_server.settings import LISTEN, PORT, ReplySender, settings from anthias_viewer.constants import EMPTY_PL_DELAY as EMPTY_PL_DELAY @@ -249,6 +250,50 @@ def _build_webview_env() -> dict[str, str]: # inherited value is overwritten. env['ANTHIAS_UA_TOKEN'] = get_anthias_product_token() + # Boards with ~1 GB RAM (Pi 3, plus 1 GB Pi 4 / Pi 5 SKUs) have no + # swap head-room, so QtWebEngine's default multi-process Chromium — + # tuned for desktops — slowly grows past what the board can hold when + # a web-page asset stays up or cycles for hours (forum #6731: black + # screen / thrash after long uptime). Chromium only auto-enables its + # low-memory profile below ~512 MB, so a ~1 GB board never gets it. + # Force it on, cap the per-renderer V8 old-space heap, and collapse to + # a single renderer to bound steady-state footprint. Reuse the shared + # ``is_low_ram_device`` gate (anthias_common.board) that already drives + # the video 1080p cap and the system-info Low-RAM badge, so the viewer, + # asset processor and UI all agree on which boards are constrained + # rather than each hard-coding a threshold. Each flag is added only + # when its switch isn't already present, so an inherited value on a + # respawn isn't duplicated and a switch the device set on purpose + # (say a different --js-flags) is neither overridden nor doubled — + # Chromium keeps only the last occurrence of a switch. The ones we do + # add are prepended, so the device's own flags and the dark-mode + # --blink-settings switch main.cpp appends still take effect. + if is_low_ram_device(): + # (switch key, full flag). The key identifies an already-present + # switch: the ``--name=`` prefix for a valued flag, the whole + # token for a bare one. + low_mem_flags = ( + ('--enable-low-end-device-mode', '--enable-low-end-device-mode'), + ('--js-flags=', '--js-flags=--max-old-space-size=64'), + ('--renderer-process-limit=', '--renderer-process-limit=1'), + ('--process-per-site', '--process-per-site'), + ('--disable-dev-shm-usage', '--disable-dev-shm-usage'), + ) + existing = env.get('QTWEBENGINE_CHROMIUM_FLAGS', '') + existing_tokens = existing.split() + to_add = [ + flag + for key, flag in low_mem_flags + if not any( + token == key or token.startswith(key) + for token in existing_tokens + ) + ] + if to_add: + env['QTWEBENGINE_CHROMIUM_FLAGS'] = ( + f'{" ".join(to_add)} {existing}'.strip() + ) + # "Prefer dark mode" applies to every board (the C++ webview turns # this into a Chromium blink flag at launch — see applyDarkModePreference # in src/anthias_webview/src/main.cpp), so set it before the diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 32fc4f170..a992e145d 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1615,6 +1615,113 @@ def test_build_webview_env_overwrites_stale_ua_token() -> None: assert env['ANTHIAS_UA_TOKEN'] == 'Anthias/1.2.3' +# Low-memory Chromium profile — ~1 GB boards (Pi 3, plus 1 GB Pi 4 / Pi 5 +# SKUs) with no swap head-room slowly grow past their RAM under a +# long-lived / cycling web-page asset (forum #6731). Chromium only +# auto-enables its low-memory profile below ~512 MB, so _build_webview_env +# forces it on for boards the shared is_low_ram_device() gate flags — the +# same gate that drives the video 1080p cap and the system-info badge. +# HW-validated on a real Pi 3B+: −78 MB (−35 %) steady-state RSS. + +_LOW_MEMORY_FLAGS = '--enable-low-end-device-mode' + + +def test_build_webview_env_injects_low_memory_flags_on_low_ram_board() -> None: + with ( + mock.patch.dict( + os.environ, {'QT_QPA_PLATFORM': 'linuxfb'}, clear=False + ), + mock.patch('anthias_viewer.is_low_ram_device', return_value=True), + ): + env = viewer._build_webview_env() + flags = env['QTWEBENGINE_CHROMIUM_FLAGS'] + assert _LOW_MEMORY_FLAGS in flags + assert '--js-flags=--max-old-space-size=64' in flags + assert '--renderer-process-limit=1' in flags + assert '--process-per-site' in flags + assert '--disable-dev-shm-usage' in flags + + +def test_build_webview_env_skips_low_memory_flags_on_roomy_board() -> None: + with ( + mock.patch.dict( + os.environ, {'QT_QPA_PLATFORM': 'linuxfb'}, clear=False + ), + mock.patch('anthias_viewer.is_low_ram_device', return_value=False), + ): + env = viewer._build_webview_env() + assert _LOW_MEMORY_FLAGS not in env.get('QTWEBENGINE_CHROMIUM_FLAGS', '') + + +def test_build_webview_env_low_memory_flags_prepend_and_idempotent() -> None: + # A device-supplied QTWEBENGINE_CHROMIUM_FLAGS must survive (prepended, + # not clobbered), and a value already carrying the low-memory switch + # (e.g. inherited on a respawn) must not stack a duplicate. + with ( + mock.patch.dict( + os.environ, + { + 'QT_QPA_PLATFORM': 'linuxfb', + 'QTWEBENGINE_CHROMIUM_FLAGS': '--custom-device-flag', + }, + clear=False, + ), + mock.patch('anthias_viewer.is_low_ram_device', return_value=True), + ): + first = viewer._build_webview_env() + # Feed the composed value back in to emulate an inherited respawn. + with mock.patch.dict( + os.environ, + { + 'QTWEBENGINE_CHROMIUM_FLAGS': first[ + 'QTWEBENGINE_CHROMIUM_FLAGS' + ] + }, + clear=False, + ): + second = viewer._build_webview_env() + flags = first['QTWEBENGINE_CHROMIUM_FLAGS'] + assert '--custom-device-flag' in flags + assert flags.count(_LOW_MEMORY_FLAGS) == 1 + # Prepended, not appended: the injected switches precede the + # device-supplied flag (Chromium keeps the last occurrence, but we + # want ours ahead of the operator's so theirs can still override). + assert flags.index(_LOW_MEMORY_FLAGS) < flags.index('--custom-device-flag') + assert second['QTWEBENGINE_CHROMIUM_FLAGS'].count(_LOW_MEMORY_FLAGS) == 1 + + +def test_build_webview_env_low_memory_respects_preset_switches() -> None: + # A device that already set some of the switches (here a *different* + # --js-flags value, plus the bare low-end switch) must keep its own: + # we add only the still-missing flags, never a second occurrence of a + # switch Chromium would then resolve to the wrong value. + with ( + mock.patch.dict( + os.environ, + { + 'QT_QPA_PLATFORM': 'linuxfb', + 'QTWEBENGINE_CHROMIUM_FLAGS': ( + '--enable-low-end-device-mode ' + '--js-flags=--max-old-space-size=256' + ), + }, + clear=False, + ), + mock.patch('anthias_viewer.is_low_ram_device', return_value=True), + ): + env = viewer._build_webview_env() + flags = env['QTWEBENGINE_CHROMIUM_FLAGS'] + # Device's own values are untouched — no duplicate switch added. + assert flags.count('--js-flags=') == 1 + assert '--max-old-space-size=256' in flags + assert '--max-old-space-size=64' not in flags + assert flags.count(_LOW_MEMORY_FLAGS) == 1 + # The genuinely-missing caps are still injected. + assert '--renderer-process-limit=1' in flags + assert '--process-per-site' in flags + assert '--disable-dev-shm-usage' in flags + + def test_handle_reload_queues_bounce_on_dark_mode_change( reset_dark_mode_state: None, ) -> None: