Make self-update backups skip runtime sockets

Treat live usr runtime artifacts as non-blocking during self-update backups. Skip sockets, device nodes, vanished files, and unreadable entries with log messages so update rollback checks are not tripped by active Desktop profile state.

Alessandro committed May 12, 2026 at 16:46 UTC 7ba1d61e341903a9274f9074c7caea4f3d19f1de
2 files changed +91 -3
docker/run/fs/exe/self_update_manager.py
+43 -3
@@ -8,6 +8,7 @@ import os
8 import re
9 import shutil
10 import signal
11 +import stat
12 import subprocess
13 import sys
14 import tempfile
@@ -366,11 +367,15 @@ def create_usr_backup(
367 root_path = Path(root)
368 for filename in files:
369 source_file = root_path / filename
369 - if source_file.is_symlink() and not source_file.exists():
370 - logger.log(f"Skipping broken symlink during usr backup: {source_file}")
370 + if not should_include_usr_backup_entry(source_file, logger):
371 continue
372 archive_name = Path("usr") / source_file.relative_to(usr_dir)
373 - archive.write(source_file, archive_name.as_posix())
373 + try:
374 + archive.write(source_file, archive_name.as_posix())
375 + except FileNotFoundError:
376 + logger.log(f"Skipping vanished usr backup entry: {source_file}")
377 + except OSError as exc:
378 + logger.log(f"Skipping usr backup entry after read error: {source_file}: {exc}")
379
380 destination.parent.mkdir(parents=True, exist_ok=True)
381 shutil.move(str(temporary_backup), str(destination))
@@ -381,6 +386,41 @@ def create_usr_backup(
386 temporary_backup.unlink(missing_ok=True)
387
388
389 +def should_include_usr_backup_entry(source_file: Path, logger: AttemptLogger) -> bool:
390 + try:
391 + source_stat = source_file.lstat()
392 + except FileNotFoundError:
393 + logger.log(f"Skipping vanished usr backup entry: {source_file}")
394 + return False
395 + except OSError as exc:
396 + logger.log(f"Skipping unreadable usr backup entry: {source_file}: {exc}")
397 + return False
398 +
399 + if stat.S_ISLNK(source_stat.st_mode):
400 + try:
401 + target_stat = source_file.stat()
402 + except FileNotFoundError:
403 + logger.log(f"Skipping broken symlink during usr backup: {source_file}")
404 + return False
405 + except OSError as exc:
406 + logger.log(
407 + f"Skipping unreadable symlink target during usr backup: {source_file}: {exc}"
408 + )
409 + return False
410 + if not stat.S_ISREG(target_stat.st_mode):
411 + logger.log(
412 + f"Skipping non-regular symlink target during usr backup: {source_file}"
413 + )
414 + return False
415 + return True
416 +
417 + if not stat.S_ISREG(source_stat.st_mode):
418 + logger.log(f"Skipping non-regular usr backup entry: {source_file}")
419 + return False
420 +
421 + return True
422 +
423 +
424 def run_command(
425 command: list[str],
426 *,
tests/test_self_update_tag_filter.py
+48
@@ -1,5 +1,7 @@
1 import importlib.util
2 +import socket
3 import sys
4 +import tempfile
5 import types
6 import zipfile
7 from pathlib import Path
@@ -781,6 +783,52 @@ def test_self_update_manager_usr_backup_skips_broken_symlinks(tmp_path):
783 assert "usr/workdir/reachy-mini-mcp/.venv/bin/python" not in names
784
785
786 +def test_self_update_manager_usr_backup_skips_runtime_sockets():
787 + manager = load_self_update_manager()
788 + with tempfile.TemporaryDirectory(prefix="a0su-", dir="/tmp") as temp_root:
789 + repo_dir = Path(temp_root) / "repo"
790 + usr_dir = repo_dir / "usr"
791 + gnupg_dir = (
792 + usr_dir
793 + / "plugins"
794 + / "_desktop"
795 + / "profiles"
796 + / "agent-zero-desktop"
797 + / ".gnupg"
798 + )
799 + gnupg_dir.mkdir(parents=True)
800 + (usr_dir / "settings.json").write_text('{"ok": true}\n', encoding="utf-8")
801 + socket_path = gnupg_dir / "S.gpg-agent"
802 + messages = []
803 +
804 + class ListLogger:
805 + def log(self, message=""):
806 + messages.append(message)
807 +
808 + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as runtime_socket:
809 + runtime_socket.bind(str(socket_path))
810 + backup_path = manager.create_usr_backup(
811 + repo_dir=repo_dir,
812 + backup_path=str(Path(temp_root) / "backups"),
813 + backup_name="usr-backup.zip",
814 + conflict_policy="rename",
815 + logger=ListLogger(),
816 + )
817 +
818 + with zipfile.ZipFile(backup_path) as archive:
819 + names = set(archive.namelist())
820 +
821 + assert "usr/settings.json" in names
822 + assert (
823 + "usr/plugins/_desktop/profiles/agent-zero-desktop/.gnupg/S.gpg-agent"
824 + not in names
825 + )
826 + assert any(
827 + "Skipping non-regular usr backup entry" in message
828 + for message in messages
829 + )
830 +
831 +
832 def test_self_update_manager_clean_uv_cache_uses_uv_when_available(monkeypatch):
833 manager = load_self_update_manager()
834 commands = []