Files
Kiwy-Signage/linux/_probe_chromium_footprint.py
ske087 3ac7f836c4 Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on
Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows
code is removed here but preserved on the Windows-Player branch.

Entry point
-----------
linux/run_linux.py replaces windows/run_win.py. src/main.py stays
platform-neutral; all Pi-specific behaviour is injected from linux/.

Five bugs that prevented the port (all measured on real hardware)
----------------------------------------------------------------
1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so
   no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh
   symlinks the system SDL2 over the bundled filename.
2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough,
   unlike wlopm. This broke every systemd/cron/autostart launch.
   linux_display.ensure_session_environment() detects and exports it.
3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a
   different name crashed the player ~20s after a successful start.
4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and
   ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the
   display blanked after 10 minutes.
5. The launchers ran src/main.py directly, bypassing every platform patch and
   resolving the data directory one level too high.

Web links
---------
- --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it
  aborts. The platform is now chosen explicitly.
- The keyring password prompt is suppressed via the ENVIRONMENT, not the flags:
  launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot
  reach gnome-keyring-daemon.
- Teardown kills the whole process group (needs start_new_session=True);
  previously it silently fell back to terminate() and orphaned children.

Video normalisation
-------------------
A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at
0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at
sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip),
triggered by resolution only so already-playable files are untouched.

src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker
makes the player skip the item while it is being rebuilt, then the converted
file is played instead. If nothing is playable at all (a single-item playlist
whose only video is converting), the player loops the intro video rather than
leaving a blank screen.

Also fixed
----------
- network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses
  NetworkManager; ifconfig and dhclient are not even installed).
- Removed the Windows-only focus keeper/guardian from main.py.
- main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings
  "Test connection" now uses tempfile.gettempdir().
- config/app_config.json: credentials blanked so a fresh clone runs the
  first-run setup flow.

Verification
------------
linux/test_media_state.py 18/18, test_linux_patches.py 21/21,
test_linux_browser_flags.py 27/27. Verified live against a real DigiServer:
image -> weblink -> image -> video with correct durations, zero leaked Chromium
processes, and no throttling over a 10 minute monitored run.
2026-09-13 21:57:49 +03:00

127 lines
3.8 KiB
Python

"""_probe_chromium_footprint.py — measure Chromium's footprint for one page.
Compares the flag profiles so the memory trade-off is measured rather than
guessed. Run:
.venv/bin/python linux/_probe_chromium_footprint.py [url]
Reports process count and total resident memory for the browser tree, using the
same flags the player uses (so the numbers match production).
"""
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linux_display import ensure_session_environment # noqa: E402
ensure_session_environment()
import linux_browser # noqa: E402
URL = sys.argv[1] if len(sys.argv) > 1 else 'about:blank'
def tree_memory(root_pid):
"""(PSS kB, process count) for root_pid and its descendants.
PSS (proportional set size) from ``/proc/<pid>/smaps_rollup`` is the right
measure here, **not** RSS. Chromium forks many processes that share the same
libraries and file-backed pages; summing RSS counts every shared page once
per process, which inflated earlier measurements by roughly 2x and even made
a smaller configuration look larger.
"""
try:
out = subprocess.run(
['ps', '-eo', 'pid,ppid'], capture_output=True, text=True,
timeout=10, check=False,
).stdout
except Exception:
return 0, 0
parents = {}
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0].isdigit():
parents[int(parts[0])] = int(parts[1])
family = [root_pid]
changed = True
while changed:
changed = False
for pid, parent in parents.items():
if parent in family and pid not in family:
family.append(pid)
changed = True
total_kb = 0
for pid in family:
try:
with open(f'/proc/{pid}/smaps_rollup') as fh:
for line in fh:
if line.startswith('Pss:'):
total_kb += int(line.split()[1])
break
except OSError:
# Process exited between listing and reading.
continue
return total_kb, len(family)
def measure(mode):
os.environ['KIWY_CHROMIUM_MODE'] = mode
profile = f'/tmp/kiwy-footprint-{mode}'
subprocess.run(['rm', '-rf', profile], check=False)
adapter = linux_browser.LinuxChromiumAdapter(
browser_path=linux_browser.find_linux_browser(), kiosk=True,
)
adapter._profile_dir = profile
ok = adapter.launch(URL, 1280, 720)
if not ok or adapter._proc is None:
return None
try:
# Let Chromium finish spawning helpers before sampling.
time.sleep(8)
if adapter._proc.poll() is not None:
return {'crashed': True, 'rc': adapter._proc.returncode}
pss_kb, procs = tree_memory(adapter._proc.pid)
return {"pss_mb": pss_kb / 1024.0, "procs": procs}
finally:
adapter.teardown()
time.sleep(1.5)
print(f'url = {URL}\n')
print(f'{"mode":<10} {"procs":>6} {"PSS (MB)":>10}')
print('-' * 30)
results = {}
for mode in ('safe', 'light', 'minimal'):
result = measure(mode)
results[mode] = result
if result is None:
note = 'launch failed'
elif result.get('crashed'):
note = f'CRASHED rc={result["rc"]}'
else:
note = ''
if note:
print(f'{mode:<10} {"-":>6} {note:>10}')
else:
print(f'{mode:<10} {result["procs"]:>6} {result["pss_mb"]:>10.0f}')
print()
base = results.get('safe', {}) or {}
light = results.get('light', {}) or {}
if base.get('pss_mb') and light.get('pss_mb'):
saved = base['pss_mb'] - light['pss_mb']
pct = 100.0 * saved / base['pss_mb']
print(f'light mode saves {saved:.0f} MB ({pct:.0f}%) vs the default flag set')
print(f' processes: {base["procs"]} -> {light["procs"]}')