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: