diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index cd920afc9f..2b23b9493a 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -6,7 +6,6 @@ import contextlib import errno import os import re -import stat import subprocess import sys import time @@ -663,95 +662,10 @@ def _release_dead_index_lock(git_dir: Path, root: Path | None = None) -> bool: return True -# The ZIP update's equivalent of the interrupted-pull marker: a journal in the install root naming -# every entry the swap stages/renames and whether it existed before, plus an OS lock its owner holds -# for the whole stage+swap (the kernel drops it with the owner: liveness without pids or ages). +# The ZIP update's equivalent of the interrupted-pull marker (``_early_recovery_zip``): a journal in the +# install root naming every entry the swap stages/renames. Its name stays here: the launch fast path +# stats it without importing the ZIP code. ZIP_SWAP_JOURNAL = ".hermes-update-zip-swap" -_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX = ".hermes-update-staging", ".hermes-update-old" - - -class ZipSwapLock: - """``zip_swap_owner_lock``'s verdict: truthy only while this process holds the kernel lock. - - ``reason`` names a refusal: ``busy`` (a live owner holds it) or why no lock could be taken at all. - """ - - __slots__ = ("owned", "reason") - - def __init__(self, owned: bool, reason: str = "") -> None: - self.owned, self.reason = owned, reason - - def __bool__(self) -> bool: - return self.owned - - -@contextlib.contextmanager -def zip_swap_owner_lock(root: Path, *, wait: float = 0.0): - """Yields a truthy ``ZipSwapLock`` while this process owns the ZIP swap lock, a falsy one otherwise. - - The lock file is a stable sidecar: never unlinked, so every process locks the same inode (an - unlink lets a waiter keep the old inode while a newcomer locks a fresh one). No lock, no admission: - a root where the file cannot be opened or locked refuses with the reason (fail closed).""" - lock_path = Path(root) / (ZIP_SWAP_JOURNAL + ".lock") - try: - fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) - except OSError as exc: - yield ZipSwapLock(False, f"cannot open the ZIP swap lock {lock_path}: {exc.strerror or exc}") - return - try: - deadline = time.monotonic() + wait - while True: - try: - if sys.platform == "win32": - import msvcrt - - os.lseek(fd, 0, os.SEEK_SET) - msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except OSError as exc: - if exc.errno not in _CLAIM_HELD_ERRNOS: - yield ZipSwapLock(False, f"cannot lock the ZIP swap lock {lock_path}: {exc.strerror or exc}") - return - if time.monotonic() >= deadline: - yield ZipSwapLock(False, "busy") - return - time.sleep(0.05) - try: - yield ZipSwapLock(True) - finally: - _lock_fd(fd, False) - finally: - os.close(fd) - - -def write_zip_swap_journal(root: Path, phase: str, entries: list, gen: str) -> None: - """Publish the swap journal: ``entries`` are ``[name, existed, staged_id, live_id]`` (ids from - ``zip_entry_identity``, "" while unknown); ``gen`` is the run's random tag (its backup temps).""" - import json - - write_durable_text(Path(root) / ZIP_SWAP_JOURNAL, - json.dumps({"pid": os.getpid(), "gen": gen, "phase": phase, "entries": entries})) - - -def zip_entry_identity(path) -> str: - """The entry's own identity (lstat, never its target); "" when absent or unidentifiable. - - The ZIP journal's provenance: recovery deletes only an entry whose identity it recorded when the - swap made it (a staging copy, or the live entry its backup links/renames), never a lookalike. A - non-directory adds size and mtime: a file deleted and recreated often gets the same inode back. - A directory is ``dev:ino:type`` only: a staged tree's own mtime moves while it is being filled.""" - try: - st = os.lstat(path) - except OSError: - return "" - if not st.st_ino: - return "" - base = f"{st.st_dev}:{st.st_ino}:{stat.S_IFMT(st.st_mode):o}" - return base if stat.S_ISDIR(st.st_mode) else f"{base}:{st.st_size}:{st.st_mtime_ns}" def write_durable_text(path: Path, text: str) -> None: @@ -781,173 +695,6 @@ def write_durable_text(path: Path, text: str) -> None: raise -def _drop_path(path: Path) -> None: - if path.is_dir() and not path.is_symlink(): - import shutil - - try: - shutil.rmtree(path) - except OSError: - # A staged copy keeps its source's modes: a read-only directory refuses to give up its - # entries. These trees are the update's own copies, so make their directories writable and - # retry. Files keep their modes: a grafted artifact is a hardlink to the LIVE file, and POSIX - # deletion needs only the parent's write bit. Windows refuses to delete a read-only file, so - # there only a file no other link shares has its read-only bit cleared. - rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR - os.chmod(path, os.stat(path).st_mode | rwx) - for dirpath, dirnames, files in os.walk(path): - for name in dirnames: - child = os.path.join(dirpath, name) - if not os.path.islink(child): - os.chmod(child, os.stat(child).st_mode | rwx) - for name in files if sys.platform == "win32" else (): - child = os.path.join(dirpath, name) - st = os.lstat(child) - if st.st_nlink == 1 and not stat.S_ISLNK(st.st_mode): - os.chmod(child, st.st_mode | stat.S_IWUSR) - shutil.rmtree(path) - elif path.exists() or path.is_symlink(): - path.unlink() - - -def _parse_zip_swap_journal(raw: str) -> tuple[str, str, list[tuple[str, bool, str, str]]] | None: - """``(phase, gen, [(entry name, existed, staged_id, live_id)])`` from a journal this code wrote, else None.""" - import json - - try: - data = json.loads(raw) - except ValueError: - return None - if not isinstance(data, dict) or data.get("phase") not in ("staging", "swapping", "committed"): - return None - entries, gen = data.get("entries"), data.get("gen") - if not isinstance(entries, list) or not isinstance(gen, str) or not re.fullmatch(r"[0-9a-f]{12}", gen): - return None - if not all(isinstance(e, list) and len(e) == 4 and all(isinstance(v, str) for v in (e[0], e[2], e[3])) - and isinstance(e[1], bool) and e[0] not in ("", ".", "..") and "/" not in e[0] and "\\" not in e[0] - for e in entries): - return None - return data["phase"], gen, [(name, existed, staged, live) for name, existed, staged, live in entries] - - -def _discard_unless_owned(path: Path, identity: str, kept: list[Path]) -> None: - """Delete ``path`` only when it is provably the entry the swap recorded; anything else (a later - user file at the suffix, a foreign journal's victim) is renamed aside, never deleted (review Z1).""" - if not os.path.lexists(path): - return - if identity and zip_entry_identity(path) == identity: - _drop_path(path) - else: - kept.append(_keep_aside(path)) - - -def _settle_zip_entry(root: Path, phase: str, gen: str, entry: tuple[str, bool, str, str], kept: list[Path]) -> bool: - """Finish or roll back one journaled entry; True when the live tree changed. - - Presence is the ENTRY's (lexists/lstat), never its target's: a dangling symlink backup is the only - copy of a tracked symlink, not "no backup" (review Z3). Restoring a backup never deletes bytes, so - it needs no provenance; every deletion does (``_discard_unless_owned``).""" - name, existed, staged_id, live_id = entry - here = os.path.lexists - dst = root / name - staging, old = Path(f"{dst}{_ZIP_STAGING_SUFFIX}"), Path(f"{dst}{_ZIP_OLD_SUFFIX}") - changed = False - if phase == "swapping": - # Staging finished before this phase, so a backup now is the one the swap made from the old - # entry: it existed, whatever an older journal recorded. - existed = existed or here(old) - if existed and here(old) and not (live_id and zip_entry_identity(dst) == live_id): - if (old.is_file() and not old.is_symlink() and staged_id and zip_entry_identity(dst) == staged_id - and not dst.is_dir()): - os.replace(old, dst) # a file entry never goes missing, not even here - else: - _discard_unless_owned(dst, staged_id, kept) - os.rename(old, dst) # moves a symlink itself, never its target - changed = True - elif not existed and here(dst) and not here(staging): - _discard_unless_owned(dst, staged_id, kept) - changed = True - elif existed and not here(dst) and here(old): - os.rename(old, dst) # the backup is the only copy left - changed = True - for leftover, identity in ((staging, staged_id), (old, live_id)): - _discard_unless_owned(leftover, identity, kept) - # ``..tmp``: a backup copy of the live file killed before its rename (no-hardlink file - # systems). The tag names it but proves nothing about the bytes (F78): only a prefix of the live - # file is that copy; anything else at the name is kept aside. - tmp = Path(f"{old}.{gen}.tmp") - if here(tmp): - regular = [p.is_file() and not p.is_symlink() for p in (tmp, dst)] - if all(regular) and dst.read_bytes().startswith(tmp.read_bytes()): - _drop_path(tmp) - else: - kept.append(_keep_aside(tmp)) - return changed - - -def restore_interrupted_zip_swap(project_root: Path | None = None) -> bool: - """Finish or roll back a ZIP swap whose owner died; True when the tree changed (relaunch). - - ``committed``: every rename landed, only backups remain -> drop them (finish). ``swapping``: put - each moved-aside entry back and remove entries the swap added (roll back to the old tree, which - the venv was built for; ``hermes update`` redoes it). ``staging``: nothing live moved -> drop the - staging copies. Either way no ``*.hermes-update-staging``/``-old`` sibling is left to wedge the - next run's free-space or dirty-tree checks. A live owner (lock held) is never second-guessed. - Provenance (review Z1): a sibling or entry is deleted only when its identity is the one the journal - recorded; anything else is kept aside under a ``.hermes-update-kept`` name and reported. - """ - root = _project_root() if project_root is None else Path(project_root) - journal = root / ZIP_SWAP_JOURNAL - if not journal.is_file() or _pytest_owns_live_checkout(root): - return False - with zip_swap_owner_lock(root) as owned: - if not owned and owned.reason != "busy": - print(f"⚠ An interrupted ZIP update cannot be settled: {owned.reason}.", file=sys.stderr) - if not owned or not journal.is_file(): - return False - try: - raw = journal.read_text(encoding="utf-8-sig") - except OSError as exc: # transient (AV, sharing violation): the journal is still the record - print(f"⚠ Could not read the interrupted ZIP update's journal ({exc}); the next launch retries.", - file=sys.stderr) - return False - parsed = _parse_zip_swap_journal(raw) - if parsed is None: - # Never guess which live entry to move, and never retire the only record of a mixed tree. - print(f"⚠ The interrupted ZIP update's journal {journal} is unreadable or from another version; " - "it and every `*.hermes-update-old` backup were kept. Put back what each backup replaced " - "(or reinstall), then delete the journal.", file=sys.stderr) - return False - phase, gen, entries = parsed - changed = False - failed = False - kept: list[Path] = [] - for entry in reversed(entries): - try: - changed = _settle_zip_entry(root, phase, gen, entry, kept) or changed - except OSError as exc: - failed = True - print(f"⚠ Could not settle {entry[0]} after an interrupted ZIP update: {exc}", file=sys.stderr) - if kept: - print("⚠ An interrupted ZIP update's recovery found entries it could not prove were its own and " - f"kept them aside instead of deleting them: {', '.join(map(str, kept))}. Delete each once " - "you know it is not yours.", file=sys.stderr) - # Retire the journal only on a verified terminal state, not an exception-free loop: no sibling - # (staging copy, backup, backup temp) left that only this journal could still explain. - siblings = (_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX, f"{_ZIP_OLD_SUFFIX}.{gen}.tmp") - unsettled = [e[0] for e in entries if any(os.path.lexists(f"{root / e[0]}{x}") for x in siblings)] - if failed or unsettled: - if unsettled and not failed: - print(f"⚠ The interrupted ZIP update left {', '.join(unsettled)} unsettled; its journal " - f"{journal} stays and the next launch retries.", file=sys.stderr) - return changed # the journal stays: the next launch retries - journal.unlink(missing_ok=True) - if changed: - print("⚠ A previous ZIP `hermes update` was interrupted mid-swap; the old install was put back. " - "`hermes update` updates it again.", file=sys.stderr) - return changed - - def restore_interrupted_pull(project_root: Path | None = None, *, after_failure: bool = False) -> bool: """Put back the files a killed ``hermes update`` had half-moved to the new commit. @@ -987,6 +734,8 @@ def restore_interrupted_pull(project_root: Path | None = None, *, after_failure: if busy: print(busy_note.format(busy), file=sys.stderr) return False + from hermes_cli._early_recovery_zip import restore_interrupted_zip_swap + if restore_interrupted_zip_swap(root): return True if not marker.is_file(): diff --git a/hermes_cli/_early_recovery_zip.py b/hermes_cli/_early_recovery_zip.py new file mode 100644 index 0000000000..f3d07fd2fa --- /dev/null +++ b/hermes_cli/_early_recovery_zip.py @@ -0,0 +1,276 @@ +"""Settles a ZIP ``hermes update`` swap whose owner died: its journal, owner lock and recovery. + +Split out of ``_early_recovery`` (stdlib-only, like it). ``_early_recovery.restore_interrupted_pull`` +imports this only when the journal exists, so the git-marker repair and its published closure +(``RECOVERY_CLOSURE``) never need it. +""" + +from __future__ import annotations + +import contextlib +import os +import re +import stat +import sys +import time +from pathlib import Path + +from hermes_cli._early_recovery import ( + _CLAIM_HELD_ERRNOS, ZIP_SWAP_JOURNAL, _keep_aside, _lock_fd, _project_root, _pytest_owns_live_checkout, + write_durable_text) + +# A journal in the install root naming every entry the swap stages/renames and whether it existed +# before, plus an OS lock its owner holds for the whole stage+swap (the kernel drops it with the +# owner: liveness without pids or ages). +_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX = ".hermes-update-staging", ".hermes-update-old" + + +class ZipSwapLock: + """``zip_swap_owner_lock``'s verdict: truthy only while this process holds the kernel lock. + + ``reason`` names a refusal: ``busy`` (a live owner holds it) or why no lock could be taken at all. + """ + + __slots__ = ("owned", "reason") + + def __init__(self, owned: bool, reason: str = "") -> None: + self.owned, self.reason = owned, reason + + def __bool__(self) -> bool: + return self.owned + + +@contextlib.contextmanager +def zip_swap_owner_lock(root: Path, *, wait: float = 0.0): + """Yields a truthy ``ZipSwapLock`` while this process owns the ZIP swap lock, a falsy one otherwise. + + The lock file is a stable sidecar: never unlinked, so every process locks the same inode (an + unlink lets a waiter keep the old inode while a newcomer locks a fresh one). No lock, no admission: + a root where the file cannot be opened or locked refuses with the reason (fail closed).""" + lock_path = Path(root) / (ZIP_SWAP_JOURNAL + ".lock") + try: + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) + except OSError as exc: + yield ZipSwapLock(False, f"cannot open the ZIP swap lock {lock_path}: {exc.strerror or exc}") + return + try: + deadline = time.monotonic() + wait + while True: + try: + if sys.platform == "win32": + import msvcrt + + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError as exc: + if exc.errno not in _CLAIM_HELD_ERRNOS: + yield ZipSwapLock(False, f"cannot lock the ZIP swap lock {lock_path}: {exc.strerror or exc}") + return + if time.monotonic() >= deadline: + yield ZipSwapLock(False, "busy") + return + time.sleep(0.05) + try: + yield ZipSwapLock(True) + finally: + _lock_fd(fd, False) + finally: + os.close(fd) + + +def write_zip_swap_journal(root: Path, phase: str, entries: list, gen: str) -> None: + """Publish the swap journal: ``entries`` are ``[name, existed, staged_id, live_id]`` (ids from + ``zip_entry_identity``, "" while unknown); ``gen`` is the run's random tag (its backup temps).""" + import json + + write_durable_text(Path(root) / ZIP_SWAP_JOURNAL, + json.dumps({"pid": os.getpid(), "gen": gen, "phase": phase, "entries": entries})) + + +def zip_entry_identity(path) -> str: + """The entry's own identity (lstat, never its target); "" when absent or unidentifiable. + + The ZIP journal's provenance: recovery deletes only an entry whose identity it recorded when the + swap made it (a staging copy, or the live entry its backup links/renames), never a lookalike. A + non-directory adds size and mtime: a file deleted and recreated often gets the same inode back. + A directory is ``dev:ino:type`` only: a staged tree's own mtime moves while it is being filled.""" + try: + st = os.lstat(path) + except OSError: + return "" + if not st.st_ino: + return "" + base = f"{st.st_dev}:{st.st_ino}:{stat.S_IFMT(st.st_mode):o}" + return base if stat.S_ISDIR(st.st_mode) else f"{base}:{st.st_size}:{st.st_mtime_ns}" + + +def _drop_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + import shutil + + try: + shutil.rmtree(path) + except OSError: + # A staged copy keeps its source's modes: a read-only directory refuses to give up its + # entries. These trees are the update's own copies, so make their directories writable and + # retry. Files keep their modes: a grafted artifact is a hardlink to the LIVE file, and POSIX + # deletion needs only the parent's write bit. Windows refuses to delete a read-only file, so + # there only a file no other link shares has its read-only bit cleared. + rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR + os.chmod(path, os.stat(path).st_mode | rwx) + for dirpath, dirnames, files in os.walk(path): + for name in dirnames: + child = os.path.join(dirpath, name) + if not os.path.islink(child): + os.chmod(child, os.stat(child).st_mode | rwx) + for name in files if sys.platform == "win32" else (): + child = os.path.join(dirpath, name) + st = os.lstat(child) + if st.st_nlink == 1 and not stat.S_ISLNK(st.st_mode): + os.chmod(child, st.st_mode | stat.S_IWUSR) + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + + +def _parse_zip_swap_journal(raw: str) -> tuple[str, str, list[tuple[str, bool, str, str]]] | None: + """``(phase, gen, [(entry name, existed, staged_id, live_id)])`` from a journal this code wrote, else None.""" + import json + + try: + data = json.loads(raw) + except ValueError: + return None + if not isinstance(data, dict) or data.get("phase") not in ("staging", "swapping", "committed"): + return None + entries, gen = data.get("entries"), data.get("gen") + if not isinstance(entries, list) or not isinstance(gen, str) or not re.fullmatch(r"[0-9a-f]{12}", gen): + return None + if not all(isinstance(e, list) and len(e) == 4 and all(isinstance(v, str) for v in (e[0], e[2], e[3])) + and isinstance(e[1], bool) and e[0] not in ("", ".", "..") and "/" not in e[0] and "\\" not in e[0] + for e in entries): + return None + return data["phase"], gen, [(name, existed, staged, live) for name, existed, staged, live in entries] + + +def _discard_unless_owned(path: Path, identity: str, kept: list[Path]) -> None: + """Delete ``path`` only when it is provably the entry the swap recorded; anything else (a later + user file at the suffix, a foreign journal's victim) is renamed aside, never deleted (review Z1).""" + if not os.path.lexists(path): + return + if identity and zip_entry_identity(path) == identity: + _drop_path(path) + else: + kept.append(_keep_aside(path)) + + +def _settle_zip_entry(root: Path, phase: str, gen: str, entry: tuple[str, bool, str, str], kept: list[Path]) -> bool: + """Finish or roll back one journaled entry; True when the live tree changed. + + Presence is the ENTRY's (lexists/lstat), never its target's: a dangling symlink backup is the only + copy of a tracked symlink, not "no backup" (review Z3). Restoring a backup never deletes bytes, so + it needs no provenance; every deletion does (``_discard_unless_owned``).""" + name, existed, staged_id, live_id = entry + here = os.path.lexists + dst = root / name + staging, old = Path(f"{dst}{_ZIP_STAGING_SUFFIX}"), Path(f"{dst}{_ZIP_OLD_SUFFIX}") + changed = False + if phase == "swapping": + # Staging finished before this phase, so a backup now is the one the swap made from the old + # entry: it existed, whatever an older journal recorded. + existed = existed or here(old) + if existed and here(old) and not (live_id and zip_entry_identity(dst) == live_id): + if (old.is_file() and not old.is_symlink() and staged_id and zip_entry_identity(dst) == staged_id + and not dst.is_dir()): + os.replace(old, dst) # a file entry never goes missing, not even here + else: + _discard_unless_owned(dst, staged_id, kept) + os.rename(old, dst) # moves a symlink itself, never its target + changed = True + elif not existed and here(dst) and not here(staging): + _discard_unless_owned(dst, staged_id, kept) + changed = True + elif existed and not here(dst) and here(old): + os.rename(old, dst) # the backup is the only copy left + changed = True + for leftover, identity in ((staging, staged_id), (old, live_id)): + _discard_unless_owned(leftover, identity, kept) + # ``..tmp``: a backup copy of the live file killed before its rename (no-hardlink file + # systems). The tag names it but proves nothing about the bytes (F78): only a prefix of the live + # file is that copy; anything else at the name is kept aside. + tmp = Path(f"{old}.{gen}.tmp") + if here(tmp): + regular = [p.is_file() and not p.is_symlink() for p in (tmp, dst)] + if all(regular) and dst.read_bytes().startswith(tmp.read_bytes()): + _drop_path(tmp) + else: + kept.append(_keep_aside(tmp)) + return changed + + +def restore_interrupted_zip_swap(project_root: Path | None = None) -> bool: + """Finish or roll back a ZIP swap whose owner died; True when the tree changed (relaunch). + + ``committed``: every rename landed, only backups remain -> drop them (finish). ``swapping``: put + each moved-aside entry back and remove entries the swap added (roll back to the old tree, which + the venv was built for; ``hermes update`` redoes it). ``staging``: nothing live moved -> drop the + staging copies. Either way no ``*.hermes-update-staging``/``-old`` sibling is left to wedge the + next run's free-space or dirty-tree checks. A live owner (lock held) is never second-guessed. + Provenance (review Z1): a sibling or entry is deleted only when its identity is the one the journal + recorded; anything else is kept aside under a ``.hermes-update-kept`` name and reported. + """ + root = _project_root() if project_root is None else Path(project_root) + journal = root / ZIP_SWAP_JOURNAL + if not journal.is_file() or _pytest_owns_live_checkout(root): + return False + with zip_swap_owner_lock(root) as owned: + if not owned and owned.reason != "busy": + print(f"⚠ An interrupted ZIP update cannot be settled: {owned.reason}.", file=sys.stderr) + if not owned or not journal.is_file(): + return False + try: + raw = journal.read_text(encoding="utf-8-sig") + except OSError as exc: # transient (AV, sharing violation): the journal is still the record + print(f"⚠ Could not read the interrupted ZIP update's journal ({exc}); the next launch retries.", + file=sys.stderr) + return False + parsed = _parse_zip_swap_journal(raw) + if parsed is None: + # Never guess which live entry to move, and never retire the only record of a mixed tree. + print(f"⚠ The interrupted ZIP update's journal {journal} is unreadable or from another version; " + "it and every `*.hermes-update-old` backup were kept. Put back what each backup replaced " + "(or reinstall), then delete the journal.", file=sys.stderr) + return False + phase, gen, entries = parsed + changed = False + failed = False + kept: list[Path] = [] + for entry in reversed(entries): + try: + changed = _settle_zip_entry(root, phase, gen, entry, kept) or changed + except OSError as exc: + failed = True + print(f"⚠ Could not settle {entry[0]} after an interrupted ZIP update: {exc}", file=sys.stderr) + if kept: + print("⚠ An interrupted ZIP update's recovery found entries it could not prove were its own and " + f"kept them aside instead of deleting them: {', '.join(map(str, kept))}. Delete each once " + "you know it is not yours.", file=sys.stderr) + # Retire the journal only on a verified terminal state, not an exception-free loop: no sibling + # (staging copy, backup, backup temp) left that only this journal could still explain. + siblings = (_ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX, f"{_ZIP_OLD_SUFFIX}.{gen}.tmp") + unsettled = [e[0] for e in entries if any(os.path.lexists(f"{root / e[0]}{x}") for x in siblings)] + if failed or unsettled: + if unsettled and not failed: + print(f"⚠ The interrupted ZIP update left {', '.join(unsettled)} unsettled; its journal " + f"{journal} stays and the next launch retries.", file=sys.stderr) + return changed # the journal stays: the next launch retries + journal.unlink(missing_ok=True) + if changed: + print("⚠ A previous ZIP `hermes update` was interrupted mid-swap; the old install was put back. " + "`hermes update` updates it again.", file=sys.stderr) + return changed diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index 01839cc12e..86d976e9eb 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -14,9 +14,9 @@ import sys from pathlib import Path from typing import Collection, Optional -from hermes_cli._early_recovery import ( - ZIP_SWAP_JOURNAL, _keep_aside, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity, - zip_swap_owner_lock) +from hermes_cli._early_recovery import _keep_aside +from hermes_cli._early_recovery_zip import ( + ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity, zip_swap_owner_lock) # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index d91d582053..4b5956a8b1 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -15,6 +15,7 @@ from pathlib import Path import pytest from hermes_cli import _early_recovery as er +from hermes_cli import _early_recovery_zip as erz from pm import recovery REPO_ROOT = Path(__file__).resolve().parents[2] @@ -205,7 +206,7 @@ def test_zip_journal_writer_never_writes_through_a_preexisting_temp(tmp_path, li tmp.symlink_to(secret) except OSError: pytest.skip("symlinks need privileges here") - er.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab") + erz.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab") assert secret.read_text(encoding="utf-8-sig") == "API_KEY=keep\n" journal = root / er.ZIP_SWAP_JOURNAL assert not journal.is_symlink() and '"phase": "staging"' in journal.read_text(encoding="utf-8-sig") @@ -218,8 +219,8 @@ def test_zip_journal_publication_never_deletes_a_file_at_its_old_fixed_temp_name root.mkdir() user = root / (er.ZIP_SWAP_JOURNAL + ".tmp") user.write_text("USER NOTES\n", encoding="utf-8") - er.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab") - er.write_zip_swap_journal(root, "swapping", [["a.py", True, "", ""]], "0123456789ab") + erz.write_zip_swap_journal(root, "staging", [["a.py", True, "", ""]], "0123456789ab") + erz.write_zip_swap_journal(root, "swapping", [["a.py", True, "", ""]], "0123456789ab") assert user.read_text(encoding="utf-8-sig") == "USER NOTES\n" assert '"phase": "swapping"' in (root / er.ZIP_SWAP_JOURNAL).read_text(encoding="utf-8-sig") assert sorted(p.name for p in root.iterdir()) == sorted([er.ZIP_SWAP_JOURNAL, user.name]) @@ -247,10 +248,10 @@ def test_an_interrupted_swap_restores_its_backup_by_entry_not_by_target(tmp_path _symlink_or_skip(old, "gone.txt" if kind == "dangling-symlink" else "target.txt") (root / "alpha").write_text("NEW", encoding="utf-8") # As the swap journals it: the staged copy (now live) and the moved-aside original by identity. - er.write_zip_swap_journal(root, "swapping", [["alpha", True, er.zip_entry_identity(root / "alpha"), - er.zip_entry_identity(old)]], "0123456789ab") + erz.write_zip_swap_journal(root, "swapping", [["alpha", True, erz.zip_entry_identity(root / "alpha"), + erz.zip_entry_identity(old)]], "0123456789ab") - assert er.restore_interrupted_zip_swap(root) is True + assert erz.restore_interrupted_zip_swap(root) is True alpha = root / "alpha" if kind == "regular": assert not alpha.is_symlink() and alpha.read_text(encoding="utf-8") == "OLD" @@ -282,7 +283,7 @@ def test_an_unparsed_zip_journal_keeps_itself_and_every_backup(tmp_path, monkeyp return real(self, *args, **kwargs) monkeypatch.setattr(Path, "read_text", flaky) - assert er.restore_interrupted_zip_swap(root) is False + assert erz.restore_interrupted_zip_swap(root) is False assert journal.is_file() assert {p.name: p.read_text(encoding="utf-8-sig") for p in root.iterdir() if p != journal and p.suffix != ".lock"} == { "a.py": "NEW_A", "a.py.hermes-update-old": "OLD_A", "b.py": "OLD_B", "b.py.hermes-update-staging": "NEW_B"} @@ -299,7 +300,7 @@ def test_dropping_a_staged_tree_never_changes_a_hardlinked_live_files_mode(tmp_p os.link(live / "Hermes.exe", staging / "Hermes.exe") (live / "Hermes.exe").chmod(0o555) staging.chmod(0o555) # a read-only dir refuses rmtree: the retry path runs - er._drop_path(staging.parent) + erz._drop_path(staging.parent) assert not staging.parent.exists() assert (live / "Hermes.exe").stat().st_mode & 0o777 == 0o555 diff --git a/tests/hermes_cli/test_update_zip_fallback_guards.py b/tests/hermes_cli/test_update_zip_fallback_guards.py index 650fd1c664..c678e089ed 100644 --- a/tests/hermes_cli/test_update_zip_fallback_guards.py +++ b/tests/hermes_cli/test_update_zip_fallback_guards.py @@ -333,7 +333,7 @@ def test_zip_gate_refuses_on_a_user_file_at_the_old_fixed_journal_temp_name(tmp_ def test_zip_gate_admits_the_retry_after_an_interrupted_swap(tmp_path, monkeypatch): """The siblings an interrupted swap's journal owns never refuse the retry: they are settled first.""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, write_zip_swap_journal, zip_entry_identity + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, write_zip_swap_journal, zip_entry_identity _git_install(tmp_path) leftover = tmp_path / "alpha.hermes-update-staging" diff --git a/tests/hermes_cli/test_update_zip_two_phase.py b/tests/hermes_cli/test_update_zip_two_phase.py index bd1f322424..3823b0a98e 100644 --- a/tests/hermes_cli/test_update_zip_two_phase.py +++ b/tests/hermes_cli/test_update_zip_two_phase.py @@ -429,13 +429,13 @@ def test_root_files_never_go_missing_mid_swap(tmp_path, monkeypatch, hardlinks): # --------------------------------------------------------------------------- -# ZIP swap owner lock and staging journal (_early_recovery / _journaled_stage_and_swap) +# ZIP swap owner lock and staging journal (_early_recovery_zip / _journaled_stage_and_swap) # --------------------------------------------------------------------------- _LOCK_ROLE = r''' import json, os, sys, time from pathlib import Path -from hermes_cli import _early_recovery as er +from hermes_cli import _early_recovery_zip as erz role, work = sys.argv[1], Path(sys.argv[2]) live, lock_path = work / "live", work / "live" / ".hermes-update-zip-swap.lock" def wait_for(name): @@ -448,25 +448,25 @@ def mark(name, owned): (work / name).write_text(json.dumps({"owned": bool(owned), "inode": os.stat(lock_path).st_ino if lock_path.exists() else None})) if role == "a": # owner whose release is caught right after its unlock: B already holds the inode - real = er._lock_fd + real = erz._lock_fd def lock_fd(fd, lock): done = real(fd, lock) if not lock: wait_for("b") return done - er._lock_fd = lock_fd - with er.zip_swap_owner_lock(live) as owned: + erz._lock_fd = lock_fd + with erz.zip_swap_owner_lock(live) as owned: mark("a", owned) wait_for("b-waiting"); time.sleep(0.3) (work / "a-done").touch() elif role == "b": # waiter that wins the lock as A lets go wait_for("a"); (work / "b-waiting").touch() - with er.zip_swap_owner_lock(live, wait=20) as owned: + with erz.zip_swap_owner_lock(live, wait=20) as owned: mark("b", owned) wait_for("c") else: # newcomer after A's release completed wait_for("a-done") - with er.zip_swap_owner_lock(live) as owned: + with erz.zip_swap_owner_lock(live) as owned: mark("c", owned) ''' @@ -493,7 +493,7 @@ def test_zip_swap_lock_is_one_inode_across_a_release(tmp_path): reason="POSIX permission bits; root ignores them") def test_zip_swap_lock_refuses_admission_without_a_lock(tmp_path): """A root where the lock file cannot be created grants nothing: no lock, no swap, and a named reason.""" - from hermes_cli._early_recovery import zip_swap_owner_lock + from hermes_cli._early_recovery_zip import zip_swap_owner_lock live = tmp_path / "live" live.mkdir() @@ -538,7 +538,7 @@ _POSIX_MODES = pytest.mark.skipif(os.name == "nt" or (hasattr(os, "geteuid") and def test_unreadable_source_file_leaves_no_partial_stage(tmp_path): """The copy of ``second`` dies on an unreadable file after copying the rest: that partial staging tree is the failing entry's and must be dropped like the finished ones (nothing live changes).""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap live, extracted, locked = _unreadable_release(tmp_path, read_only_dir=False) try: @@ -557,7 +557,7 @@ def test_unreadable_source_file_leaves_no_partial_stage(tmp_path): def test_a_stage_cleanup_that_cannot_finish_keeps_the_journal_for_recovery(tmp_path): """When the updater cannot remove its partial stage, the journal is that stage's only record: it stays, and the next launch's recovery removes the stage, then the journal.""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap live, extracted, locked = _unreadable_release(tmp_path, read_only_dir=True) try: @@ -575,7 +575,7 @@ def test_a_stage_cleanup_that_cannot_finish_keeps_the_journal_for_recovery(tmp_p def _swap_or_recover(extracted: Path, entries: list[str], live: Path) -> None: """``_download_and_swap_zip``'s wiring: a failed swap is settled from its journal right after.""" - from hermes_cli._early_recovery import restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import restore_interrupted_zip_swap try: update_cmd_zip._journaled_stage_and_swap(str(extracted), entries, live, None) @@ -666,7 +666,7 @@ def test_a_backup_copy_killed_before_its_rename_is_cleared_by_the_recovery(tmp_p """On a file system without hardlinks ``_file_backup`` copies to ``.hermes-update-old.tmp`` first. A kill inside that copy left the temp behind: recovery dropped only staging and the backup, so every later ZIP update refused on "uncommitted changes" (review C4).""" - from hermes_cli._early_recovery import ( + from hermes_cli._early_recovery_zip import ( ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity) live = tmp_path / "live" @@ -689,7 +689,7 @@ def test_a_file_at_the_backup_temp_name_that_is_not_the_killed_copy_is_kept(tmp_ backup copy existed leaves the name free, and a file there afterwards is not Hermes'. Recovery deletes only a prefix of the live file (what a killed copy of it is) and keeps anything else aside, still retiring the journal (review F78).""" - from hermes_cli._early_recovery import ( + from hermes_cli._early_recovery_zip import ( ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap, write_zip_swap_journal, zip_entry_identity) live = tmp_path / "live" @@ -742,7 +742,7 @@ def test_recovery_never_installs_or_deletes_a_backup_suffix_created_after_the_ki path-only journal took it for the swap's backup: it overwrote the live cli.py with it, and the old bytes were gone (review Z1). Recovery now proves a backup is the entry it recorded before using or deleting it: the live file stays, the user's file is kept aside, byte for byte.""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap root = _swap_killed(tmp_path, monkeypatch, live={"cli.py": "LIVE"}, new={"cli.py": "NEW"}, install_first=False) (root / "cli.py.hermes-update-old").write_text("USER LATER", encoding="utf-8") @@ -759,7 +759,7 @@ def test_recovery_never_deletes_a_user_file_that_replaced_an_installed_entry(tmp """A new entry (notes.md) was renamed into place, then the swap was killed; the user then replaced notes.md with a file of their own. Recovery deleted whatever sat at the journaled name (review Z1); only the entry the swap installed (its recorded staging identity) may go.""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap root = _swap_killed(tmp_path, monkeypatch, live={}, new={"notes.md": "NEW"}, install_first=True) (root / "notes.md").unlink() @@ -774,7 +774,7 @@ def test_recovery_never_deletes_a_user_file_that_replaced_an_installed_entry(tmp def test_recovery_still_rolls_back_an_authentic_killed_swap(tmp_path, monkeypatch): """The control: a swap killed after installing its first entry rolls back to the old tree, and every staging copy and backup the swap itself made is deleted (provenance proven), with nothing kept aside.""" - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap root = _swap_killed(tmp_path, monkeypatch, live={"a.py": "OLD_A", "b.py": "OLD_B"}, new={"a.py": "NEW_A", "b.py": "NEW_B", "c.py": "NEW_C"}, install_first=True) @@ -830,7 +830,7 @@ def test_a_committed_swap_keeps_its_journal_until_the_backup_is_gone(tmp_path, m import json from hermes_cli import update_cmd_commit - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap monkeypatch.setattr(update_cmd_commit, "arm_commit_obligations", lambda *a, **k: None) live, extracted = tmp_path / "live", tmp_path / "extracted" @@ -861,7 +861,7 @@ def test_a_suffix_path_that_appears_after_the_preflight_is_never_deleted(tmp_pat stage dropped the first unconditionally and the hardlink backup the second; both survive now, byte for byte, and the swap is refused with the live tree left old.""" from hermes_cli import update_cmd_commit - from hermes_cli._early_recovery import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap + from hermes_cli._early_recovery_zip import ZIP_SWAP_JOURNAL, restore_interrupted_zip_swap live, extracted = tmp_path / "live", tmp_path / "extracted" live.mkdir()