From 61307ba7b1ade4e4cb8cc505047eab0581fa9d74 Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Thu, 9 Jul 2026 17:30:45 +0000 Subject: [PATCH 1/3] fix(viewer): force Chromium's low-memory profile on ~1 GB boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ~1 GB board with no swap head-room (Raspberry Pi 3, and 1 GB Pi 4 / Pi 5 SKUs) slowly grows past its RAM under a web-page asset that stays up or cycles for hours, ending in a black screen / swap thrash (forum report: a Pi 3B+ rendered unusable, load ~9, after long uptime). QtWebEngine ships Chromium's desktop-tuned, multi-process memory model and only auto-enables its low-memory profile below ~512 MB, so a ~1 GB board never gets it. Force it on and bound the per-renderer footprint by prepending to QTWEBENGINE_CHROMIUM_FLAGS in _build_webview_env: --enable-low-end-device-mode --js-flags=--max-old-space-size=64 --renderer-process-limit=1 --process-per-site --disable-dev-shm-usage Gate on measured RAM (MemTotal from /proc/meminfo, shared with the host) rather than a board/DEVICE_TYPE allowlist, so every low-memory SKU is covered — including the 1 GB Qt5 armhf boards — and 2 GB+ boards keep the faster default process model. The switches are prepended so a device-supplied QTWEBENGINE_CHROMIUM_FLAGS and the dark-mode --blink-settings switch main.cpp appends still apply, and guarded on the flag string so an inherited value on a respawn can't stack duplicates. Hardware-validated on a real Pi 3B+ (806 MB RAM, no swap), 10-min A/B soak with two web pages cycling every 20 s: steady-state viewer-container RSS dropped from ~225 MB to ~147 MB (-78 MB / -35 %), the AnthiasViewer process from ~78 MB to ~59 MB, with ~20 MB more MemAvailable and no crashes. On a 787 MB no-swap board that margin is the difference between staying alive and OOM under the same workload. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/__init__.py | 57 +++++++++++++++++++++ tests/test_viewer.py | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/anthias_viewer/__init__.py b/src/anthias_viewer/__init__.py index abfd4024c..160158912 100644 --- a/src/anthias_viewer/__init__.py +++ b/src/anthias_viewer/__init__.py @@ -214,6 +214,36 @@ def _set_qpa_rotation(qpa: str, rotation: int) -> str: return f'{plugin}:{",".join(options)}' +# Threshold below which a board is treated as memory-constrained and gets +# the low-memory Chromium profile in _build_webview_env. ~1.5 GiB sits +# above every 1 GB SKU's reported MemTotal (a 1 GB board reports well +# under 1 GiB after the GPU carve-out and kernel reserve) and safely below +# 2 GB boards (~1.9 GiB), so it cleanly separates the two. +_LOW_MEMORY_THRESHOLD_KB = 1_536_000 + + +def _system_memory_kb() -> int: + """Total system RAM in kB from /proc/meminfo (0 if unreadable). + + Read from the host-shared /proc/meminfo — the viewer container runs + without a memory cgroup cap, so this reflects the board's real RAM. + """ + try: + with open('/proc/meminfo') as meminfo: + for line in meminfo: + if line.startswith('MemTotal:'): + return int(line.split()[1]) + except (OSError, ValueError): + pass + return 0 + + +def _is_low_memory_board() -> bool: + """True on ~1 GB boards that need Chromium's low-memory profile.""" + mem_kb = _system_memory_kb() + return 0 < mem_kb < _LOW_MEMORY_THRESHOLD_KB + + def _build_webview_env() -> dict[str, str]: """Compose the env to pass when spawning AnthiasViewer. @@ -249,6 +279,33 @@ 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. Gate on measured + # RAM, not a board allowlist, so every low-memory SKU is covered and + # the roomier boards keep the faster default process model. Prepended + # so a device-supplied QTWEBENGINE_CHROMIUM_FLAGS and the dark-mode + # --blink-settings switch main.cpp appends still apply; guarded so + # respawns / an inherited value don't stack duplicates. + if _is_low_memory_board(): + low_mem_flags = ( + '--enable-low-end-device-mode ' + '--js-flags=--max-old-space-size=64 ' + '--renderer-process-limit=1 ' + '--process-per-site ' + '--disable-dev-shm-usage' + ) + existing = env.get('QTWEBENGINE_CHROMIUM_FLAGS', '') + if '--enable-low-end-device-mode' not in existing: + env['QTWEBENGINE_CHROMIUM_FLAGS'] = ( + f'{low_mem_flags} {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..89a0633fd 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1615,6 +1615,96 @@ 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 under _LOW_MEMORY_THRESHOLD_KB. HW-validated on a +# real Pi 3B+: −78 MB (−35 %) steady-state viewer-container RSS. + +_LOW_MEMORY_FLAGS = '--enable-low-end-device-mode' + + +def test_is_low_memory_board_true_below_threshold() -> None: + # 806 MB — a 1 GB Raspberry Pi 3 after the GPU carve-out. + with mock.patch('anthias_viewer._system_memory_kb', return_value=806228): + assert viewer._is_low_memory_board() is True + + +def test_is_low_memory_board_false_on_2gb_board() -> None: + with mock.patch( + 'anthias_viewer._system_memory_kb', return_value=1_900_000 + ): + assert viewer._is_low_memory_board() is False + + +def test_is_low_memory_board_false_when_meminfo_unreadable() -> None: + # _system_memory_kb returns 0 on a read/parse failure; the guard + # (0 < mem) then fails closed so a roomy board is never mislabelled. + with mock.patch('anthias_viewer._system_memory_kb', return_value=0): + assert viewer._is_low_memory_board() is False + + +def test_build_webview_env_injects_low_memory_flags_on_1gb_board() -> None: + with ( + mock.patch.dict( + os.environ, {'QT_QPA_PLATFORM': 'linuxfb'}, clear=False + ), + mock.patch('anthias_viewer._system_memory_kb', return_value=806228), + ): + 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._system_memory_kb', return_value=1_900_000), + ): + 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._system_memory_kb', return_value=806228), + ): + 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 + assert second['QTWEBENGINE_CHROMIUM_FLAGS'].count(_LOW_MEMORY_FLAGS) == 1 + + def test_handle_reload_queues_bounce_on_dark_mode_change( reset_dark_mode_state: None, ) -> None: From 013ccc89ede4af7964d06a96ddd691d6de69d3bc Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Thu, 9 Jul 2026 17:38:07 +0000 Subject: [PATCH 2/3] refactor(viewer): gate the low-memory profile on the shared is_low_ram_device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse anthias_common.board.is_low_ram_device / LOW_RAM_THRESHOLD_KB — the canonical ~1 GB gate that already drives the video 1080p upload cap and the system-info Low-RAM badge — instead of a second /proc/meminfo reader with its own threshold. One source of truth: the viewer, asset processor and UI now agree on which boards are constrained, and the gate reads the host_agent-published MemTotal from Redis so server and viewer can't drift. Also correct the system-info Low-RAM badge: single-QWebEngineView is unconditional now (the preloaded dual-buffer was removed for issue #2954), so the badge described a mode every board runs. It now lists the actual RAM-gated behaviours — the Chromium low-memory profile for web pages and the 1080p video upload cap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/templates/system_info.html | 4 +- src/anthias_viewer/__init__.py | 43 ++++--------------- tests/test_viewer.py | 33 +++----------- 3 files changed, 17 insertions(+), 63 deletions(-) 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 160158912..3eefd8e05 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 @@ -214,36 +215,6 @@ def _set_qpa_rotation(qpa: str, rotation: int) -> str: return f'{plugin}:{",".join(options)}' -# Threshold below which a board is treated as memory-constrained and gets -# the low-memory Chromium profile in _build_webview_env. ~1.5 GiB sits -# above every 1 GB SKU's reported MemTotal (a 1 GB board reports well -# under 1 GiB after the GPU carve-out and kernel reserve) and safely below -# 2 GB boards (~1.9 GiB), so it cleanly separates the two. -_LOW_MEMORY_THRESHOLD_KB = 1_536_000 - - -def _system_memory_kb() -> int: - """Total system RAM in kB from /proc/meminfo (0 if unreadable). - - Read from the host-shared /proc/meminfo — the viewer container runs - without a memory cgroup cap, so this reflects the board's real RAM. - """ - try: - with open('/proc/meminfo') as meminfo: - for line in meminfo: - if line.startswith('MemTotal:'): - return int(line.split()[1]) - except (OSError, ValueError): - pass - return 0 - - -def _is_low_memory_board() -> bool: - """True on ~1 GB boards that need Chromium's low-memory profile.""" - mem_kb = _system_memory_kb() - return 0 < mem_kb < _LOW_MEMORY_THRESHOLD_KB - - def _build_webview_env() -> dict[str, str]: """Compose the env to pass when spawning AnthiasViewer. @@ -286,13 +257,15 @@ def _build_webview_env() -> dict[str, str]: # 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. Gate on measured - # RAM, not a board allowlist, so every low-memory SKU is covered and - # the roomier boards keep the faster default process model. Prepended - # so a device-supplied QTWEBENGINE_CHROMIUM_FLAGS and the dark-mode + # 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. Prepended so a + # device-supplied QTWEBENGINE_CHROMIUM_FLAGS and the dark-mode # --blink-settings switch main.cpp appends still apply; guarded so # respawns / an inherited value don't stack duplicates. - if _is_low_memory_board(): + if is_low_ram_device(): low_mem_flags = ( '--enable-low-end-device-mode ' '--js-flags=--max-old-space-size=64 ' diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 89a0633fd..6c0d371d4 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1619,38 +1619,19 @@ def test_build_webview_env_overwrites_stale_ua_token() -> None: # 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 under _LOW_MEMORY_THRESHOLD_KB. HW-validated on a -# real Pi 3B+: −78 MB (−35 %) steady-state viewer-container RSS. +# 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_is_low_memory_board_true_below_threshold() -> None: - # 806 MB — a 1 GB Raspberry Pi 3 after the GPU carve-out. - with mock.patch('anthias_viewer._system_memory_kb', return_value=806228): - assert viewer._is_low_memory_board() is True - - -def test_is_low_memory_board_false_on_2gb_board() -> None: - with mock.patch( - 'anthias_viewer._system_memory_kb', return_value=1_900_000 - ): - assert viewer._is_low_memory_board() is False - - -def test_is_low_memory_board_false_when_meminfo_unreadable() -> None: - # _system_memory_kb returns 0 on a read/parse failure; the guard - # (0 < mem) then fails closed so a roomy board is never mislabelled. - with mock.patch('anthias_viewer._system_memory_kb', return_value=0): - assert viewer._is_low_memory_board() is False - - -def test_build_webview_env_injects_low_memory_flags_on_1gb_board() -> None: +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._system_memory_kb', return_value=806228), + mock.patch('anthias_viewer.is_low_ram_device', return_value=True), ): env = viewer._build_webview_env() flags = env['QTWEBENGINE_CHROMIUM_FLAGS'] @@ -1666,7 +1647,7 @@ def test_build_webview_env_skips_low_memory_flags_on_roomy_board() -> None: mock.patch.dict( os.environ, {'QT_QPA_PLATFORM': 'linuxfb'}, clear=False ), - mock.patch('anthias_viewer._system_memory_kb', return_value=1_900_000), + 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', '') @@ -1685,7 +1666,7 @@ def test_build_webview_env_low_memory_flags_prepend_and_idempotent() -> None: }, clear=False, ), - mock.patch('anthias_viewer._system_memory_kb', return_value=806228), + 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. From 88afa0ec1bde2f2aa76a8d9d162a38172a2f28ea Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 10 Jul 2026 05:40:59 +0000 Subject: [PATCH 3/3] fix(viewer): inject each low-memory flag only when its switch is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the previous guard keyed only on --enable-low-end-device-mode, so a device that pre-set just that switch (without the renderer/V8 caps) would skip the rest, and a device that set a different --js-flags value would be silently overridden. Add each flag independently, matching a valued switch by its "--name=" prefix and a bare switch by the whole token, so an inherited value on a respawn isn't duplicated and a switch the operator set on purpose is preserved. Prepend order is unchanged. Also assert the prepend order in the test (injected flags precede the device-supplied one — an accidental append would otherwise still pass) and add a case for a device that pre-set some switches. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/__init__.py | 37 ++++++++++++++++++++++++---------- tests/test_viewer.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/anthias_viewer/__init__.py b/src/anthias_viewer/__init__.py index 3eefd8e05..9dc671756 100644 --- a/src/anthias_viewer/__init__.py +++ b/src/anthias_viewer/__init__.py @@ -261,22 +261,37 @@ def _build_webview_env() -> dict[str, str]: # ``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. Prepended so a - # device-supplied QTWEBENGINE_CHROMIUM_FLAGS and the dark-mode - # --blink-settings switch main.cpp appends still apply; guarded so - # respawns / an inherited value don't stack duplicates. + # 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 ' - '--js-flags=--max-old-space-size=64 ' - '--renderer-process-limit=1 ' - '--process-per-site ' - '--disable-dev-shm-usage' + ('--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', '') - if '--enable-low-end-device-mode' not in existing: + 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'{low_mem_flags} {existing}'.strip() + f'{" ".join(to_add)} {existing}'.strip() ) # "Prefer dark mode" applies to every board (the C++ webview turns diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 6c0d371d4..a992e145d 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1683,9 +1683,45 @@ def test_build_webview_env_low_memory_flags_prepend_and_idempotent() -> None: 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: