mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-20 18:09:05 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8591e4ac |
@@ -8,18 +8,6 @@ Type=simple
|
|||||||
User=root
|
User=root
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
# glibc gives each allocating thread its own malloc arena, up to 8 x CPU count,
|
|
||||||
# and an arena that has grown is never handed back to the OS. This process runs
|
|
||||||
# 9 threads on a 3-core Pi, so the ceiling is 24 arenas -- and a rig measured at
|
|
||||||
# 1030 MB resident held 23 large anonymous mappings on 64 MB-aligned addresses,
|
|
||||||
# 920 MB of them, while the live data it was actually holding (widest scroll
|
|
||||||
# strip seen: 35,746 x 64) accounts for roughly 15 MB. That gap is arena bloat,
|
|
||||||
# not leaked objects: RSS was flat across repeated sampling, not climbing.
|
|
||||||
#
|
|
||||||
# Capping the arenas trades a little allocator concurrency for a large amount of
|
|
||||||
# resident memory on a device that has neither to spare. 2 is the usual value;
|
|
||||||
# raise it if frame times regress.
|
|
||||||
Environment=MALLOC_ARENA_MAX=2
|
|
||||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/run.py
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/run.py
|
||||||
# Restart=always, not on-failure: run.py exiting 0 (a clean shutdown path taken
|
# Restart=always, not on-failure: run.py exiting 0 (a clean shutdown path taken
|
||||||
# for a reason that no longer applies, e.g. a config reload) would otherwise leave
|
# for a reason that no longer applies, e.g. a config reload) would otherwise leave
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"""The display unit must cap glibc's malloc arenas.
|
|
||||||
|
|
||||||
glibc hands each allocating thread its own malloc arena, up to 8 x CPU count,
|
|
||||||
and an arena that has grown is never returned to the OS. This process runs
|
|
||||||
threads for the render loop, the update workers and the background fetchers, so
|
|
||||||
on a 3-core Pi the ceiling is 24 arenas.
|
|
||||||
|
|
||||||
Measured on a live rig, 2.5 hours in:
|
|
||||||
|
|
||||||
RSS 1030 MB
|
|
||||||
Private_Dirty 988 MB
|
|
||||||
anonymous mappings > 10 MB 23 (ceiling is 8 x 3 = 24)
|
|
||||||
largest few 104, 79, 66, 63, 63 MB, on 64 MB-aligned addresses
|
|
||||||
|
|
||||||
against live data that accounts for perhaps 15 MB -- the widest scroll strip
|
|
||||||
observed was 35,746 x 64, about 7 MB as RGB and the same again for its numpy
|
|
||||||
mirror. Repeated sampling showed RSS flat between 990 and 1030 MB rather than
|
|
||||||
climbing, so this is arena bloat rather than a leak: memory Python has freed
|
|
||||||
but glibc is holding per-arena.
|
|
||||||
|
|
||||||
The device had 59 MB free at the time.
|
|
||||||
|
|
||||||
Capping the arena count trades a little allocator concurrency for that resident
|
|
||||||
memory. The render loop is latency-sensitive, so if p99 frame time regresses the
|
|
||||||
right response is to raise this rather than remove it.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
UNIT = (Path(__file__).resolve().parent.parent / "systemd" / "ledmatrix.service")
|
|
||||||
|
|
||||||
#: The value the unit is expected to carry. 2 is the usual choice for a
|
|
||||||
#: threaded Python process; 1-4 all keep some of the saving, but only one of
|
|
||||||
#: them is what this project ships.
|
|
||||||
EXPECTED_ARENA_MAX = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _environment(unit_text):
|
|
||||||
return dict(
|
|
||||||
line.split("=", 2)[1:3] if line.count("=") >= 2 else (line.split("=", 1)[1], "")
|
|
||||||
for line in unit_text.splitlines()
|
|
||||||
if line.startswith("Environment=")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_unit_exists():
|
|
||||||
assert UNIT.is_file(), f"{UNIT} is missing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_malloc_arena_max_is_capped():
|
|
||||||
env = _environment(UNIT.read_text(encoding="utf-8"))
|
|
||||||
assert "MALLOC_ARENA_MAX" in env, (
|
|
||||||
"the display unit does not cap glibc arenas; on a 3-core Pi the default "
|
|
||||||
"ceiling is 24 and a measured rig held 23 of them, 920 MB"
|
|
||||||
)
|
|
||||||
value = int(env["MALLOC_ARENA_MAX"])
|
|
||||||
# Pinned, not a range. A range let a change to 4 -- which hands most of the
|
|
||||||
# saving back -- pass unnoticed, which was the point of the finding that
|
|
||||||
# prompted this. Raising it is a legitimate response to a frame-time
|
|
||||||
# regression, but it should be a visible edit here rather than a silent
|
|
||||||
# drift, so the number lives in one place and changing it shows up in
|
|
||||||
# review.
|
|
||||||
assert value == EXPECTED_ARENA_MAX, (
|
|
||||||
f"MALLOC_ARENA_MAX={value}, expected {EXPECTED_ARENA_MAX}. If this was "
|
|
||||||
"raised deliberately because frame times regressed, update "
|
|
||||||
"EXPECTED_ARENA_MAX here and say so in the commit."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_reason_is_recorded_next_to_it():
|
|
||||||
"""A bare tuning knob invites removal by whoever meets it next."""
|
|
||||||
text = UNIT.read_text(encoding="utf-8")
|
|
||||||
index = text.index("Environment=MALLOC_ARENA_MAX")
|
|
||||||
preamble = text[:index].splitlines()[-12:]
|
|
||||||
comment = "\n".join(line for line in preamble if line.startswith("#"))
|
|
||||||
assert "arena" in comment.lower(), "no explanation precedes the setting"
|
|
||||||
assert re.search(r"\d", comment), (
|
|
||||||
"the explanation cites no measurement, so a reader cannot tell whether "
|
|
||||||
"it still applies to their hardware"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("unit", ["ledmatrix.service"])
|
|
||||||
def test_the_unit_still_parses_as_ini(unit):
|
|
||||||
"""systemd will refuse a malformed unit, and the panel stays dark."""
|
|
||||||
import configparser
|
|
||||||
|
|
||||||
path = UNIT.parent / unit
|
|
||||||
parser = configparser.ConfigParser(strict=False)
|
|
||||||
# systemd allows repeated keys; ConfigParser needs them merged, not rejected.
|
|
||||||
parser.read_string(path.read_text(encoding="utf-8"))
|
|
||||||
assert parser.has_section("Service")
|
|
||||||
assert parser.has_option("Service", "ExecStart")
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""A check that could not run must not be reported as "up to date".
|
||||||
|
|
||||||
|
check-update returned update_available=False whenever git failed. The banner
|
||||||
|
is the only route to the update button, so a checkout git refuses to touch
|
||||||
|
looked exactly like a current one -- permanently, and with nothing for the
|
||||||
|
user to act on. The usual cause is an install performed as root, after which
|
||||||
|
every git command fails with "detected dubious ownership".
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
|
||||||
|
DUBIOUS = ("fatal: detected dubious ownership in repository at "
|
||||||
|
"'/home/pi/LEDMatrix'\nTo add an exception for this directory, call:\n"
|
||||||
|
"\tgit config --global --add safe.directory /home/pi/LEDMatrix\n")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
mod._update_check_cache['result'] = None
|
||||||
|
mod._update_check_cache['ts'] = 0
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fails(stderr: bytes):
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
if args[:2] == ['git', 'fetch']:
|
||||||
|
return subprocess.CompletedProcess(args, 1, stdout=b'', stderr=stderr)
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
||||||
|
return fake_run
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailedCheckIsNotSilence:
|
||||||
|
def test_dubious_ownership_is_reported_not_swallowed(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['check_failed'] is True, (
|
||||||
|
"a git failure was reported as a successful 'no update' check")
|
||||||
|
assert data['update_available'] is False
|
||||||
|
|
||||||
|
def test_the_message_tells_the_user_what_to_do(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run', _fetch_fails(DUBIOUS.encode())):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert 'chown' in data['error'], (
|
||||||
|
"dubious ownership is unactionable without the fix command")
|
||||||
|
assert 'root' in data['error']
|
||||||
|
|
||||||
|
def test_an_ordinary_git_failure_still_surfaces(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_fetch_fails(b'fatal: some other git problem\n')):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['check_failed'] is True
|
||||||
|
assert 'some other git problem' in data['error']
|
||||||
|
|
||||||
|
def test_offline_reads_as_offline(self, client):
|
||||||
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_fetch_fails(b'fatal: could not resolve host: github.com\n')):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert 'Could not reach GitHub' in data['error']
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuccessPathUnchanged:
|
||||||
|
def test_up_to_date_carries_no_failure_flag(self, client):
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
if args[:2] == ['git', 'fetch']:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout=b'', stderr=b'')
|
||||||
|
if args[:2] == ['git', 'rev-parse']:
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='abc123\n', stderr='')
|
||||||
|
return subprocess.CompletedProcess(args, 0, stdout='0\n', stderr='')
|
||||||
|
with patch.object(mod.subprocess, 'run', fake_run):
|
||||||
|
data = client.get('/api/v3/system/check-update').get_json()
|
||||||
|
assert data['update_available'] is False
|
||||||
|
assert not data.get('check_failed'), "a healthy check must not look like a failure"
|
||||||
@@ -1821,6 +1821,33 @@ def get_system_version():
|
|||||||
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
||||||
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
||||||
|
|
||||||
|
def _update_check_failed(detail: str) -> Dict[str, Any]:
|
||||||
|
"""A check that could not run is not the same as being up to date.
|
||||||
|
|
||||||
|
Reporting update_available=False on a git failure hides the banner, and
|
||||||
|
the banner is the only route to the update button -- so a checkout git
|
||||||
|
refuses to touch looks exactly like a current one, permanently. The most
|
||||||
|
common cause is an install performed as root: git then reports "dubious
|
||||||
|
ownership" and every command fails, including the fetch here.
|
||||||
|
"""
|
||||||
|
return {'update_available': False, 'remote_sha': 'unknown',
|
||||||
|
'commits_behind': 0, 'check_failed': True, 'error': detail}
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_git_failure(stderr: str) -> str:
|
||||||
|
"""Turn git's stderr into something the user can act on."""
|
||||||
|
text = (stderr or '').strip()
|
||||||
|
if 'dubious ownership' in text or 'detected dubious ownership' in text:
|
||||||
|
return ("This checkout is owned by a different user than the one "
|
||||||
|
"running the web interface, so git refuses to use it. It is "
|
||||||
|
"usually the result of installing as root. Fix the ownership "
|
||||||
|
"and the update will work: sudo chown -R $USER:$USER "
|
||||||
|
+ str(PROJECT_ROOT))
|
||||||
|
if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower():
|
||||||
|
return "Could not reach GitHub to check for updates."
|
||||||
|
return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed")
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/system/check-update', methods=['GET'])
|
@api_v3.route('/system/check-update', methods=['GET'])
|
||||||
def check_for_update():
|
def check_for_update():
|
||||||
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||||
@@ -1836,12 +1863,13 @@ def check_for_update():
|
|||||||
capture_output=True, timeout=10, cwd=cwd,
|
capture_output=True, timeout=10, cwd=cwd,
|
||||||
)
|
)
|
||||||
if fetch_result.returncode != 0:
|
if fetch_result.returncode != 0:
|
||||||
|
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
||||||
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||||
fetch_result.returncode,
|
fetch_result.returncode, stderr)
|
||||||
fetch_result.stderr.decode(errors='replace').strip())
|
failed = _update_check_failed(_describe_git_failure(stderr))
|
||||||
_update_check_cache['result'] = _safe
|
_update_check_cache['result'] = failed
|
||||||
_update_check_cache['ts'] = now
|
_update_check_cache['ts'] = now
|
||||||
return jsonify(_safe)
|
return jsonify(failed)
|
||||||
local = subprocess.run(
|
local = subprocess.run(
|
||||||
['git', 'rev-parse', 'HEAD'],
|
['git', 'rev-parse', 'HEAD'],
|
||||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
@@ -1869,7 +1897,8 @@ def check_for_update():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("check-update failed: %s", e)
|
logger.warning("check-update failed: %s", e)
|
||||||
return jsonify(_safe)
|
return jsonify(_update_check_failed(
|
||||||
|
"Could not check for updates; see logs for details."))
|
||||||
|
|
||||||
@api_v3.route('/system/action', methods=['POST'])
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
|
|||||||
@@ -1107,15 +1107,29 @@
|
|||||||
fetch('/api/v3/system/check-update')
|
fetch('/api/v3/system/check-update')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
var banner = document.getElementById('update-banner');
|
||||||
|
var btn = document.getElementById('update-banner-btn');
|
||||||
|
if (data.check_failed) {
|
||||||
|
// A check that could not run is not the same as being up
|
||||||
|
// to date. Hiding the banner here made a checkout git
|
||||||
|
// refuses to touch look permanently current, with no
|
||||||
|
// route to the update button and nothing to act on.
|
||||||
|
document.getElementById('update-banner-text').textContent =
|
||||||
|
data.error || 'Could not check for updates.';
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
banner.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||||||
var n = data.commits_behind || 0;
|
var n = data.commits_behind || 0;
|
||||||
var msg = 'A new LEDMatrix update is available';
|
var msg = 'A new LEDMatrix update is available';
|
||||||
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||||
document.getElementById('update-banner-text').textContent = msg;
|
document.getElementById('update-banner-text').textContent = msg;
|
||||||
document.getElementById('update-banner').style.display = '';
|
banner.style.display = '';
|
||||||
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('update-banner').style.display = 'none';
|
banner.style.display = 'none';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
|
|||||||
Reference in New Issue
Block a user