diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index 4bf32f7a02..45a1455939 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -440,12 +440,17 @@ _ZIP_STAGING_SUFFIX, _ZIP_OLD_SUFFIX = ".hermes-update-staging", ".hermes-update @contextlib.contextmanager def zip_swap_owner_lock(root: Path, *, wait: float = 0.0): - """Yields True while this process owns the ZIP swap lock, False when a live owner holds it.""" + """Yields True while this process owns the ZIP swap lock, False when a live owner holds it. + + The lock file is removed again once no journal is left, so a settled install carries no + breadcrumb (the swap itself also runs under the update lock; this one only proves liveness).""" + lock_path = Path(root) / (ZIP_SWAP_JOURNAL + ".lock") try: - fd = os.open(Path(root) / (ZIP_SWAP_JOURNAL + ".lock"), os.O_RDWR | os.O_CREAT, 0o644) + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) except OSError: yield True # unwritable root: no swap can run there either return + owned = False try: deadline = time.monotonic() + wait while not _lock_fd(fd, True): @@ -453,12 +458,16 @@ def zip_swap_owner_lock(root: Path, *, wait: float = 0.0): yield False return time.sleep(0.05) + owned = True try: yield True finally: _lock_fd(fd, False) finally: os.close(fd) + if owned and not (Path(root) / ZIP_SWAP_JOURNAL).exists(): + with contextlib.suppress(OSError): + lock_path.unlink() def write_zip_swap_journal(root: Path, phase: str, entries: list) -> None: diff --git a/tests/hermes_cli/test_update_fetch_failure_classifier.py b/tests/hermes_cli/test_update_fetch_failure_classifier.py index 994d17b154..e6b21ff604 100644 --- a/tests/hermes_cli/test_update_fetch_failure_classifier.py +++ b/tests/hermes_cli/test_update_fetch_failure_classifier.py @@ -149,9 +149,11 @@ def test_update_and_upstream_network_calls_disable_terminal_prompts(monkeypatch, assert update_cmd_git._sync_with_upstream_if_needed(["git"], tmp_path, assume_yes=True) from hermes_cli.update_custody import git_subcommand - # The fork's fast-forward is local (`merge --ff-only upstream/main` after the fetch): no - # credential helper ever runs under the checkout lock fd a mutator inherits (R2). - network = [(args, kwargs) for args, kwargs in calls if git_subcommand(args) != "rev-parse"] + # The fork push runs only after the update is validated (_push_synced_fork), never inside the sync. + update_cmd_git._sync_fork_with_upstream(["git"], tmp_path) + # The fork's fast-forward is local (`merge --ff-only` after the fetch): no credential helper ever + # runs under the checkout lock fd a mutator inherits (R2). + network = [(args, kwargs) for args, kwargs in calls if git_subcommand(args) in {"fetch", "pull", "push", "merge"}] assert [git_subcommand(args) for args, _ in network] == ["fetch", "fetch", "merge", "push"] assert network[2][0][-1] == upstream # the counted commit, not a name a local branch can shadow for args, kwargs in network: