1006 lines
42 KiB
Python
1006 lines
42 KiB
Python
"""Cross-process update mutual exclusion (``hermes_cli.update_lock``).
|
|
|
|
Three surfaces can start an update of one install tree: a terminal ``hermes
|
|
update``, the dashboard's Update button (which spawns that same command
|
|
detached), and the desktop's Update button (Tauri updater → install-mode
|
|
bootstrap on its failure screen). Before the shared lock, two of them could run
|
|
concurrently and rewrite source under a live interpreter — observed in the wild
|
|
as an installer ``git checkout`` rewinding the checkout ~9k commits while a
|
|
dashboard-spawned ``hermes update`` was mid-``npm install``, which then failed
|
|
against the rewound tree's manifests.
|
|
|
|
These exercise the real marker file against a temp home — no mocks — because
|
|
the contract that matters is what the Rust updater and the Electron gate see on
|
|
disk.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import update_lock
|
|
from hermes_cli.update_lock import (
|
|
HANDOFF_PID_ENV,
|
|
UPDATE_MARKER_MAX_AGE_SECONDS,
|
|
UpdateLock,
|
|
checkout_lock_fds,
|
|
checkout_lock_path,
|
|
describe_holder,
|
|
process_create_time,
|
|
read_live_update,
|
|
update_in_progress,
|
|
update_marker_path,
|
|
)
|
|
|
|
# Repo root: the -I -S -B subprocesses insert it on sys.path to import the
|
|
# real hermes_cli without site-packages.
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# A pid no live process owns. os.kill(pid, 0) must report it dead so a crashed
|
|
# updater can never wedge every future update. Deliberately larger than any
|
|
# platform's pid_t so it also covers the corrupt-marker path (OverflowError).
|
|
DEAD_PID = 4294967294
|
|
|
|
|
|
@pytest.fixture
|
|
def marker(tmp_path):
|
|
return tmp_path / ".hermes-update-in-progress"
|
|
|
|
|
|
@pytest.fixture
|
|
def other_pid():
|
|
"""A live process that is not us: the stand-in for another updater (our own pid is ours)."""
|
|
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"], stdin=subprocess.DEVNULL)
|
|
yield proc.pid
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
def _claim(marker, pid, started_at=None):
|
|
"""A v1 (legacy, pid-only) claim."""
|
|
marker.write_text(f"{pid}\n{int(time.time() if started_at is None else started_at)}\n", encoding="utf-8")
|
|
|
|
|
|
def _claim_v2(marker, pid, started_at=None, ct_offset=0.0):
|
|
started = int(time.time() if started_at is None else started_at)
|
|
marker.write_text(f"{pid}\n{started}\nct:{process_create_time(pid) + ct_offset:.3f}\n", encoding="utf-8", newline="")
|
|
|
|
|
|
def test_marker_path_is_the_profile_tree_root(tmp_path, monkeypatch):
|
|
"""The lock must land where the Rust updater, Electron gate and hand-off scripts look.
|
|
|
|
They all read the ROOT home; a sticky/`-p` profile re-homes HERMES_HOME to
|
|
``<root>/profiles/<p>``, and a marker there is invisible to every other owner (V9c).
|
|
"""
|
|
root = tmp_path / "root"
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "work"))
|
|
assert update_marker_path() == root / ".hermes-update-in-progress"
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
assert update_marker_path() == root / ".hermes-update-in-progress"
|
|
|
|
|
|
def test_acquire_writes_pid_and_start_time(marker):
|
|
lock = UpdateLock(path=marker)
|
|
|
|
assert lock.acquire() is True
|
|
assert lock.acquired is True
|
|
|
|
lines = marker.read_text(encoding="utf-8-sig").splitlines()
|
|
assert int(lines[0]) == os.getpid(), (
|
|
"the Electron gate probes this pid for liveness"
|
|
)
|
|
assert int(lines[1]) == pytest.approx(time.time(), abs=5)
|
|
assert float(lines[2].removeprefix("ct:")) == pytest.approx(process_create_time(), abs=0.01), (
|
|
"line 3 is our creation time: readers tell a reused pid from us by it"
|
|
)
|
|
assert len(lines) == 3
|
|
|
|
|
|
def test_second_acquire_is_refused_while_the_first_is_live(marker, other_pid):
|
|
"""The bug: two updaters mutating one checkout at the same time."""
|
|
_claim(marker, other_pid)
|
|
|
|
second = UpdateLock(path=marker)
|
|
assert second.acquire() is False
|
|
assert second.holder is not None
|
|
assert second.holder.pid == other_pid
|
|
assert second.acquired is False
|
|
|
|
|
|
def test_refused_lock_does_not_delete_the_live_owners_marker(marker, other_pid):
|
|
_claim(marker, other_pid)
|
|
|
|
second = UpdateLock(path=marker)
|
|
second.acquire()
|
|
second.release()
|
|
|
|
assert marker.exists(), "a refused claimant must never clear the live owner's lock"
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_marker_owned_by_a_zombie_self_heals(marker):
|
|
"""A crashed updater lingering unreaped must not pin the lock for 20 minutes.
|
|
|
|
``os.kill(pid, 0)`` still succeeds for a zombie, so a marker owned by a
|
|
dead-but-unreaped stage used to read as a live update until the age ceiling
|
|
expired (#77259, #120635, #125932).
|
|
"""
|
|
pid = os.fork()
|
|
assert pid >= 0
|
|
if pid == 0:
|
|
os._exit(0) # noqa: P111 — child exits without running pytest teardown
|
|
|
|
# Keep the child unreaped (a zombie) and wait until the state probe
|
|
# actually reports 'Z' so the assertion can't race the exit.
|
|
from hermes_cli._early_recovery import _process_state
|
|
|
|
became_zombie = False
|
|
for _ in range(40):
|
|
state = _process_state(pid)
|
|
if state is not None and state.upper().startswith("Z"):
|
|
became_zombie = True
|
|
break
|
|
time.sleep(0.05)
|
|
assert became_zombie, "child never reached zombie state on this platform"
|
|
|
|
try:
|
|
_claim(marker, pid)
|
|
assert read_live_update(path=marker) is None, "a zombie is not a live update"
|
|
assert not marker.exists(), "the stale marker must self-heal (unlink)"
|
|
finally:
|
|
os.waitpid(pid, 0) # reap so the test leaks no children
|
|
|
|
|
|
def test_marker_naming_our_own_pid_is_adopted(marker, monkeypatch):
|
|
"""A killed update's marker names the pid its retry gets (containers restart pid numbering).
|
|
|
|
No other live process can hold our pid, so the claim is ours: take it instead of refusing
|
|
"another update" for up to 20 minutes. It is a new attempt, so it is claimed fresh: a
|
|
nearly-expired claim must still block a second updater for our whole run.
|
|
"""
|
|
_claim(marker, os.getpid(), time.time() - UPDATE_MARKER_MAX_AGE_SECONDS + 5)
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert lock.acquired is True
|
|
|
|
real_time = time.time
|
|
monkeypatch.setattr(time, "time", lambda: real_time() + 60)
|
|
holder = read_live_update(path=marker)
|
|
assert holder is not None and holder.pid == os.getpid(), "a second updater would start mid-run"
|
|
lock.release()
|
|
assert not marker.exists()
|
|
|
|
|
|
def test_v2_marker_naming_our_pid_at_a_nearby_creation_time_is_a_killed_update(marker):
|
|
"""A fresh pid namespace (bwrap, a container) gives the next launch the killed updater's pid
|
|
about a second after it started: inside the 2 s cross-writer skew, but not our creation time.
|
|
Read as ours, the launch adopted a dead claim and silently skipped the tail the kill owed."""
|
|
_claim_v2(marker, os.getpid(), ct_offset=-1.5)
|
|
|
|
assert read_live_update(path=marker) is None, "a dead update's claim reads as a live one"
|
|
_claim_v2(marker, os.getpid(), ct_offset=-1.5)
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True and lock.acquired is True, "adopted a killed update's claim"
|
|
assert marker.read_text(encoding="utf-8-sig").splitlines()[2] == f"ct:{process_create_time():.3f}"
|
|
lock.release()
|
|
|
|
|
|
def test_one_incarnation_rule_when_our_own_creation_time_is_unreadable(marker, monkeypatch):
|
|
"""Degraded (our creation time unreadable): we write no-ct claims, so a no-ct claim naming our
|
|
pid is ours and a ct one is a previous incarnation. The marker reader said so while the
|
|
holder-record reader answered "unprovable" and named the dead incarnation as the holder."""
|
|
from hermes_cli import update_lock
|
|
|
|
monkeypatch.setattr(update_lock, "_OWN_CT", {"pid": os.getpid(), "ct": None})
|
|
now = int(time.time())
|
|
for text, ours in ((f"{os.getpid()}\n{now}\n", True), (f"{os.getpid()}\n{now}\nct:{now - 1}.000\n", False)):
|
|
marker.write_text(text, encoding="utf-8")
|
|
parsed = update_lock._parse_marker(marker.read_bytes())
|
|
assert parsed.owner_live() is ours
|
|
assert update_lock.incarnation_live(os.getpid(), parsed.create_time) is ours, text
|
|
assert update_lock._lock_holder(marker).held is not ours, text
|
|
|
|
|
|
def test_release_leaves_a_marker_a_handoff_partner_now_owns(marker):
|
|
"""The desktop writes the marker, then the Tauri updater takes ownership.
|
|
|
|
Releasing must not delete a marker whose pid is no longer ours — that would
|
|
reopen the gate while the partner is still mid-update.
|
|
"""
|
|
lock = UpdateLock(path=marker)
|
|
lock.acquire()
|
|
|
|
marker.write_text(f"{DEAD_PID}\n{int(time.time())}\n", encoding="utf-8")
|
|
lock.release()
|
|
|
|
assert marker.exists(), "the partner's marker is not ours to remove"
|
|
|
|
|
|
def test_dead_owner_is_reclaimed_not_honored(marker):
|
|
marker.write_text(f"{DEAD_PID}\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert int(marker.read_text(encoding="utf-8-sig").splitlines()[0]) == os.getpid()
|
|
|
|
|
|
def test_v1_owner_past_the_age_ceiling_is_reclaimed(marker, other_pid):
|
|
"""A legacy pid-only marker carries no creation time: only the ceiling exposes a reused pid."""
|
|
_claim(marker, other_pid, int(time.time()) - UPDATE_MARKER_MAX_AGE_SECONDS - 60)
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True and lock.acquired is True
|
|
|
|
|
|
def test_v2_live_owner_is_live_regardless_of_age(marker, other_pid):
|
|
"""An update running 25 minutes (Windows builds, long drains) still owns the lock (V3/V10/V11)."""
|
|
_claim_v2(marker, other_pid, time.time() - 25 * 60)
|
|
before = marker.read_bytes()
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.pid == other_pid
|
|
assert read_live_update(path=marker) is not None
|
|
assert marker.read_bytes() == before, "a refused claimant never rewrites the owner's marker"
|
|
|
|
|
|
def test_v2_marker_whose_pid_was_reused_is_reclaimed(marker, other_pid):
|
|
"""A live pid whose creation time does not match the record is a recycled pid (V22)."""
|
|
_claim_v2(marker, other_pid, ct_offset=-100.0)
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True and lock.acquired is True
|
|
assert int(marker.read_text(encoding="utf-8-sig").splitlines()[0]) == os.getpid()
|
|
|
|
|
|
def test_delegate_keeps_the_claim_live_after_the_partner_dies(marker, monkeypatch, other_pid):
|
|
"""Rule 6: `hermes update` adopting a hand-off claim names itself on line 4, restores
|
|
lines 1-3 byte-identical on exit, and is what keeps the claim visible if the partner dies."""
|
|
_claim_v2(marker, other_pid)
|
|
base = marker.read_bytes()
|
|
monkeypatch.setenv(HANDOFF_PID_ENV, str(other_pid))
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True and lock.acquired is False
|
|
lines = marker.read_text(encoding="utf-8-sig").splitlines()
|
|
assert marker.read_bytes().startswith(base) and lines[3].startswith(f"delegate:{os.getpid()} ct:")
|
|
|
|
dead_partner = f"{DEAD_PID}\n{int(time.time())}\nct:1.000\n{lines[3]}\n"
|
|
marker.write_text(dead_partner, encoding="utf-8", newline="")
|
|
holder = read_live_update(path=marker)
|
|
assert holder is not None and holder.pid == os.getpid(), "the running delegate keeps the update visible"
|
|
marker.write_bytes(base + f"{lines[3]}\n".encode())
|
|
lock.release()
|
|
assert marker.read_bytes() == base, "the live partner gets its own claim back, byte-identical"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
["", "not-a-pid\n123\n", "\n\n", "12345", "{live}\n{now}.5\n", "1_0\n{now}\n", "{live}\nsoon\n"],
|
|
ids=["empty", "garbage-pid", "blank-lines", "no-start-time", "fractional-start", "underscore-pid",
|
|
"garbage-start"],
|
|
)
|
|
def test_malformed_markers_never_block_an_update(marker, body, other_pid):
|
|
"""Contract A2: line 1 and line 2 are integers or the marker is malformed, i.e. dead, in
|
|
every reader (a fractional start time used to be live in Python and dead in the others)."""
|
|
marker.write_text(body.format(live=other_pid, now=int(time.time())), encoding="utf-8")
|
|
old = time.time() - 60
|
|
os.utime(marker, (old, old)) # past the empty-file grace (A3)
|
|
|
|
assert read_live_update(path=marker) is None
|
|
assert UpdateLock(path=marker).acquire() is True
|
|
|
|
|
|
def test_marker_parse_follows_the_shared_contract(marker, other_pid):
|
|
"""Contract A2: BOM and CRLF are tolerated; a malformed ct line makes the marker v1 (so the
|
|
age ceiling applies); only an exact line-4 delegate counts."""
|
|
now, ct = int(time.time()), process_create_time(other_pid)
|
|
live = {
|
|
"bom": f"\ufeff{other_pid}\n{now}\nct:{ct:.3f}\n",
|
|
"crlf": f"{other_pid}\r\n{now}\r\nct:{ct:.3f}\r\n",
|
|
"bad-ct-fresh": f"{other_pid}\n{now}\nct:abc\n",
|
|
}
|
|
dead = {
|
|
"bad-ct-aged": f"{other_pid}\n{now - UPDATE_MARKER_MAX_AGE_SECONDS - 300}\nct:abc\n",
|
|
"spaced-delegate": f"{DEAD_PID}\n{now}\nct:1.000\ndelegate: {other_pid} ct:{ct:.3f}\n",
|
|
"delegate-on-line-3": f"{DEAD_PID}\n{now}\ndelegate:{other_pid} ct:{ct:.3f}\n",
|
|
}
|
|
for name, body in {**live, **dead}.items():
|
|
marker.write_text(body, encoding="utf-8", newline="")
|
|
holder = read_live_update(path=marker)
|
|
assert (holder is not None) == (name in live), name
|
|
|
|
|
|
def test_fresh_empty_marker_is_a_claim_in_flight(marker):
|
|
"""Contract A3: a writer without hard links publishes create-then-write; an empty marker that
|
|
young is someone's claim in progress, so it is neither deleted nor taken over."""
|
|
marker.write_bytes(b"")
|
|
|
|
holder = read_live_update(path=marker)
|
|
lock = UpdateLock(path=marker)
|
|
assert holder is not None and holder.pid == 0
|
|
assert lock.acquire() is False and marker.read_bytes() == b""
|
|
|
|
|
|
def test_claim_publishes_whole_and_reclaims_dead_writers_tmp_files(marker):
|
|
"""Contract A3 + litter: the claim lands complete (tmp + exclusive link) and leaves no tmp;
|
|
a tmp left by a writer that died between write and publish is reclaimed, a live one is kept."""
|
|
dead_tmp = marker.with_name(f"{marker.name}.{DEAD_PID}.tmp")
|
|
mine = marker.with_name(f"{marker.name}.{os.getpid()}.ab12.tmp")
|
|
dead_tmp.write_text("x")
|
|
mine.write_text("x")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert marker.read_text(encoding="utf-8").startswith(f"{os.getpid()}\n")
|
|
leftovers = sorted(p.name for p in marker.parent.iterdir() if p.name.endswith(".tmp"))
|
|
assert leftovers == [mine.name]
|
|
lock.release()
|
|
|
|
|
|
def test_stale_tmp_under_our_own_pid_number_is_reclaimed(marker):
|
|
"""Containers hand out the same pids every boot: a tmp a previous holder of our pid number
|
|
left is litter once it is older than any write of ours, while a fresh one may be in flight."""
|
|
stale = marker.with_name(f"{marker.name}.{os.getpid()}.deadbeef.tmp")
|
|
fresh = marker.with_name(f"{marker.name}.{os.getpid()}.cafe.tmp")
|
|
stale.write_bytes(b"x")
|
|
fresh.write_bytes(b"x")
|
|
hour_ago = time.time() - 3600
|
|
os.utime(stale, (hour_ago, hour_ago))
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
lock.release()
|
|
assert not stale.exists()
|
|
assert fresh.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("owner", ["\u00b2", "\u2460"], ids=["superscript-two", "circled-one"])
|
|
def test_tmp_litter_with_a_digit_lookalike_pid_never_breaks_admission(marker, owner):
|
|
"""A sibling whose pid field is a Unicode digit lookalike is not a pid we can judge: it is
|
|
left alone and the claim still lands (``str.isdigit`` accepts it, ``int`` refuses it)."""
|
|
odd = marker.with_name(f"{marker.name}.{owner}.tmp")
|
|
odd.write_bytes(b"x")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert marker.read_bytes().startswith(f"{os.getpid()}\n".encode())
|
|
lock.release()
|
|
|
|
|
|
def _torn_write(after=None):
|
|
"""An ``os.write`` that lands part of the claim, then fails like a full disk."""
|
|
real_write = os.write
|
|
|
|
def torn(fd, data):
|
|
real_write(fd, data[: len(data) // 2])
|
|
if after is not None:
|
|
after()
|
|
raise OSError(errno.ENOSPC, "No space left on device")
|
|
|
|
return torn
|
|
|
|
|
|
def _no_hard_links(*_args, **_kwargs):
|
|
raise PermissionError(errno.EPERM, "Operation not permitted")
|
|
|
|
|
|
def test_failed_exclusive_create_leaves_no_torn_claim(marker, monkeypatch):
|
|
"""Contract A3 without hard links (FAT, some network mounts): a claim whose write fails
|
|
part-way is withdrawn — a process that never acquired must not block every updater while
|
|
it lives."""
|
|
monkeypatch.setattr(os, "link", _no_hard_links)
|
|
monkeypatch.setattr(os, "write", _torn_write())
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is False
|
|
monkeypatch.undo()
|
|
assert not marker.exists()
|
|
assert read_live_update(path=marker) is None
|
|
|
|
|
|
@pytest.mark.platforms("posix") # replacing a file someone holds open needs POSIX unlink
|
|
def test_withdrawing_a_torn_claim_never_deletes_a_replacement(marker, monkeypatch, other_pid):
|
|
"""The withdrawal deletes only the inode it created: a claimant that reclaimed the torn
|
|
marker and published its own in between keeps its claim."""
|
|
def replace():
|
|
marker.unlink()
|
|
_claim_v2(marker, other_pid)
|
|
|
|
monkeypatch.setattr(os, "link", _no_hard_links)
|
|
monkeypatch.setattr(os, "write", _torn_write(replace))
|
|
assert UpdateLock(path=marker).acquire() is False
|
|
monkeypatch.undo()
|
|
assert marker.read_bytes().startswith(f"{other_pid}\n".encode())
|
|
|
|
|
|
def test_unreadable_creation_time_gets_the_v1_ceiling(marker, other_pid, monkeypatch):
|
|
"""Contract A1: a live pid whose creation time cannot be read (Windows denies it for
|
|
elevated/other-user pids) is live only within the legacy ceiling: it may be a reused pid."""
|
|
from hermes_cli import update_lock
|
|
|
|
_claim_v2(marker, other_pid)
|
|
fresh = marker.read_text(encoding="utf-8")
|
|
monkeypatch.setattr(update_lock, "process_create_time", lambda pid=None: None)
|
|
assert read_live_update(path=marker) is not None
|
|
_claim_v2(marker, other_pid, started_at=time.time() - UPDATE_MARKER_MAX_AGE_SECONDS - 300)
|
|
monkeypatch.undo()
|
|
aged = marker.read_text(encoding="utf-8")
|
|
assert read_live_update(path=marker) is not None, "a matching creation time is live at any age"
|
|
monkeypatch.setattr(update_lock, "process_create_time", lambda pid=None: None)
|
|
assert read_live_update(path=marker) is None and not marker.exists(), (fresh, aged)
|
|
|
|
|
|
def test_stale_marker_is_removed_on_read(marker):
|
|
marker.write_text(f"{DEAD_PID}\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
assert read_live_update(path=marker) is None
|
|
assert not marker.exists(), "whoever notices a stale marker clears it"
|
|
|
|
|
|
def test_absent_marker_reports_no_live_update(marker):
|
|
assert read_live_update(path=marker) is None
|
|
|
|
|
|
def test_context_manager_releases_even_on_exception(marker):
|
|
with pytest.raises(RuntimeError):
|
|
with UpdateLock(path=marker) as lock:
|
|
assert lock.acquired is True
|
|
raise RuntimeError("update blew up mid-flight")
|
|
|
|
assert not marker.exists(), "a crashed update must not strand the lock"
|
|
|
|
|
|
def test_describe_holder_names_the_pid_and_elapsed_time(marker):
|
|
lock = UpdateLock(path=marker)
|
|
lock.acquire()
|
|
|
|
holder = read_live_update(path=marker)
|
|
assert holder is not None
|
|
message = describe_holder(holder)
|
|
|
|
assert str(os.getpid()) in message, (
|
|
"the user needs the pid to find the other update"
|
|
)
|
|
assert "already running" in message
|
|
|
|
|
|
def test_unwritable_marker_location_refuses_instead_of_running_unlocked(tmp_path):
|
|
"""No "proceed unlocked": an update that cannot claim the lock cannot exclude another."""
|
|
lock = UpdateLock(path=tmp_path / "nonexistent-file" / "marker")
|
|
(tmp_path / "nonexistent-file").write_text(
|
|
"i am a file, not a dir", encoding="utf-8"
|
|
)
|
|
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.reason
|
|
assert "Cannot lock this install" in describe_holder(lock.holder)
|
|
|
|
|
|
|
|
def test_an_unwritable_marker_location_says_why_the_update_needs_it_and_how_to_fix_it(tmp_path):
|
|
"""Review P2 (5411135165): the refusal stays (the marker is what the Desktop gate, gateways and
|
|
the other updaters read), so it must say what the path is for and how to make it writable."""
|
|
blocker = tmp_path / "home"
|
|
blocker.write_bytes(b"a file where the marker directory should be")
|
|
lock = UpdateLock(path=blocker / "marker")
|
|
|
|
assert lock.acquire() is False
|
|
message = describe_holder(lock.holder)
|
|
assert str(blocker) in message
|
|
assert "Desktop app" in message and "other updaters" in message, message
|
|
assert "read-only filesystem" in message, message
|
|
|
|
_HOLD_CHECKOUT = """
|
|
import sys, time
|
|
sys.path.insert(0, sys.argv[1])
|
|
from hermes_cli.update_lock import UpdateLock
|
|
lock = UpdateLock(path=__import__("pathlib").Path(sys.argv[3]), install_root=sys.argv[2])
|
|
assert lock.acquire()
|
|
print("held", flush=True)
|
|
time.sleep(120)
|
|
"""
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_checkout_lock_excludes_an_update_from_another_home(tmp_path):
|
|
"""Two `hermes update` runs on one checkout from different homes exclude each other (V3)."""
|
|
install = tmp_path / "checkout"
|
|
install.mkdir()
|
|
holder = subprocess.Popen(
|
|
[sys.executable, "-c", _HOLD_CHECKOUT, str(REPO_ROOT), str(install), str(tmp_path / "homeA" / "m")],
|
|
stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True,
|
|
)
|
|
try:
|
|
assert holder.stdout.readline().strip() == "held"
|
|
lock = UpdateLock(path=tmp_path / "homeB-marker", install_root=install)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.pid == holder.pid
|
|
assert not (tmp_path / "homeB-marker").exists()
|
|
assert update_in_progress(install)
|
|
finally:
|
|
holder.kill()
|
|
holder.wait()
|
|
assert not update_in_progress(install), "the kernel frees the lock with its holder"
|
|
lock = UpdateLock(path=tmp_path / "homeB-marker", install_root=install)
|
|
assert lock.acquire() is True
|
|
lock.release()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_an_unjudgeable_marker_never_admits_a_contender_past_a_held_checkout_lock(tmp_path, monkeypatch):
|
|
"""L5: read_live_update never raises, so a marker it cannot judge (any exception) reads as
|
|
"no live update". That fails open on the marker only: the checkout kernel lock stays the
|
|
guard, so update_in_progress still answers True and a contender's acquire is refused."""
|
|
from hermes_cli import update_lock
|
|
|
|
install = tmp_path / "checkout"
|
|
install.mkdir()
|
|
marker_path = tmp_path / "marker"
|
|
holder = subprocess.Popen(
|
|
[sys.executable, "-c", _HOLD_CHECKOUT, str(REPO_ROOT), str(install), str(tmp_path / "homeA" / "m")],
|
|
stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True,
|
|
)
|
|
try:
|
|
assert holder.stdout.readline().strip() == "held"
|
|
_claim_v2(marker_path, holder.pid)
|
|
|
|
def unjudgeable(_path):
|
|
raise RuntimeError("an error the parse was never expected to raise")
|
|
|
|
monkeypatch.setattr(update_lock, "_read_marker", unjudgeable)
|
|
assert read_live_update(path=marker_path, install_root=install) is None
|
|
assert update_in_progress(install), "an unjudgeable marker hid a held checkout lock"
|
|
lock = UpdateLock(path=marker_path, install_root=install)
|
|
assert lock.acquire() is False, "a contender got past a held checkout lock"
|
|
finally:
|
|
holder.kill()
|
|
holder.wait()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_checkout_lock_outlives_its_owner_while_an_inheriting_child_runs(tmp_path):
|
|
"""Invariant: lock free => no process of the update tree alive (pass_fds children)."""
|
|
install = tmp_path / "checkout"
|
|
install.mkdir()
|
|
lock = UpdateLock(path=tmp_path / "marker", install_root=install)
|
|
assert lock.acquire() is True
|
|
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"],
|
|
pass_fds=checkout_lock_fds(install), stdin=subprocess.DEVNULL)
|
|
try:
|
|
lock.release()
|
|
assert update_in_progress(install), "the owner's exit must not free a lock its child holds"
|
|
finally:
|
|
child.kill()
|
|
child.wait()
|
|
assert not update_in_progress(install)
|
|
assert checkout_lock_path(install).name == ".hermes-update.lock" # no .git: a ZIP install
|
|
|
|
|
|
def test_git_checkout_lock_lives_in_the_common_git_dir_and_never_dirties_the_tree(tmp_path):
|
|
"""A held lock must never show in `git status`: the updater's autostash would stash its own
|
|
lock file in any tree whose .gitignore lacks it (fixtures, forks, a branch switch)."""
|
|
install = tmp_path / "checkout"
|
|
env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
|
|
|
|
def git(*args, cwd=install):
|
|
return subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True,
|
|
text=True, encoding="utf-8").stdout
|
|
|
|
install.mkdir()
|
|
git("init", "-q")
|
|
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "one")
|
|
linked = tmp_path / "linked"
|
|
git("worktree", "add", "-q", "--detach", str(linked))
|
|
common = Path(os.path.normpath(install / ".git"))
|
|
assert checkout_lock_path(install) == common / "hermes-update.lock"
|
|
assert checkout_lock_path(linked) == common / "hermes-update.lock"
|
|
|
|
lock = UpdateLock(path=tmp_path / "marker", install_root=install)
|
|
assert lock.acquire() is True
|
|
try:
|
|
assert git("status", "--porcelain", "--untracked-files=all") == ""
|
|
assert update_in_progress(linked), "a linked worktree shares the repository's lock"
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
def test_unwritable_install_root_refuses(tmp_path):
|
|
"""A checkout we cannot write is refused through the exit-2 path, never updated unlocked."""
|
|
install = tmp_path / "not-a-dir"
|
|
install.write_text("file", encoding="utf-8")
|
|
lock = UpdateLock(path=tmp_path / "marker", install_root=install)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.reason
|
|
assert not (tmp_path / "marker").exists()
|
|
|
|
|
|
class TestHandoffFromOrchestratingUpdater:
|
|
"""The Tauri updater holds the marker, then spawns ``hermes update``.
|
|
|
|
The regression: the child saw its own parent's live marker and exited 2,
|
|
so every GUI update failed with "Hermes is still running" and retrying
|
|
just re-ran the same self-deadlock. The parent names its pid in
|
|
HANDOFF_PID_ENV; a live holder matching it is our own orchestrator.
|
|
"""
|
|
|
|
def test_child_runs_under_the_parents_live_claim(self, marker, monkeypatch, other_pid):
|
|
# other_pid stands in for the live parent updater.
|
|
_claim(marker, other_pid)
|
|
monkeypatch.setenv(HANDOFF_PID_ENV, str(other_pid))
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert lock.acquired is False, "the parent's claim is not ours to own"
|
|
|
|
lock.release()
|
|
assert marker.exists(), "the parent still needs its marker after our stage ends"
|
|
assert int(marker.read_text(encoding="utf-8-sig").splitlines()[0]) == other_pid
|
|
|
|
def test_handoff_pid_that_is_not_the_live_holder_grants_nothing(
|
|
self, marker, monkeypatch, other_pid
|
|
):
|
|
"""The env var alone must not bypass the lock."""
|
|
_claim(marker, other_pid)
|
|
monkeypatch.setenv(HANDOFF_PID_ENV, str(other_pid + 1))
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["", "not-a-pid", "-1", "0"],
|
|
ids=["empty", "garbage", "negative", "zero"],
|
|
)
|
|
def test_malformed_handoff_values_fall_back_to_refusal(
|
|
self, marker, monkeypatch, value, other_pid
|
|
):
|
|
_claim(marker, other_pid)
|
|
monkeypatch.setenv(HANDOFF_PID_ENV, value)
|
|
|
|
assert UpdateLock(path=marker).acquire() is False
|
|
|
|
def test_handoff_env_with_no_marker_claims_normally(self, marker, monkeypatch):
|
|
"""A handoff pid must not stop us writing our own claim when unlocked."""
|
|
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True
|
|
assert lock.acquired is True
|
|
assert int(marker.read_text(encoding="utf-8-sig").splitlines()[0]) == os.getpid()
|
|
|
|
|
|
class TestAncestryHandoff:
|
|
"""Staged updaters older than the HANDOFF_PID_ENV export never send it.
|
|
|
|
``hermes-setup`` under ``~/.hermes`` is only refreshed by a full installer
|
|
run, so an updated checkout (new lock) driven by a pre-handoff staged
|
|
updater (old parent) deadlocks on exit 2 forever unless the child also
|
|
recognizes a live holder that is its own process ancestor.
|
|
|
|
``_pid_alive`` is pinned True here because the hermetic conftest guards
|
|
``os.kill`` probes of pids outside the test subtree (our ppid included);
|
|
liveness has its own coverage above — ancestry is what's under test.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _liveness_pinned_true(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.update_lock._pid_alive", lambda pid: True)
|
|
|
|
def test_marker_owned_by_our_parent_process_is_our_orchestrator(self, marker):
|
|
marker.write_text(f"{os.getppid()}\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True, "a live ancestor's claim is the one we run under"
|
|
assert lock.acquired is False, "the parent's claim is not ours to own"
|
|
|
|
lock.release()
|
|
assert marker.exists(), "the parent still needs its marker after our stage ends"
|
|
|
|
@pytest.mark.platforms("any")
|
|
def test_grandchild_adopts_orchestrator_marker_without_psutil(self, marker, tmp_path):
|
|
"""Regression: the desktop hand-off's grandchild refused its own orchestrator.
|
|
|
|
The posix shim (grandparent) holds the marker and spawns ``hermes update``
|
|
(direct child — adopts via getppid). An old-updater update into a PM tree
|
|
then hands off again: ``_old_updater._run_child`` spawns
|
|
``_update_takeover.py`` as ``python -I -S -B``, where psutil cannot import
|
|
(-S skips site-packages). The psutil-only ancestry walk returned False for
|
|
the two-hops-up shim, and the takeover child refused with exit 2 —
|
|
"Another Hermes update is already running (PID <the shim itself>)" —
|
|
observed live on a macOS rehearsal install, then again on Windows, where
|
|
the stdlib walk had no /proc and no ps. Marked for every lane: the Windows
|
|
lane only imports files carrying a platforms marker, which is how the
|
|
Windows half went unseen. The stdlib fallback walk is what must adopt here.
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
from textwrap import dedent
|
|
|
|
# Simulate the orchestrator: this test process holds the marker and
|
|
# spawns the -I -B middle, which spawns the -I -S -B takeover-like leaf.
|
|
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
leaf = dedent(
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, %(root)r)
|
|
from hermes_cli.update_lock import UpdateLock
|
|
lock = UpdateLock(path=Path(%(marker)r))
|
|
if not lock.acquire():
|
|
print("REFUSED", lock.holder.pid)
|
|
raise SystemExit(2)
|
|
assert lock.acquired is False, "the orchestrator's claim is not ours to own"
|
|
print("ADOPTED")
|
|
"""
|
|
) % {"root": str(REPO_ROOT), "marker": str(marker)}
|
|
middle = dedent(
|
|
"""
|
|
import subprocess, sys
|
|
code = subprocess.run(
|
|
[sys.executable, "-I", "-S", "-B", "-c", %(leaf)r],
|
|
).returncode
|
|
raise SystemExit(code)
|
|
"""
|
|
) % {"leaf": leaf}
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-I", "-B", "-c", middle],
|
|
capture_output=True, text=True, timeout=120,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "ADOPTED" in result.stdout
|
|
assert marker.exists(), "the orchestrator still needs its marker after the leaf ends"
|
|
|
|
@pytest.mark.platforms("any")
|
|
def test_unrelated_live_holder_is_still_refused_under_stdlib_walk(self, marker, tmp_path):
|
|
"""The stdlib fallback must not widen the lock: a foreign pid stays foreign.
|
|
|
|
The marker owner is a live *sibling* of the leaf (a sleeper spawned by
|
|
this test), never an ancestor of it — the shape of an unrelated
|
|
concurrent updater. The leaf's ancestry walk dead-ends at pytest and
|
|
the sibling must keep the lock.
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
import time as time_mod
|
|
from textwrap import dedent
|
|
|
|
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
|
|
try:
|
|
marker.write_text(f"{sleeper.pid}\n{int(time_mod.time())}\n", encoding="utf-8")
|
|
assert read_live_update(path=marker) is not None
|
|
|
|
leaf = dedent(
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, %(root)r)
|
|
from hermes_cli.update_lock import UpdateLock
|
|
lock = UpdateLock(path=Path(%(marker)r))
|
|
print("ADOPTED" if lock.acquire() else "REFUSED")
|
|
"""
|
|
) % {"root": str(REPO_ROOT), "marker": str(marker)}
|
|
result = subprocess.run(
|
|
[sys.executable, "-I", "-S", "-B", "-c", leaf],
|
|
capture_output=True, text=True, timeout=120,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "REFUSED" in result.stdout
|
|
finally:
|
|
sleeper.kill()
|
|
sleeper.wait()
|
|
|
|
def test_live_non_ancestor_holder_is_still_refused(self, marker):
|
|
"""Ancestry must not open the lock to unrelated concurrent updaters."""
|
|
marker.write_text(f"{DEAD_PID}\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None
|
|
assert lock.holder.pid == DEAD_PID
|
|
|
|
|
|
class _FakeProcess:
|
|
"""One link of a stubbed parent chain.
|
|
|
|
``error`` makes this link refuse inspection, standing in for a process the
|
|
sandbox will not let us read (psutil raises ``AccessDenied`` there).
|
|
"""
|
|
|
|
def __init__(self, pid, parent=None, error=None):
|
|
self.pid = pid
|
|
self._parent = parent
|
|
self._error = error
|
|
|
|
def parent(self):
|
|
if self._error is not None:
|
|
raise self._error
|
|
return self._parent
|
|
|
|
|
|
def _pin_ancestry(monkeypatch, leaf):
|
|
"""Make ``psutil.Process()`` return the leaf of a stubbed chain."""
|
|
psutil = pytest.importorskip("psutil")
|
|
monkeypatch.setattr(psutil, "Process", lambda *a, **k: leaf)
|
|
|
|
|
|
def _chain(*pids, blocked_above=False):
|
|
"""Build us -> pids[0] -> pids[1] ... innermost first.
|
|
|
|
``blocked_above`` caps the chain with a link that raises instead of
|
|
reporting its own parent — the sandboxed ``/proc/1`` case.
|
|
"""
|
|
psutil = pytest.importorskip("psutil")
|
|
top = _FakeProcess(1, error=psutil.AccessDenied(pid=1)) if blocked_above else None
|
|
node = top
|
|
for pid in reversed(pids):
|
|
node = _FakeProcess(pid, parent=node)
|
|
return _FakeProcess(os.getpid(), parent=node)
|
|
|
|
|
|
class TestAncestryUnderUnreadableProcesses:
|
|
"""Regression: #87514 — an unreadable process ABOVE the orchestrator.
|
|
|
|
``psutil.Process.parents()`` builds the whole chain before returning and
|
|
only tolerates ``NoSuchProcess`` per link, so one ``AccessDenied`` high up
|
|
threw away the ancestors already found. Under firejail with
|
|
``ptrace_scope=1`` (and in hardened containers) ``/proc/1`` is unreadable,
|
|
so every desktop update refused its own orchestrator's fresh marker and
|
|
exited 2 forever. Ancestry must be decided link by link.
|
|
"""
|
|
|
|
def test_ancestor_below_an_unreadable_process_is_still_found(self, monkeypatch):
|
|
from hermes_cli.update_lock import _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(2000, blocked_above=True))
|
|
|
|
assert _is_ancestor_pid(2000) is True, (
|
|
"the orchestrator is one link up; a process we cannot read above "
|
|
"it must not erase a match already proven"
|
|
)
|
|
|
|
def test_deeper_ancestor_below_an_unreadable_process_is_found(self, monkeypatch):
|
|
from hermes_cli.update_lock import _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(2000, 3000, blocked_above=True))
|
|
|
|
assert _is_ancestor_pid(3000) is True
|
|
|
|
def test_unreadable_process_below_the_match_still_refuses(self, monkeypatch):
|
|
"""Failing before a match keeps the conservative refusal."""
|
|
from hermes_cli.update_lock import _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(blocked_above=True))
|
|
|
|
assert _is_ancestor_pid(2000) is False
|
|
|
|
def test_unrelated_pid_is_refused_on_a_fully_readable_chain(self, monkeypatch):
|
|
from hermes_cli.update_lock import _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(2000, 3000))
|
|
|
|
assert _is_ancestor_pid(DEAD_PID) is False
|
|
|
|
def test_our_own_pid_is_never_an_ancestor(self, monkeypatch):
|
|
from hermes_cli.update_lock import _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(2000))
|
|
|
|
assert _is_ancestor_pid(os.getpid()) is False
|
|
|
|
def test_walk_is_bounded(self, monkeypatch):
|
|
"""A pathological chain terminates instead of spinning."""
|
|
from hermes_cli.update_lock import _MAX_ANCESTRY_DEPTH, _is_ancestor_pid
|
|
|
|
_pin_ancestry(monkeypatch, _chain(*range(2000, 2000 + _MAX_ANCESTRY_DEPTH * 2)))
|
|
|
|
assert _is_ancestor_pid(2000 + _MAX_ANCESTRY_DEPTH * 2 - 1) is False
|
|
|
|
def test_acquire_accepts_the_orchestrator_behind_an_unreadable_init(
|
|
self, marker, monkeypatch
|
|
):
|
|
"""The reporter's end-to-end symptom: exit 2 on every GUI update."""
|
|
monkeypatch.setattr("hermes_cli.update_lock._pid_alive", lambda pid: True)
|
|
_pin_ancestry(monkeypatch, _chain(2000, blocked_above=True))
|
|
marker.write_text(f"2000\n{int(time.time())}\n", encoding="utf-8")
|
|
|
|
lock = UpdateLock(path=marker)
|
|
assert lock.acquire() is True, "the hand-off child runs under 2000's claim"
|
|
assert lock.acquired is False, "the orchestrator's claim is not ours to own"
|
|
|
|
lock.release()
|
|
assert marker.exists(), "the orchestrator still needs its marker"
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root writes any file")
|
|
def test_unwritable_lock_file_still_locks_the_checkout(tmp_path):
|
|
"""Contract A5: a `sudo hermes update` leaves the lock file root-owned; later updates take the
|
|
kernel lock on a read-only fd instead of refusing forever, and still exclude each other."""
|
|
root = tmp_path / "install"
|
|
root.mkdir()
|
|
lock_file = checkout_lock_path(root)
|
|
lock_file.write_text("0\n0\n")
|
|
lock_file.chmod(0o444)
|
|
|
|
first = UpdateLock(path=tmp_path / "a" / ".hermes-update-in-progress", install_root=root)
|
|
assert first.acquire() is True, first.holder
|
|
second = subprocess.run(
|
|
[sys.executable, "-c",
|
|
"import sys; from hermes_cli.update_lock import UpdateLock; "
|
|
"sys.exit(0 if UpdateLock(path=__import__('pathlib').Path(sys.argv[1]), install_root=sys.argv[2]).acquire() else 3)",
|
|
str(tmp_path / "b" / ".hermes-update-in-progress"), str(root)],
|
|
cwd=REPO_ROOT, env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}, stdin=subprocess.DEVNULL, timeout=60,
|
|
check=False,
|
|
)
|
|
first.release()
|
|
assert second.returncode == 3, "a second update ran while the read-only-locked checkout was held"
|
|
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_read_only_lock_file_is_refused_on_windows(tmp_path):
|
|
"""Windows children cannot inherit the lock; they find their owner by the record in it. An
|
|
owner that can only open the lock file read-only refuses up front instead of admitting
|
|
itself and then having its own completion child refused."""
|
|
root = tmp_path / "install"
|
|
root.mkdir()
|
|
lock_file = checkout_lock_path(root)
|
|
lock_file.write_bytes(b"0\n0\n")
|
|
lock_file.chmod(0o444) # FILE_ATTRIBUTE_READONLY: O_RDWR is denied, the read-only open works
|
|
marker = tmp_path / "home" / ".hermes-update-in-progress"
|
|
try:
|
|
lock = UpdateLock(path=marker, install_root=root)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.reason
|
|
assert not marker.exists()
|
|
finally:
|
|
lock_file.chmod(0o666)
|
|
|
|
|
|
@pytest.mark.parametrize("link", [pytest.param("symlink", marks=pytest.mark.platforms("posix")), "link"])
|
|
def test_checkout_lock_never_writes_through_a_link_to_a_file_outside_the_install(tmp_path, link):
|
|
"""The holder record is written into the lock file itself: a symlink or hard link planted at
|
|
its name is refused, never followed into truncating the file it points at."""
|
|
root = tmp_path / "install"
|
|
root.mkdir()
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_bytes(b"PRECIOUS USER DATA")
|
|
getattr(os, link)(outside, checkout_lock_path(root))
|
|
|
|
lock = UpdateLock(path=tmp_path / "home" / ".hermes-update-in-progress", install_root=root)
|
|
assert lock.acquire() is False
|
|
assert lock.holder is not None and lock.holder.reason
|
|
assert outside.read_bytes() == b"PRECIOUS USER DATA"
|
|
|
|
|
|
def test_whole_second_creation_time_in_our_own_second_is_us(marker):
|
|
"""The macOS hand-off shell writes `ct:<secs>.000` (ps lstart has no sub-second part) for the
|
|
update child it names as delegate. That is our incarnation, so the child adopts the claim
|
|
instead of refusing its own hand-off as "another update"."""
|
|
import math
|
|
|
|
own = process_create_time()
|
|
assert update_lock.incarnation_live(os.getpid(), float(math.floor(own))) is True
|
|
|
|
parent = os.getppid()
|
|
marker.write_text(
|
|
f"{parent}\n{int(time.time())}\nct:{math.floor(process_create_time(parent))}.000\n"
|
|
f"delegate:{os.getpid()} ct:{math.floor(own)}.000\n",
|
|
encoding="utf-8", newline="",
|
|
)
|
|
assert update_lock.judge_marker(marker.read_bytes())[0] == "ours"
|
|
# A whole second from an EARLIER second is a killed update's pid reused: still not us.
|
|
assert update_lock.incarnation_live(os.getpid(), float(math.floor(own)) - 1) is False
|