mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-30 01:39:13 +00:00
* fix(update): stop a timed-out git from leaving .git/index.lock behind subprocess.run's timeout SIGKILLs only the direct git child, so the lock is left behind and git's helpers are orphaned. On a slow SD card the same pull then times out on every retry and recreates the lock, so removing it by hand does not help. - run_git starts git in its own session, stops the group with SIGTERM before SIGKILL, and clears the lock if nothing owns it. Update Code's pull, checkout, reset and stash go through it, with a 600s pull timeout. - Read-only calls (describe --dirty, status, the update-check fetch) set GIT_OPTIONAL_LOCKS=0 so a timed-out poll cannot take the lock. - A timed-out pull returns a clear error; a rebase left by an interrupted update is aborted before pulling. - The manual-fix hint says to stop the services and kill running git. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(update): address review of the index.lock fix - The automatic update's status check now sets GIT_OPTIONAL_LOCKS=0 whichever runner it is given, not only the default one. - Update Code no longer runs `git rebase --abort` on any rebase dir: a rebase in progress (detached HEAD) holds the user's conflict work and blocks the update; an abandoned one is dropped with `rebase --quit`, as the automatic update does, and a failure to clear it stops the update. - run_git waits out the lock-freshness window after stopping a timed-out git, so a lock created just before the timeout is not left behind. - Mark the Popen/subprocess use with the repo's nosec comments. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(update): mark run_git's CompletedProcess as a result object, not a subprocess call Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""A pull that changed nothing on the running system is not an applied update.
|
|
|
|
git_pull replaces files on disk and restarts nothing -- there is no systemctl
|
|
call anywhere in the handler. The display and web services keep running the
|
|
code they loaded at boot, so the user is told "Code updated successfully" and
|
|
sees no change until they happen to reboot. The response now says whether a
|
|
restart is owed, and the UI raises the existing restart-pending banner.
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
import contextlib
|
|
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
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _patched(fake):
|
|
"""Fake git for both the plain subprocess calls and the timeout-safe runner."""
|
|
with patch.object(mod.subprocess, 'run', fake), patch.object(mod.git_index_lock, 'run_git', fake):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
app = Flask(__name__)
|
|
app.config['TESTING'] = True
|
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
|
# The handler consults these after a successful pull; None is the
|
|
# "not wired up" case it already guards for.
|
|
api_v3.plugin_store_manager = None
|
|
api_v3.config_manager = None
|
|
return app.test_client()
|
|
|
|
|
|
def _git(heads, pull_rc=0, pull_out='Updating a1b2c3..d4e5f6\n'):
|
|
"""Fake git. `heads` are the successive answers to rev-parse HEAD."""
|
|
seq = list(heads)
|
|
|
|
def run(args, **kwargs):
|
|
def ok(stdout='', rc=0, b=False):
|
|
return subprocess.CompletedProcess(
|
|
args, rc, stdout=(stdout.encode() if b else stdout),
|
|
stderr=(b'' if b else ''))
|
|
if args[:2] == ['git', 'rev-parse'] and args[-1] == 'HEAD':
|
|
return ok(seq.pop(0) + '\n' if seq else 'deadbeef\n')
|
|
if 'symbolic-full-name' in args or '@{u}' in args:
|
|
return ok('origin/main\n')
|
|
if args[:2] == ['git', 'status']:
|
|
return ok('')
|
|
if args[:2] == ['git', 'diff']:
|
|
return ok('')
|
|
if args[:2] == ['git', 'pull']:
|
|
return ok(pull_out, pull_rc)
|
|
return ok('')
|
|
return run
|
|
|
|
|
|
def _pull(client):
|
|
return client.post('/api/v3/system/action',
|
|
json={'action': 'git_pull'}).get_json()
|
|
|
|
|
|
class TestRestartIsRequestedWhenCodeChanged:
|
|
def test_a_pull_that_moved_head_asks_for_a_restart(self, client):
|
|
with _patched(_git(['aaa111', 'bbb222'])):
|
|
data = _pull(client)
|
|
assert data['status'] == 'success'
|
|
assert data['restart_required'] is True, (
|
|
"new code on disk, services still running the old code, and "
|
|
"nothing told the user to restart")
|
|
|
|
def test_already_up_to_date_does_not(self, client):
|
|
with _patched(_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
|
data = _pull(client)
|
|
assert data['status'] == 'success'
|
|
assert data['restart_required'] is False, (
|
|
"prompting after a no-op update trains users to ignore the prompt")
|
|
|
|
def test_a_failed_pull_does_not(self, client):
|
|
with _patched(_git(['aaa111'], pull_rc=1)):
|
|
data = _pull(client)
|
|
assert data['status'] == 'error'
|
|
assert data['restart_required'] is False
|
|
|
|
|
|
class TestUnitsAreRefreshedWithTheCode:
|
|
"""New code may bring new systemd unit settings; installing them is part of the update."""
|
|
|
|
@pytest.fixture
|
|
def refresh(self, monkeypatch):
|
|
from web_interface import unit_refresh
|
|
calls = []
|
|
|
|
def fake():
|
|
calls.append(True)
|
|
return {'status': 'refreshed', 'message': 'Service settings updated (ledmatrix.service).',
|
|
'units': ['ledmatrix.service']}
|
|
monkeypatch.setattr(unit_refresh, 'refresh_after_update', fake)
|
|
return calls
|
|
|
|
def test_an_update_that_moved_head_refreshes_the_units(self, client, refresh):
|
|
with _patched(_git(['aaa111', 'bbb222'])):
|
|
data = _pull(client)
|
|
assert refresh == [True]
|
|
assert data['unit_refresh']['status'] == 'refreshed'
|
|
assert 'Service settings updated' in data['message']
|
|
|
|
def test_nothing_new_touches_no_units(self, client, refresh):
|
|
with _patched(_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
|
data = _pull(client)
|
|
assert refresh == [] and data['unit_refresh'] is None
|
|
|
|
def test_a_failed_pull_touches_no_units(self, client, refresh):
|
|
with _patched(_git(['aaa111'], pull_rc=1)):
|
|
data = _pull(client)
|
|
assert refresh == [] and data['unit_refresh'] is None
|