Files
LEDMatrix/test/test_install_lowmem.py
T
ChuckBuildsandClaude Opus 5 09123320bb test(install): stop assuming pytest's tmp_path is on disk
test_returns_nothing_when_tmpdir_is_already_disk_backed asserted that
lm_disk_backed_tmpdir prints nothing when TMPDIR is already disk-backed,
and used pytest's tmp_path as the "disk-backed" directory:

    # tmp_path is on the regular filesystem, so the default must be kept.
    assert call("lm_disk_backed_tmpdir", env={"TMPDIR": str(tmp_path)}) == ""

That premise is false on the platform the helper was written for. Debian
13 mounts /tmp as tmpfs -- which is the entire reason lm_disk_backed_tmpdir
exists -- and pytest puts tmp_path under /tmp. So on the target platform
TMPDIR is memory-backed, the helper correctly answers /var/tmp, and the
test fails:

    E  AssertionError: assert '/var/tmp' == ''

The helper is right; the test was wrong. Reproduced on a box where
/tmp is tmpfs and / is ext4.

The test now looks for a directory whose backing store is actually disk
-- tmp_path, else a scratch dir under /var/tmp, else beside the library
-- using the same findmnt lookup the helper itself uses, and skips only
if no disk-backed directory exists anywhere. An earlier version of this
fix skipped whenever tmp_path was tmpfs, which made it skip on every
machine with a tmpfs /tmp; that is barely better than asserting the
wrong thing, so it now searches instead of giving up.

Verified: 31 passed, 0 skipped. Mutation-checked -- deleting the
"is the current TMPDIR memory-backed?" guard from lm_disk_backed_tmpdir
fails this test, so it still catches the regression it is there for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 14:04:08 -04:00

242 lines
9.9 KiB
Python

"""
Tests for scripts/install/lib_lowmem.sh, the installer's low-memory helpers.
Background: the rgbmatrix build compiles ~45 C++ translation units, two of them
Cython-generated. Upstream's pyproject.toml sets no [tool.scikit-build] options,
so scikit-build-core drives Ninja at its default of nproc+2 jobs -- six
concurrent cc1plus on a 4-core Pi. On 512MB and 1GB models the OOM killer reaps
the compiler and pip reports only "Failed building wheel for rgbmatrix", which
the installer used to misreport as a missing-build-tools problem.
These cover the pure sizing/detection functions. The swap-management functions
need root and mutate the system, so they are exercised manually instead.
"""
import subprocess
import tempfile
from pathlib import Path
import pytest
LIB = Path(__file__).resolve().parent.parent / "scripts" / "install" / "lib_lowmem.sh"
def run_lib(snippet: str, env: dict | None = None) -> subprocess.CompletedProcess:
"""Source the helper library and run a snippet against it."""
script = f". {LIB}\n{snippet}"
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin:/usr/sbin:/sbin", **(env or {})},
)
def _fstype_of(path: object) -> str:
"""Filesystem type backing ``path``, via the same tool the helper uses."""
result = subprocess.run(
["findmnt", "-no", "FSTYPE", "--target", str(path)],
capture_output=True, text=True,
env={"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"},
)
return result.stdout.strip()
def call(fn: str, *args: object, env: dict | None = None) -> str:
joined = " ".join(str(a) for a in args)
result = run_lib(f"{fn} {joined}", env=env)
assert result.returncode == 0, f"{fn} failed: {result.stderr}"
return result.stdout.strip()
class TestLibraryLoads:
def test_library_exists_and_is_syntactically_valid(self):
assert LIB.is_file(), f"{LIB} is missing"
result = subprocess.run(["bash", "-n", str(LIB)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
def test_sourcing_is_safe_under_strict_mode(self):
# first_time_install.sh runs under `set -Eeuo pipefail` with an ERR
# trap, so sourcing must not trip either.
result = run_lib("set -Eeuo pipefail\ntrap 'exit 99' ERR\necho ok")
assert result.returncode == 0, result.stderr
assert "ok" in result.stdout
class TestBuildJobs:
@pytest.mark.parametrize(
"ram_mb,cores,expected",
[
(512, 4, 1), # Pi Zero 2 W - must serialize
(1024, 4, 1), # Pi 3B/3B+ - the device from the bug report
(2048, 4, 2),
(4096, 4, 4), # core-capped
(8192, 4, 4), # core-capped
(2048, 1, 1), # single-core machine
],
)
def test_jobs_scale_with_ram_and_cap_at_cores(self, ram_mb, cores, expected):
assert call("lm_build_jobs", ram_mb, cores) == str(expected)
def test_never_returns_zero_jobs(self):
assert call("lm_build_jobs", 0, 4) == "1"
def test_treats_zero_cores_as_one(self):
assert call("lm_build_jobs", 8192, 0) == "1"
class TestSwapSizing:
@pytest.mark.parametrize(
"ram_mb,existing_swap_mb,expected",
[
(512, 0, 2048), # capped at LM_SWAP_MAX_MB
(1024, 0, 2048), # matches the workaround the reporter found
(2048, 0, 1024),
(2048, 1024, 0), # existing swap already covers it
(4096, 0, 0), # untouched on machines that already work
(8192, 0, 0),
],
)
def test_swap_target_scales_with_ram(self, ram_mb, existing_swap_mb, expected):
assert call("lm_swap_needed_mb", ram_mb, existing_swap_mb) == str(expected)
def test_result_is_a_multiple_of_256mb(self):
# 3072 - 900 = 2172, which must round up rather than produce an odd size.
assert int(call("lm_swap_needed_mb", 900, 0)) % 256 == 0
class TestSwapDetection:
def test_zram_swap_is_excluded(self, tmp_path):
# zram swap is compressed RAM: counting it would let a zram-enabled
# image skip provisioning and then OOM exactly as before.
swaps = tmp_path / "swaps"
swaps.write_text(
"Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n"
"/dev/zram0 partition\t1048572\t\t0\t\t100\n"
"/var/swap file\t\t524284\t\t0\t\t-2\n"
)
assert call("lm_total_swap_mb", env={"LM_SWAPS": str(swaps)}) == "511"
def test_zram_only_system_reports_no_usable_swap(self, tmp_path):
swaps = tmp_path / "swaps"
swaps.write_text(
"Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n"
"/dev/zram0 partition\t1048572\t\t0\t\t100\n"
)
assert call("lm_total_swap_mb", env={"LM_SWAPS": str(swaps)}) == "0"
def test_ram_is_read_from_meminfo(self, tmp_path):
meminfo = tmp_path / "meminfo"
# A real Pi 3B+ reports this; 948204/1024 truncates to 925.
meminfo.write_text("MemTotal: 948204 kB\nMemFree: 123456 kB\n")
assert call("lm_total_ram_mb", env={"LM_MEMINFO": str(meminfo)}) == "925"
def test_missing_files_report_zero_rather_than_failing(self, tmp_path):
missing = str(tmp_path / "nope")
assert call("lm_total_ram_mb", env={"LM_MEMINFO": missing}) == "0"
assert call("lm_total_swap_mb", env={"LM_SWAPS": missing}) == "0"
class TestOomDetection:
"""The regression tests for the misdiagnosis in the bug report."""
def _check(self, tmp_path, build_log: str, kernel_log: str = "") -> bool:
build_file = tmp_path / "build.log"
build_file.write_text(build_log)
kernel_file = tmp_path / "kernel.log"
kernel_file.write_text(kernel_log)
result = run_lib(
f"lm_build_failed_on_oom {build_file}",
env={"LM_KERNEL_LOG_FILE": str(kernel_file)},
)
return result.returncode == 0
@pytest.mark.parametrize(
"line",
[
"c++: fatal error: Killed signal terminated program cc1plus",
"cc1plus: out of memory allocating 65536 bytes",
"virtual memory exhausted: Cannot allocate memory",
"error: command '/usr/bin/c++' died with signal 9",
],
)
def test_detects_compiler_reported_memory_failures(self, tmp_path, line):
log = f"[15/45] Building CXX object core.cpp.o\n{line}\nninja: build stopped.\n"
assert self._check(tmp_path, log) is True
def test_detects_oom_visible_only_in_the_kernel_log(self, tmp_path):
# The OOM killer writes nothing to the build's stdout. This silence is
# precisely why the old handler blamed missing build tools.
build_log = (
"[15/45] Building CXX object core.cpp.o\n"
"ninja: build stopped: subcommand failed.\n"
"ERROR: Failed building wheel for rgbmatrix\n"
)
kernel_log = (
"[12345.6] Out of memory: Killed process 4242 (cc1plus) "
"total-vm:812345kB, anon-rss:764000kB\n"
)
assert self._check(tmp_path, build_log, kernel_log) is True
def test_does_not_flag_a_genuine_missing_build_tool(self, tmp_path):
build_log = (
"CMake Error at CMakeLists.txt:12 (find_package):\n"
" Could NOT find Python (missing: Development.Module)\n"
"fatal error: Python.h: No such file or directory\n"
"ERROR: Failed building wheel for rgbmatrix\n"
)
assert self._check(tmp_path, build_log) is False
def test_does_not_flag_a_network_failure(self, tmp_path):
build_log = (
"WARNING: Retrying after connection broken by 'NewConnectionError'\n"
"ERROR: Could not install packages due to an OSError\n"
)
assert self._check(tmp_path, build_log) is False
def test_handles_a_missing_build_log(self, tmp_path):
kernel_file = tmp_path / "kernel.log"
kernel_file.write_text("")
result = run_lib(
f"lm_build_failed_on_oom {tmp_path / 'absent.log'}",
env={"LM_KERNEL_LOG_FILE": str(kernel_file)},
)
assert result.returncode == 1
class TestDiskBackedTmpdir:
def test_returns_nothing_when_tmpdir_is_already_disk_backed(self, tmp_path):
# Do not assume tmp_path is disk-backed. Debian 13 -- the platform this
# helper exists for -- mounts /tmp as tmpfs, and pytest puts tmp_path
# under /tmp, so this asserted against a *memory*-backed directory and
# failed on the target platform while the helper behaved exactly as
# designed. Search for a directory whose backing store is really disk.
scratch = None
disk_backed = None
for candidate in (tmp_path, Path("/var/tmp"), LIB.parent):
if _fstype_of(candidate) not in ("tmpfs", "ramfs", ""):
if candidate is tmp_path:
disk_backed = candidate
else:
scratch = Path(tempfile.mkdtemp(dir=str(candidate)))
disk_backed = scratch
break
if disk_backed is None:
pytest.skip("no disk-backed directory available to test against")
try:
assert call("lm_disk_backed_tmpdir",
env={"TMPDIR": str(disk_backed)}) == ""
finally:
if scratch is not None:
scratch.rmdir()
def test_redirects_away_from_a_memory_backed_tmpdir(self):
# Debian 13 mounts /tmp as tmpfs, which would otherwise hold the whole
# C++ build tree in RAM alongside the compiler.
shm = Path("/dev/shm")
if not shm.is_dir():
pytest.skip("/dev/shm not available")
result = run_lib("lm_disk_backed_tmpdir", env={"TMPDIR": str(shm)})
assert result.returncode == 0
assert result.stdout.strip() in ("", "/var/tmp")