Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/anthias_server/app/templates/system_info.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ <h2><i class="ti ti-activity mr-2"></i>Live diagnostics</h2>
{% if low_ram.active %}
<p class="stat-card__meta stat-card__meta--warn">
<i class="ti ti-alert-triangle mr-1"></i>
<strong>Low-RAM mode:</strong> single-QWebEngineView (no
preloaded crossfade), 1080p upload cap on video. Engages
<strong>Low-RAM mode:</strong> Chromium low-memory profile
for web pages, 1080p upload cap on video. Engages
below {{ low_ram.threshold_mib|intcomma }} MiB MemTotal.
</p>
{% endif %}
Expand Down
45 changes: 45 additions & 0 deletions src/anthias_viewer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions tests/test_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down