refactor: ZIP swap journal and recovery live in _early_recovery_zip

Pure move, behaviour-identical. The ZIP swap journal, owner lock and
journal-driven recovery (~250 lines) are unrelated to the git-marker
restore, yet had grown _early_recovery.py from 684 to 1455 lines. They now
live in the topical sibling hermes_cli/_early_recovery_zip.py.

restore_interrupted_pull imports it only when the journal exists, so the
git-marker repair and its published closure (RECOVERY_CLOSURE) still need
only _early_recovery.py; ZIP_SWAP_JOURNAL stays there for that fast-path
stat. write_durable_text stays too (the marker and fleet writers use it).
Importers and the one monkeypatch seam (_lock_fd, read by the lock) now
point at the module that reads them. (review K132361 shape gate)
This commit is contained in:
Teknium
2026-10-05 22:26:36 -07:00
parent b8d8117261
commit c57e829d0b
6 changed files with 312 additions and 286 deletions

View File

@@ -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)
# ``<old>.<gen>.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():

View File

@@ -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)
# ``<old>.<gen>.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

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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 ``<entry>.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()