fix(update): stop a timed-out git from leaving .git/index.lock behind (#802)

* 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>
This commit is contained in:
Chuck
2026-10-29 14:13:09 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent fa51ebab5f
commit 237d611b89
8 changed files with 284 additions and 37 deletions
+52 -20
View File
@@ -14,6 +14,7 @@ from web_interface.blueprints.api_v3 import (
)
import threading
from web_interface import git_index_lock
from web_interface.cache import get_cached, set_cached
from web_interface.system_metrics import collect_system_metrics, format_uptime
import web_interface.blueprints.api_v3 as _pkg
@@ -163,6 +164,7 @@ def check_for_update():
['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE,
update_channel.BETA_BRANCH],
capture_output=True, timeout=10, cwd=cwd,
env=git_index_lock.read_only_env(),
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
@@ -332,7 +334,7 @@ def channel_status(project_dir=None, fetch=True):
def _perform_core_update_locked(stash_local_changes=True):
project_dir = str(PROJECT_ROOT)
from web_interface import update_channel, git_index_lock
from web_interface import update_channel
# A git killed mid-command (a timed-out pull, a power cut) leaves
# .git/index.lock behind, and every later update then fails with
@@ -343,6 +345,28 @@ def _perform_core_update_locked(stash_local_changes=True):
'message': 'Another git command is running in the LEDMatrix folder; '
'try again in a minute.'}
# A pull killed mid-rebase leaves .git/rebase-merge behind, and every
# later `pull --rebase` then fails. Same rule as the automatic update's
# preflight: a rebase really in progress leaves HEAD detached and holds
# the user's conflict work, so leave it; with HEAD back on a branch it was
# abandoned, and `--quit` drops its state without touching the tree.
git_dir = Path(project_dir) / '.git'
rebase_dirs = [git_dir / name for name in ('rebase-merge', 'rebase-apply') if (git_dir / name).exists()]
if rebase_dirs:
def _git(*args):
return git_index_lock.run_git(['git', *args], capture_output=True, text=True,
timeout=60, cwd=project_dir)
if _git('symbolic-ref', '-q', 'HEAD').returncode != 0:
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
'message': 'A git rebase is in progress in the LEDMatrix folder. '
'Finish or abort it (git rebase --continue / --abort), then update again.'}
cleared = _git('rebase', '--quit')
if cleared.returncode != 0 or any(path.exists() for path in rebase_dirs):
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
'message': 'An abandoned git rebase in the LEDMatrix folder could not be cleared: '
f'{(cleared.stderr or "").strip() or "unknown error"}.'}
logger.warning("Cleared an abandoned git rebase from %s", ', '.join(str(p) for p in rebase_dirs))
# Which code to move to: the newest release (stable) or main (beta).
# See web_interface/update_channel.py; it never picks an older commit.
channel, fetch_error = channel_status(project_dir)
@@ -434,25 +458,33 @@ def _perform_core_update_locked(stash_local_changes=True):
# Stable checks out the release tag, carrying edits across the way the
# pull's --autostash does.
channel_note = ''
if action == update_channel.ACTION_CHECKOUT_TAG:
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
elif action == update_channel.ACTION_SWITCH_TO_BETA:
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
if result.returncode == 0:
result = subprocess.run(['git', 'pull', '--rebase', '--autostash'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
else:
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
if channel.waiting and channel.newest_release:
channel_note = channel.message
try:
if action == update_channel.ACTION_CHECKOUT_TAG:
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
elif action == update_channel.ACTION_SWITCH_TO_BETA:
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
if result.returncode == 0:
result = git_index_lock.run_git(['git', 'pull', '--rebase', '--autostash'],
capture_output=True, text=True,
timeout=git_index_lock.MUTATING_TIMEOUT_SECONDS, cwd=project_dir)
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
else:
result = git_index_lock.run_git(
pull_args,
capture_output=True,
text=True,
timeout=git_index_lock.MUTATING_TIMEOUT_SECONDS,
cwd=project_dir
)
if channel.waiting and channel.newest_release:
channel_note = channel.message
except subprocess.TimeoutExpired:
# run_git has stopped git and removed the lock it left.
logger.warning("git timed out during Update Code")
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
'message': 'Update failed: git took too long (a slow network or SD card). '
'The partial update was cleaned up; try again.'}
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.