The previous fix let the line-2 refresher take the marker over only AFTER
the hand-off died: it polls once a second and then waits for the A7 lock,
and until it rewrote line 1 the marker named the dead hand-off. An old
packaged Desktop (judges lines 1 and 2 alone, ignores A7 and the delegate)
reading in that gap deleted the marker and booted its backend while the
`hermes update` the hand-off started, or a completion survivor, still held
the checkout lock -- and the custodian then found no marker to keep.
Now the custodian is named on line 1 BEFORE any update work starts:
marker_refresher_start forks the refresher, then, under A7, rewrites the
claim's line 1 to the refresher's exact pid + creation time and adopts that
identity (MY_PID/MY_CT) for every later marker mutation (delegate, refresh,
release). posix.sh checks its own liveness with $$. The refresher keeps
line 2 young; if the hand-off dies it keeps the marker until the delegate
and the checkout lock are gone, then releases it. The hand-off releases the
marker before it stops the custodian, so line 1 stays live until the
release. If the handover fails (A7 busy), the old takeover-on-death path
still applies.
Test: the orchestrator-killed cell now holds the A7 lock across the SIGKILL
and runs the shipped legacy reader at once (no takeover is possible), then
keeps the post-takeover phases. Red on 5f855a2392c: {'live': None,
'kept': False}. Tests that asserted line 1 == the hand-off pid now expect
the custodian (that assertion encoded the vulnerable state).
76 lines
3.5 KiB
Python
76 lines
3.5 KiB
Python
"""Exercise the real handoff's publication barrier with owned inert processes."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
|
|
import pytest
|
|
|
|
fcntl = pytest.importorskip("fcntl")
|
|
pytestmark = pytest.mark.platforms("linux")
|
|
|
|
from tests.scripts.desktop_update.test_desktop_update_posix_handoff_protocol import _env
|
|
from tests.scripts.desktop_update.test_desktop_update_posix_marker import POSIX, _calls, _custodian, _install
|
|
|
|
|
|
@pytest.mark.parametrize("claim", ["missing", "foreign"])
|
|
def test_delegate_publication_refuses_without_our_claim(tmp_path, claim):
|
|
marker = tmp_path / "marker"
|
|
child = subprocess.Popen(["sleep", "60"])
|
|
try:
|
|
if claim == "foreign":
|
|
marker.write_text(f"{child.pid}\n{int(time.time())}\n")
|
|
original = marker.read_bytes() if marker.exists() else None
|
|
prelude = (f"log() {{ :; }}; MARKER={shlex.quote(str(marker))}; "
|
|
"DESKTOP_PID=0 HANDOFF_RUN='' MARKER_CLAIMED=1; "
|
|
f". {shlex.quote(str(POSIX.with_name('marker.sh')))}; "
|
|
f"marker_add_delegate {child.pid}")
|
|
result = subprocess.run(["bash", "-c", prelude], cwd=tmp_path, timeout=20)
|
|
assert result.returncode != 0, "a missing/foreign claim is not a published delegate"
|
|
assert (marker.read_bytes() if marker.exists() else None) == original
|
|
finally:
|
|
child.terminate()
|
|
child.wait(timeout=10)
|
|
|
|
|
|
def test_sidecar_timeout_never_releases_the_parked_update(tmp_path):
|
|
# The installer fixture is an inert executable witness, not a real Hermes
|
|
# update. Production bash, locks, child creation and barrier all run unchanged.
|
|
home, install = _install(tmp_path)
|
|
marker = home / ".hermes-update-in-progress"
|
|
log = home / "logs" / "desktop-update-handoff.log"
|
|
script = subprocess.Popen(
|
|
["bash", str(POSIX), "--daemonized", "--no-ui", "--install-root", str(install)],
|
|
env=_env(tmp_path, home), cwd=tmp_path, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
|
lock_fd = None
|
|
try:
|
|
deadline = time.monotonic() + 30
|
|
while not (marker.exists() and _custodian(home) and marker.read_text().splitlines()[0] == _custodian(home)):
|
|
assert script.poll() is None and time.monotonic() < deadline
|
|
time.sleep(0.005)
|
|
lock_fd = os.open(str(marker) + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
while "update marker lock is busy" not in (log.read_text() if log.exists() else ""):
|
|
assert script.poll() is None and time.monotonic() < deadline
|
|
time.sleep(0.01)
|
|
# Lock timeout is the actual production 10-second deadline, not a
|
|
# shortened/mocked predicate. Let the caller process the refused write.
|
|
deadline = time.monotonic() + 5
|
|
while script.poll() is None and "hermes update exit code:" not in log.read_text():
|
|
assert time.monotonic() < deadline
|
|
time.sleep(0.02)
|
|
assert not any(call.startswith("update --yes") for call in _calls(tmp_path)), _calls(tmp_path)
|
|
assert "hermes update exit code: 3" in log.read_text()
|
|
assert "delegate:" not in marker.read_text()
|
|
finally:
|
|
if lock_fd is not None:
|
|
os.close(lock_fd)
|
|
if script.poll() is None:
|
|
os.killpg(script.pid, signal.SIGKILL) # windows-footgun: ok — Linux process-group fixture
|
|
script.wait(timeout=10)
|