From c0c51036fcf9462ee448b515f2222331ae5b623a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 25 Sep 2026 15:05:57 +0200 Subject: [PATCH] phone_lease: per-app leases, a self-cleaning whole-phone lease, one serial Several sessions can now share one phone: a lease covers one package (the real screen stays the default scope, so existing callers keep their meaning), and only phone-wide actions take `--whole-phone`, which is capped at 300 s and runs its `--cleanup` commands exactly once on release or, via a detached reaper, on expiry. The serial is always resolved, never the "default" placeholder that let android_ui and phone_deploy lease different keys for the same phone (2026-09-25). Adds status --json, holds and whoami for phone_vd.sh and the raw-adb hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Ajzz98xksbfak8CaunZWC --- .../phone-lease-scopes-2026-09-25.json | 14 ++ .../phone-lease-scopes-2026-09-25.json | 20 ++ python_pkg/phone_lease/__init__.py | 46 ++-- python_pkg/phone_lease/__main__.py | 102 ++++++-- python_pkg/phone_lease/_cleanup.py | 69 ++++++ python_pkg/phone_lease/lease.py | 234 +++++++++++------- python_pkg/phone_lease/ops.py | 103 ++++++++ python_pkg/phone_lease/owner.py | 36 +++ python_pkg/phone_lease/serial.py | 62 +++++ .../phone_lease/tests/test_cleanup_serial.py | 116 +++++++++ python_pkg/phone_lease/tests/test_lease.py | 60 ++++- python_pkg/phone_lease/tests/test_main.py | 62 ++++- python_pkg/phone_lease/tests/test_scopes.py | 200 +++++++++++++++ 13 files changed, 979 insertions(+), 145 deletions(-) create mode 100644 docs/superpowers/contracts/phone-lease-scopes-2026-09-25.json create mode 100644 docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json create mode 100644 python_pkg/phone_lease/_cleanup.py create mode 100644 python_pkg/phone_lease/ops.py create mode 100644 python_pkg/phone_lease/owner.py create mode 100644 python_pkg/phone_lease/serial.py create mode 100644 python_pkg/phone_lease/tests/test_cleanup_serial.py create mode 100644 python_pkg/phone_lease/tests/test_scopes.py diff --git a/docs/superpowers/contracts/phone-lease-scopes-2026-09-25.json b/docs/superpowers/contracts/phone-lease-scopes-2026-09-25.json new file mode 100644 index 00000000..b69d6973 --- /dev/null +++ b/docs/superpowers/contracts/phone-lease-scopes-2026-09-25.json @@ -0,0 +1,14 @@ +{ + "title": "phone_lease: per-app leases, a short self-cleaning whole-phone lease, one serial", + "objective": "Two sessions each drive a different app on their own virtual display at the same time, only phone-wide actions take the whole phone, and a forgotten forced-Doze cannot outlive five minutes.", + "acceptance_criteria": [ + "Package leases on different apps coexist; the same app is exclusive (exit 3).", + "A whole-phone lease conflicts with every foreign lease in both directions and is clamped to 300 s.", + "--cleanup commands run exactly once, on release or on expiry, with no holder action needed.", + "`holds` answers exit 0/1 for this owner and a scope (whole-phone implies every scope); `status --json` lists all live leases.", + "No caller ever leases a placeholder serial.", + "phone_deploy.sh and android_ui keep working unchanged." + ], + "out_of_scope": ["phone_deploy virtual-display default, android_ui --display, the raw-adb hook (other session)."], + "verifier": "CLI scenario on a temp lease dir; two concurrent phone_vd displays on the real phone; pytest python_pkg/phone_lease 65 passed at 100% branch coverage." +} diff --git a/docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json b/docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json new file mode 100644 index 00000000..bf4055d2 --- /dev/null +++ b/docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json @@ -0,0 +1,20 @@ +{ + "intent": "Several Claude sessions can use one phone at once without interrupting each other: leases are per app, a whole-phone lease is short and cleans up after itself, and every caller agrees on the serial.", + "scope": ["python_pkg/phone_lease only (lease, ops, owner, serial, _cleanup, CLI, tests)", "Callers unchanged: phone_deploy.sh's `acquire \"$SERIAL\" --note --wait` and android_ui's acquire(serial, note) keep the real-screen scope", "phone_deploy/android_ui/raw-adb hook changes belong to the image-attachments-todo-dufs session"], + "changes": [ + "Scopes: pkg: (default pkg:__screen__) and whole-phone; per-package leases coexist, whole-phone conflicts with every foreign lease", + "whole-phone ttl capped at 300 s; --cleanup commands run exactly once on release or expiry, expiry enforced by a detached `reap` process that wakes at least every 5 s", + "One JSON file per phone holds all leases; pre-scope single-object files are read as a screen lease", + "resolve_serial: explicit, $ANDROID_SERIAL, $ADB_SERIAL, `adb get-serialno`; never a 'default' placeholder (the cause of the 2026-09-25 clash)", + "CLI: optional serial, --package/--whole-phone/--cleanup, status --json, holds, whoami; exit 4 when no phone", + "acquire(serial, note, *, scope, terms=Terms(ttl, wait, cleanup), owner, clock) to stay within max-args 6" + ], + "verification": [ + {"command": "two owners via PHONE_LEASE_OWNER against a temp PHONE_LEASE_DIR", "result": "pass", "evidence": "pkg:a + pkg:b coexist (0 0); B on pkg:a -> 3; B whole-phone while A holds a package -> 3; ttl 999 clamped to +300 s; release ran cleanup; unreleased whole-phone ttl 3 refreshed at +2 s was reaped only after the refreshed expiry"}, + {"command": "~/.claude/scripts/phone_vd.sh start as testA (umbrella) and testB (settings) on the real phone", "result": "pass", "evidence": "displays 7 and 8 side by side; tap on 7 changed only 7; display 0 unchanged; third owner on umbrella exit 43; stop released both package leases"}, + {"command": "pytest python_pkg/phone_lease --cov --cov-branch", "result": "pass", "evidence": "65 passed; every phone_lease module 100% line and branch"}, + {"command": "pre-commit run --files ", "result": "pass", "evidence": "all hooks passed"} + ], + "risks": ["A reaper killed before expiry leaves cleanup to the next locked update of that phone's lease file, which runs it then."], + "rollback": ["git revert the commit; old lease files remain readable by the reverted code only if they hold a single object, so delete ~/.cache/phone-lease/*.json after reverting", "Re-run pytest python_pkg/phone_lease"] +} diff --git a/python_pkg/phone_lease/__init__.py b/python_pkg/phone_lease/__init__.py index b0c520ef..6db30257 100644 --- a/python_pkg/phone_lease/__init__.py +++ b/python_pkg/phone_lease/__init__.py @@ -1,41 +1,59 @@ -"""One phone, many Claude sessions: a lease that keeps them from colliding. +"""One phone, many Claude sessions: leases that keep them from colliding. Two sessions driving the same phone interleave their taps -- on 2026-09-20 a tap meant for the workout sandbox landed in another session's manga reader, -twice. This package hands the phone to one owner at a time: +twice. This package hands out *scoped* leases: -* the lease is a JSON file per serial under ``~/.cache/phone-lease/``, guarded - by ``flock`` so read-modify-write is atomic across processes; -* the owner is the nearest ancestor ``claude`` process (one per session), so - every call from the same session refreshes the same lease and calls from - another session wait, then fail naming the holder; -* it expires ``ttl`` seconds after the last refresh, so a session that just - stops driving frees the phone without anyone remembering to release. +* the leases are one JSON file per serial under ``~/.cache/phone-lease/``, + guarded by ``flock`` so read-modify-write is atomic across processes; +* a lease covers one app (``--package``), the real screen (the default), or + the whole phone (``--whole-phone``: Doze, sleep, reboot -- capped at five + minutes, with cleanup commands a detached reaper runs if nobody releases); +* the owner is the nearest ancestor ``claude`` process (one per session); +* the serial is always resolved (:mod:`.serial`), never a placeholder, so + every caller agrees on which phone a lease is for; +* a lease expires ``ttl`` seconds after its last refresh. -``android_ui`` takes the lease on every command and ``phone_deploy.sh`` on -every deploy, which is every path that touches the phone from a session. +Sessions that drive apps on their own virtual display +(``~/.claude/scripts/phone_vd.sh``) need only a package lease, so several of +them can work on one phone at the same time. """ from python_pkg.phone_lease.lease import ( DEFAULT_TTL, DEFAULT_WAIT, + SCREEN, + WHOLE_PHONE, + WHOLE_PHONE_MAX_TTL, Clock, Lease, PhoneBusyError, + Terms, acquire, - owner_id, - release, - status, + scope_of, ) +from python_pkg.phone_lease.ops import holds, reap, release, status, status_all +from python_pkg.phone_lease.owner import owner_id +from python_pkg.phone_lease.serial import NoPhoneError, resolve_serial __all__ = [ "DEFAULT_TTL", "DEFAULT_WAIT", + "SCREEN", + "WHOLE_PHONE", + "WHOLE_PHONE_MAX_TTL", "Clock", "Lease", + "NoPhoneError", "PhoneBusyError", + "Terms", "acquire", + "holds", "owner_id", + "reap", "release", + "resolve_serial", + "scope_of", "status", + "status_all", ] diff --git a/python_pkg/phone_lease/__main__.py b/python_pkg/phone_lease/__main__.py index d6f578d7..df1b8aec 100644 --- a/python_pkg/phone_lease/__main__.py +++ b/python_pkg/phone_lease/__main__.py @@ -1,59 +1,115 @@ -"""CLI: ``python3 -m python_pkg.phone_lease acquire|release|status ``.""" +"""CLI: ``python3 -m python_pkg.phone_lease acquire|release|status|holds|reap``. + +The serial is optional everywhere; see :func:`serial.resolve_serial`. +Scope flags: ``--package `` (one app) or ``--whole-phone``; neither means +the real screen, which is what every caller meant before scopes existed. +""" from __future__ import annotations import argparse +from dataclasses import asdict +import json import sys from python_pkg.phone_lease.lease import ( DEFAULT_TTL, DEFAULT_WAIT, PhoneBusyError, + Terms, acquire, - owner_id, - release, - status, + scope_of, ) +from python_pkg.phone_lease.ops import holds, reap, release, status_all +from python_pkg.phone_lease.owner import owner_id +from python_pkg.phone_lease.serial import NoPhoneError, resolve_serial + + +def _scoped(parser: argparse.ArgumentParser) -> None: + parser.add_argument("serial", nargs="?", default=None) + which = parser.add_mutually_exclusive_group() + which.add_argument("--package", default=None, help="lease one app only") + which.add_argument( + "--whole-phone", + action="store_true", + help="phone-wide changes (Doze, sleep, reboot); capped at 300 s", + ) def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="phone-lease", description=__doc__) sub = parser.add_subparsers(dest="command", required=True) take = sub.add_parser( - "acquire", help="take or refresh the lease (exit 3 when held elsewhere)" + "acquire", help="take or refresh a lease (exit 3 when held elsewhere)" ) - take.add_argument("serial") + _scoped(take) take.add_argument( "--note", default="", help="what the holder is doing, shown to the other side" ) take.add_argument("--ttl", type=float, default=DEFAULT_TTL) take.add_argument("--wait", type=float, default=DEFAULT_WAIT) - drop = sub.add_parser("release", help="drop the lease if this session holds it") - drop.add_argument("serial") - look = sub.add_parser("status", help="print the holder, or 'free'") - look.add_argument("serial") + take.add_argument( + "--cleanup", + action="append", + default=[], + help="command to run once on release or expiry (repeatable)", + ) + _scoped(sub.add_parser("release", help="drop a lease this session holds")) + look = sub.add_parser("status", help="list live leases, or 'free'") + look.add_argument("serial", nargs="?", default=None) + look.add_argument("--json", action="store_true", help="machine-readable list") + ask = sub.add_parser("holds", help="exit 0 if OWNER may act on the scope") + _scoped(ask) + ask.add_argument("--owner", default=None, help="defaults to this session") + sub.add_parser("whoami", help="print this session's owner id") + reaper = sub.add_parser("reap", help="internal: run cleanup when a lease expires") + reaper.add_argument("serial") + reaper.add_argument("--owner", required=True) + reaper.add_argument("--scope", required=True) return parser -def main(argv: list[str] | None = None) -> int: - """Run the CLI. Exit 0 ok, 1 nothing to release, 3 phone held elsewhere.""" - args = _build_parser().parse_args(argv) - if args.command == "acquire": - try: - lease = acquire(args.serial, args.note, ttl=args.ttl, wait=args.wait) - except PhoneBusyError as exc: - sys.stderr.write(f"phone-lease: {exc}\n") - return 3 - sys.stdout.write(f"{lease.describe()}\n") +def _run(args: argparse.Namespace) -> int: + if args.command == "whoami": + sys.stdout.write(f"{owner_id()}\n") return 0 + if args.command == "reap": + reap(args.serial, args.owner, args.scope) + return 0 + serial = resolve_serial(args.serial) + if args.command == "status": + live = status_all(serial) + if args.json: + sys.stdout.write(json.dumps([asdict(x) for x in live]) + "\n") + else: + lines = [x.describe() for x in live] or ["free"] + sys.stdout.write("\n".join(lines) + "\n") + return 0 + scope = scope_of(args.package, whole_phone=args.whole_phone) + if args.command == "holds": + return 0 if holds(serial, args.owner or owner_id(), scope) else 1 if args.command == "release": - dropped = release(args.serial) + dropped = release(serial, scope=scope) sys.stdout.write("released\n" if dropped else f"not held by {owner_id()}\n") return 0 if dropped else 1 - lease = status(args.serial) - sys.stdout.write("free\n" if lease is None else f"{lease.describe()}\n") + terms = Terms(ttl=args.ttl, wait=args.wait, cleanup=tuple(args.cleanup)) + lease = acquire(serial, args.note, scope=scope, terms=terms) + sys.stdout.write(f"{lease.describe()}\n") return 0 +def main(argv: list[str] | None = None) -> int: + """Run the CLI. Exit 0 ok, 1 not held, 3 held elsewhere, 4 no phone.""" + args = _build_parser().parse_args(argv) + try: + return _run(args) + except PhoneBusyError as exc: + sys.stderr.write(f"phone-lease: {exc}\n") + return 3 + except NoPhoneError as exc: + sys.stderr.write(f"phone-lease: {exc}\n") + return 4 + + if __name__ == "__main__": sys.exit(main()) diff --git a/python_pkg/phone_lease/_cleanup.py b/python_pkg/phone_lease/_cleanup.py new file mode 100644 index 00000000..eafa1ce6 --- /dev/null +++ b/python_pkg/phone_lease/_cleanup.py @@ -0,0 +1,69 @@ +"""Run a whole-phone lease's cleanup commands, even if nobody releases it. + +A forced Doze left behind by a crashed session breaks every other session's +network and alarms until someone notices, so cleanup cannot depend on the +holder remembering: a detached reaper watches each lease that has cleanup +commands and runs them the moment it expires. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import shlex +import subprocess +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +# One cleanup command may not hang the reaper or a caller's release. +CLEANUP_TIMEOUT = 30.0 +# The repo root, so the detached reaper can import python_pkg from anywhere. +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +def run(commands: Iterable[str]) -> list[int]: + """Run each command (no shell), returning exit codes; never raises. + + A failing or missing command is reported as a non-zero code rather than + raised, because the remaining cleanup steps must still run. + """ + codes: list[int] = [] + for command in commands: + try: + done = subprocess.run( + shlex.split(command), + check=False, + timeout=CLEANUP_TIMEOUT, + capture_output=True, + ) + except OSError, ValueError, subprocess.TimeoutExpired: + codes.append(127) + continue + codes.append(done.returncode) + return codes + + +def spawn_reaper(serial: str, owner: str, scope: str) -> None: + """Start a detached ``phone_lease reap`` for one freshly taken lease.""" + env = {**os.environ, "PYTHONPATH": str(_REPO_ROOT)} + subprocess.Popen( + [ + sys.executable, + "-m", + "python_pkg.phone_lease", + "reap", + serial, + "--owner", + owner, + "--scope", + scope, + ], + env=env, + start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/python_pkg/phone_lease/lease.py b/python_pkg/phone_lease/lease.py index 1e193e7a..411a6234 100644 --- a/python_pkg/phone_lease/lease.py +++ b/python_pkg/phone_lease/lease.py @@ -1,8 +1,19 @@ -"""The lease file, its owner identity, and the acquire/release operations.""" +"""The lease file, its owner identity, and the acquire/release operations. + +One JSON file per phone holds every live lease on it. A lease has a *scope*: + +* ``pkg:`` -- one app (install, launch, driving it). Two sessions can + hold different packages at once. ``pkg:__screen__`` is the phone's real + screen, and the default, so callers written before scopes keep their meaning. +* ``whole-phone`` -- anything that changes the phone for everyone (forced + Doze, screen sleep, reboot, a focus-mode deploy). It conflicts with every + foreign lease, is capped at :data:`WHOLE_PHONE_MAX_TTL`, and carries cleanup + commands that run exactly once, on release or expiry. +""" from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field import fcntl import json import os @@ -10,6 +21,9 @@ from pathlib import Path import time from typing import TYPE_CHECKING +from python_pkg.phone_lease import _cleanup +from python_pkg.phone_lease.owner import owner_id + if TYPE_CHECKING: from collections.abc import Callable @@ -24,158 +38,188 @@ class Clock: # Seconds a lease outlives its last refresh; a quiet session frees the phone. DEFAULT_TTL = 180.0 - # Seconds ``acquire`` waits on a foreign lease before giving up. DEFAULT_WAIT = 60.0 - +# A whole-phone lease stops everyone else, so it can never be held for long. +WHOLE_PHONE_MAX_TTL = 300.0 +SCREEN = "__screen__" +WHOLE_PHONE = "whole-phone" # Where lease files live; overridable so tests never touch the real one. LEASE_DIR = Path( os.environ.get("PHONE_LEASE_DIR", str(Path.home() / ".cache" / "phone-lease")) ) -# Set this to name the owner explicitly (CI, a script without a claude parent). -OWNER_ENV = "PHONE_LEASE_OWNER" - class PhoneBusyError(RuntimeError): - """Raised when a fresh lease belongs to another owner.""" + """Raised when a fresh, conflicting lease belongs to another owner.""" @dataclass(frozen=True) class Lease: - """What the lease file says.""" + """One entry of the lease file.""" serial: str owner: str expires: float note: str + scope: str = f"pkg:{SCREEN}" + cleanup: tuple[str, ...] = field(default_factory=tuple) def is_expired(self, now: float) -> bool: """True once ``now`` has passed the expiry.""" return now >= self.expires + def conflicts_with(self, owner: str, scope: str) -> bool: + """Whether this lease stops ``owner`` from taking ``scope``.""" + if self.owner == owner: + return False + return WHOLE_PHONE in (self.scope, scope) or self.scope == scope + def describe(self) -> str: """One line for an error or a status readout.""" until = time.strftime("%H:%M:%S", time.localtime(self.expires)) - return f"phone {self.serial} held by {self.owner} ({self.note}) until {until}" + what = "" if self.scope == f"pkg:{SCREEN}" else f" [{self.scope}]" + return ( + f"phone {self.serial}{what} held by {self.owner} ({self.note}) " + f"until {until}" + ) -def owner_id(pid: int | None = None, environ: dict[str, str] | None = None) -> str: - """Identify the calling session. - - ``PHONE_LEASE_OWNER`` wins. Otherwise walk up from ``pid`` (this process - by default) to the nearest ancestor whose ``comm`` is ``claude`` -- one - per session -- and fall back to this process's own pid when there is none. - """ - env = os.environ if environ is None else environ - explicit = env.get(OWNER_ENV) - if explicit: - return explicit - current = os.getpid() if pid is None else pid - origin = current - while current > 1: - try: - comm = Path(f"/proc/{current}/comm").read_text(encoding="utf-8").strip() - stat = Path(f"/proc/{current}/stat").read_text(encoding="utf-8") - except OSError: - break - if comm == "claude": - return f"claude:{current}" - # Field 4 of /proc//stat is the parent pid; the comm field before - # it may contain spaces, so split after its closing paren. - current = int(stat.rsplit(")", 1)[1].split()[1]) - return f"pid:{origin}" +def scope_of(package: str | None = None, *, whole_phone: bool = False) -> str: + """The scope string for a package, the whole phone, or the real screen.""" + if whole_phone: + return WHOLE_PHONE + return f"pkg:{package or SCREEN}" -def _paths(serial: str) -> tuple[Path, Path]: +def lease_paths(serial: str) -> tuple[Path, Path]: + """The lock file and the lease file for ``serial``.""" LEASE_DIR.mkdir(parents=True, exist_ok=True) safe = serial.replace("/", "_") return LEASE_DIR / f"{safe}.lock", LEASE_DIR / f"{safe}.json" -def _read(path: Path) -> Lease | None: +def _parse(raw: object) -> Lease | None: + if not isinstance(raw, dict): + return None + try: + lease = Lease(**raw) + except TypeError: + return None + return Lease(**{**asdict(lease), "cleanup": tuple(lease.cleanup)}) + + +def read_leases(path: Path) -> list[Lease]: + """Every lease in the file. Pre-scope files hold one bare object.""" try: raw = json.loads(path.read_text(encoding="utf-8")) except OSError, ValueError: - return None - try: - return Lease(**raw) - except TypeError: - return None + return [] + items = raw.get("leases", [raw]) if isinstance(raw, dict) else [] + return [lease for lease in map(_parse, items) if lease is not None] -def _locked_update( - serial: str, - owner: str, - note: str, - ttl: float, - now: float, -) -> Lease: - """One atomic attempt: take or refresh the lease, or return the holder's.""" - lock_path, lease_path = _paths(serial) +def write_leases(path: Path, leases: list[Lease]) -> None: + """Replace the lease file's contents with ``leases``.""" + path.write_text( + json.dumps({"leases": [asdict(lease) for lease in leases]}), encoding="utf-8" + ) + + +def locked_update( + serial: str, change: Callable[[list[Lease], float], list[Lease]], now: float +) -> list[Lease]: + """Apply ``change`` to the live leases under the lock. + + Expired leases are dropped on the way in; their cleanup commands run after + the lock is released, so a slow ``adb`` never blocks another session. + """ + lock_path, lease_path = lease_paths(serial) with lock_path.open("w", encoding="utf-8") as lock: fcntl.flock(lock, fcntl.LOCK_EX) - current = _read(lease_path) - if ( - current is not None - and current.owner != owner - and not current.is_expired(now) - ): - return current - fresh = Lease(serial=serial, owner=owner, expires=now + ttl, note=note) - lease_path.write_text(json.dumps(asdict(fresh)), encoding="utf-8") - return fresh + leases = read_leases(lease_path) + expired = [lease for lease in leases if lease.is_expired(now)] + live = change([lease for lease in leases if not lease.is_expired(now)], now) + write_leases(lease_path, live) + for lease in expired: + _cleanup.run(lease.cleanup) + return live + + +def _attempt( + serial: str, want: Lease, ttl: float, now: float +) -> tuple[Lease | None, Lease | None, bool]: + """One locked try at ``want``: (granted lease, blocker, newly taken).""" + out: list[Lease | None] = [None, None] + fresh = [True] + + def take(live: list[Lease], at: float) -> list[Lease]: + blocker = next( + (x for x in live if x.conflicts_with(want.owner, want.scope)), None + ) + if blocker is not None: + out[1] = blocker + return live + prior = next( + (x for x in live if (x.owner, x.scope) == (want.owner, want.scope)), None + ) + fresh[0] = prior is None + granted = Lease(**{**asdict(want), "expires": at + ttl}) + out[0] = granted + return [x for x in live if x is not prior] + [granted] + + locked_update(serial, take, now) + return out[0], out[1], fresh[0] + + +@dataclass(frozen=True) +class Terms: + """How long to hold, how long to wait, and what to undo afterwards.""" + + ttl: float = DEFAULT_TTL + wait: float = DEFAULT_WAIT + cleanup: tuple[str, ...] = () def acquire( serial: str, note: str = "", *, + scope: str = f"pkg:{SCREEN}", + terms: Terms | None = None, owner: str | None = None, - ttl: float = DEFAULT_TTL, - wait: float = DEFAULT_WAIT, clock: Clock | None = None, ) -> Lease: - """Take or refresh the lease on ``serial``. + """Take or refresh a lease on ``serial`` for one ``scope`` (see :func:`scope_of`). - Waits up to ``wait`` seconds for a foreign lease to expire, polling once a - second, then raises :class:`PhoneBusyError` naming the holder. + Waits up to ``terms.wait`` seconds for conflicting foreign leases to + expire, polling once a second, then raises :class:`PhoneBusyError`. """ me = owner_id() if owner is None else owner + terms = terms or Terms() + ttl = terms.ttl + if scope == WHOLE_PHONE: + ttl = min(ttl, WHOLE_PHONE_MAX_TTL) + cleanup = terms.cleanup + wait = terms.wait clock = clock or Clock() deadline = clock.now() + wait while True: - lease = _locked_update(serial, me, note, ttl, clock.now()) - if lease.owner == me: - return lease + mine, blocker, is_new = _attempt( + serial, Lease(serial, me, 0.0, note, scope, cleanup), ttl, clock.now() + ) + if mine is not None: + if is_new and cleanup: + _cleanup.spawn_reaper(serial, me, scope) + return mine if clock.now() >= deadline: msg = ( - f"{lease.describe()} -- waited {wait:.0f}s. Do not retry in a loop: " - "drive a sandbox-flavor app on the phone's virtual display instead " - "(~/.claude/scripts/phone_vd.sh, needs no lease), or the headless " - "emulator (~/.claude/scripts/phone_emu.sh); see the phone-deploy skill." + f"{blocker.describe() if blocker else serial} -- waited {wait:.0f}s. " + "Do not retry " + "in a loop: drive the app on your own virtual display instead " + "(~/.claude/scripts/phone_vd.sh, needs only a package lease), or the " + "headless emulator (~/.claude/scripts/phone_emu.sh); see the " + "phone-deploy skill." ) raise PhoneBusyError(msg) clock.sleep(1.0) - - -def release(serial: str, *, owner: str | None = None) -> bool: - """Drop the lease if ``owner`` holds it. Returns whether anything was dropped.""" - me = owner_id() if owner is None else owner - lock_path, lease_path = _paths(serial) - with lock_path.open("w", encoding="utf-8") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - current = _read(lease_path) - if current is None or current.owner != me: - return False - lease_path.unlink() - return True - - -def status(serial: str, now: float | None = None) -> Lease | None: - """The live lease on ``serial``, or None when free or expired.""" - _, lease_path = _paths(serial) - current = _read(lease_path) - if current is None or current.is_expired(time.time() if now is None else now): - return None - return current diff --git a/python_pkg/phone_lease/ops.py b/python_pkg/phone_lease/ops.py new file mode 100644 index 00000000..de269fea --- /dev/null +++ b/python_pkg/phone_lease/ops.py @@ -0,0 +1,103 @@ +"""Release, status, the ``holds`` check the adb hook asks, and the reaper.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from python_pkg.phone_lease import _cleanup +from python_pkg.phone_lease.lease import ( + SCREEN, + WHOLE_PHONE, + Clock, + Lease, + lease_paths, + locked_update, + read_leases, +) +from python_pkg.phone_lease.owner import owner_id + +if TYPE_CHECKING: + from collections.abc import Callable + +# Longest the reaper sleeps between looks at the lease file. +REAP_POLL = 5.0 + + +def release( + serial: str, *, scope: str = f"pkg:{SCREEN}", owner: str | None = None +) -> bool: + """Drop ``owner``'s lease on ``scope``, running its cleanup. True if dropped.""" + me = owner_id() if owner is None else owner + dropped: list[Lease] = [] + + def drop(live: list[Lease], _now: float) -> list[Lease]: + dropped.extend(x for x in live if (x.owner, x.scope) == (me, scope)) + return [x for x in live if x not in dropped] + + locked_update(serial, drop, time.time()) + for lease in dropped: + _cleanup.run(lease.cleanup) + return bool(dropped) + + +def status_all(serial: str, now: float | None = None) -> list[Lease]: + """Every live lease on ``serial`` (read-only: no cleanup runs here).""" + at = time.time() if now is None else now + return [x for x in read_leases(lease_paths(serial)[1]) if not x.is_expired(at)] + + +def status( + serial: str, now: float | None = None, *, scope: str = f"pkg:{SCREEN}" +) -> Lease | None: + """The live lease on ``scope``, or None when free.""" + return next((x for x in status_all(serial, now) if x.scope == scope), None) + + +def holds( + serial: str, owner: str, scope: str = f"pkg:{SCREEN}", now: float | None = None +) -> bool: + """Whether ``owner`` may act on ``scope`` right now. + + True when it holds that scope or the whole phone. This is the question + the raw-adb hook asks before letting an ``input``/``am`` through. + """ + return any( + x.owner == owner and x.scope in {scope, WHOLE_PHONE} + for x in status_all(serial, now) + ) + + +def reap( + serial: str, + owner: str, + scope: str, + *, + clock: Clock | None = None, + alive: Callable[[], bool] = lambda: True, +) -> bool: + """Wait until ``owner``'s lease on ``scope`` ends; run its cleanup if it expired. + + Refreshes push the expiry out, so this re-reads the file after every + sleep. Returns True when it ran the cleanup, False when the holder + released first (``release`` already ran it) or ``alive`` says stop. + """ + clock = clock or Clock() + while alive(): + lease = next( + ( + x + for x in read_leases(lease_paths(serial)[1]) + if (x.owner, x.scope) == (owner, scope) + ), + None, + ) + if lease is None: + return False + if lease.is_expired(clock.now()): + # locked_update drops expired leases and runs their cleanup itself. + locked_update(serial, lambda live, _now: live, clock.now()) + return True + # Wake at least every few seconds so a released lease frees the reaper. + clock.sleep(min(REAP_POLL, max(0.5, lease.expires - clock.now()))) + return False diff --git a/python_pkg/phone_lease/owner.py b/python_pkg/phone_lease/owner.py new file mode 100644 index 00000000..c48f6f1a --- /dev/null +++ b/python_pkg/phone_lease/owner.py @@ -0,0 +1,36 @@ +"""Which Claude session is calling: the identity every lease is held under.""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Set this to name the owner explicitly (CI, a script without a claude parent). +OWNER_ENV = "PHONE_LEASE_OWNER" + + +def owner_id(pid: int | None = None, environ: dict[str, str] | None = None) -> str: + """Identify the calling session. + + ``PHONE_LEASE_OWNER`` wins. Otherwise walk up from ``pid`` (this process + by default) to the nearest ancestor whose ``comm`` is ``claude`` -- one + per session -- and fall back to this process's own pid when there is none. + """ + env = os.environ if environ is None else environ + explicit = env.get(OWNER_ENV) + if explicit: + return explicit + current = os.getpid() if pid is None else pid + origin = current + while current > 1: + try: + comm = Path(f"/proc/{current}/comm").read_text(encoding="utf-8").strip() + stat = Path(f"/proc/{current}/stat").read_text(encoding="utf-8") + except OSError: + break + if comm == "claude": + return f"claude:{current}" + # Field 4 of /proc//stat is the parent pid; the comm field before + # it may contain spaces, so split after its closing paren. + current = int(stat.rsplit(")", 1)[1].split()[1]) + return f"pid:{origin}" diff --git a/python_pkg/phone_lease/serial.py b/python_pkg/phone_lease/serial.py new file mode 100644 index 00000000..c05ff95d --- /dev/null +++ b/python_pkg/phone_lease/serial.py @@ -0,0 +1,62 @@ +"""Which phone a call is about, resolved the same way by every caller. + +Before this existed android_ui leased the literal key ``default`` while +phone_deploy leased the real serial, so the two never saw each other and +drove the same phone at once (2026-09-25). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + +_ENV_KEYS = ("ANDROID_SERIAL", "ADB_SERIAL") + + +class NoPhoneError(RuntimeError): + """Raised when no serial is given and adb cannot name exactly one phone.""" + + +def _adb_serial() -> str: + adb = shutil.which("adb") + if adb is None: + return "" + done = subprocess.run( + [adb, "get-serialno"], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + return done.stdout.strip() if done.returncode == 0 else "" + + +def resolve_serial( + explicit: str | None = None, + *, + environ: Mapping[str, str] | None = None, + ask_adb: Callable[[], str] | None = None, +) -> str: + """``explicit``, else $ANDROID_SERIAL / $ADB_SERIAL, else ``adb get-serialno``.""" + if explicit: + return explicit + env = os.environ if environ is None else environ + for key in _ENV_KEYS: + if env.get(key): + return env[key] + try: + found = (ask_adb or _adb_serial)() + except OSError, subprocess.TimeoutExpired: + found = "" + if not found or found == "unknown": + msg = ( + "no phone serial: pass one, set ANDROID_SERIAL, or connect exactly " + "one device (adb get-serialno found none or several)" + ) + raise NoPhoneError(msg) + return found diff --git a/python_pkg/phone_lease/tests/test_cleanup_serial.py b/python_pkg/phone_lease/tests/test_cleanup_serial.py new file mode 100644 index 00000000..44df7d74 --- /dev/null +++ b/python_pkg/phone_lease/tests/test_cleanup_serial.py @@ -0,0 +1,116 @@ +"""Cleanup command runner, the reaper launcher, and serial resolution.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.phone_lease import NoPhoneError, _cleanup, resolve_serial +from python_pkg.phone_lease import serial as serial_mod + + +class TestRun: + def test_codes_per_command_without_a_shell(self) -> None: + done: list[subprocess.CompletedProcess[bytes]] = [ + subprocess.CompletedProcess([], 0), + subprocess.CompletedProcess([], 3), + ] + with patch( + "python_pkg.phone_lease._cleanup.subprocess.run", side_effect=done + ) as run: + assert _cleanup.run(["adb shell 'a b'", "false"]) == [0, 3] + assert run.call_args_list[0].args[0] == ["adb", "shell", "a b"] + + def test_failures_never_stop_the_rest(self) -> None: + effects = [ + OSError("missing"), + subprocess.TimeoutExpired("x", 30), + subprocess.CompletedProcess([], 0), + ] + with patch( + "python_pkg.phone_lease._cleanup.subprocess.run", side_effect=effects + ): + assert _cleanup.run(["nope", "slow", "ok"]) == [127, 127, 0] + + def test_unparseable_command(self) -> None: + with patch("python_pkg.phone_lease._cleanup.subprocess.run") as run: + assert _cleanup.run(["echo 'unterminated"]) == [127] + run.assert_not_called() + + +def test_spawn_reaper_detaches_our_own_module() -> None: + with patch("python_pkg.phone_lease._cleanup.subprocess.Popen") as popen: + _cleanup.spawn_reaper("S", "claude:1", "whole-phone") + argv = popen.call_args.args[0] + assert argv[1:] == [ + "-m", + "python_pkg.phone_lease", + "reap", + "S", + "--owner", + "claude:1", + "--scope", + "whole-phone", + ] + kwargs = popen.call_args.kwargs + assert kwargs["start_new_session"] is True + repo_root = Path(_cleanup.__file__).resolve().parents[2] + assert kwargs["env"]["PYTHONPATH"] == str(repo_root) + + +class TestResolveSerial: + def test_explicit_wins(self) -> None: + assert resolve_serial("X", environ={"ANDROID_SERIAL": "Y"}) == "X" + + @pytest.mark.parametrize( + ("env", "want"), + [ + ({"ANDROID_SERIAL": "A", "ADB_SERIAL": "B"}, "A"), + ({"ANDROID_SERIAL": "", "ADB_SERIAL": "B"}, "B"), + ], + ) + def test_environment(self, env: dict[str, str], want: str) -> None: + assert resolve_serial(environ=env) == want + + def test_asks_adb_last(self) -> None: + assert resolve_serial(environ={}, ask_adb=lambda: "USB1") == "USB1" + + @pytest.mark.parametrize("answer", ["", "unknown"]) + def test_no_single_phone(self, answer: str) -> None: + with pytest.raises(NoPhoneError, match="no phone serial"): + resolve_serial(environ={}, ask_adb=lambda: answer) + + def test_adb_crashing_is_no_phone(self) -> None: + def boom() -> str: + raise OSError + + with pytest.raises(NoPhoneError): + resolve_serial(environ={}, ask_adb=boom) + + def test_real_environment_by_default(self) -> None: + with patch.dict("os.environ", {"ANDROID_SERIAL": "ENV"}): + assert resolve_serial() == "ENV" + + +class TestAdbSerial: + def test_without_adb(self) -> None: + with patch("python_pkg.phone_lease.serial.shutil.which", return_value=None): + assert serial_mod._adb_serial() == "" + + @pytest.mark.parametrize(("code", "want"), [(0, "PHONE"), (1, "")]) + def test_asks_the_full_adb_path(self, code: int, want: str) -> None: + done = MagicMock(returncode=code, stdout="PHONE\n") + with ( + patch( + "python_pkg.phone_lease.serial.shutil.which", + return_value="/usr/bin/adb", + ), + patch( + "python_pkg.phone_lease.serial.subprocess.run", return_value=done + ) as run, + ): + assert serial_mod._adb_serial() == want + assert run.call_args.args[0] == ["/usr/bin/adb", "get-serialno"] diff --git a/python_pkg/phone_lease/tests/test_lease.py b/python_pkg/phone_lease/tests/test_lease.py index eb510ebe..6bd4b175 100644 --- a/python_pkg/phone_lease/tests/test_lease.py +++ b/python_pkg/phone_lease/tests/test_lease.py @@ -13,6 +13,7 @@ from python_pkg.phone_lease import ( Clock, Lease, PhoneBusyError, + Terms, acquire, owner_id, release, @@ -64,7 +65,7 @@ class TestOwnerId: d.mkdir(parents=True) (d / "comm").write_text(f"{comm}\n") (d / "stat").write_text(f"{pid} ({comm}) S {ppid} 1 1 0 -1\n") - with patch.object(lease_mod.Path, "read_text", _proc_reader(proc)): + with patch.object(Path, "read_text", _proc_reader(proc)): assert owner_id(pid=300, environ={}) == "claude:200" def test_falls_back_to_own_pid_without_a_claude_ancestor( @@ -75,7 +76,7 @@ class TestOwnerId: d.mkdir(parents=True) (d / "comm").write_text("zsh\n") (d / "stat").write_text("300 (zsh) S 1 1 1 0 -1\n") - with patch.object(lease_mod.Path, "read_text", _proc_reader(proc)): + with patch.object(Path, "read_text", _proc_reader(proc)): assert owner_id(pid=300, environ={}) == "pid:300" def test_unreadable_proc_falls_back_to_own_pid(self) -> None: @@ -100,7 +101,11 @@ class TestAcquire: def test_takes_a_free_phone(self, lease_dir: Path) -> None: clock = FakeClock() got = acquire( - "PIXEL", "deploy", owner="claude:1", ttl=10, clock=clock.as_clock() + "PIXEL", + "deploy", + owner="claude:1", + terms=Terms(ttl=10), + clock=clock.as_clock(), ) assert got == Lease( serial="PIXEL", owner="claude:1", expires=1010.0, note="deploy" @@ -109,32 +114,58 @@ class TestAcquire: def test_refreshes_the_same_owner(self) -> None: clock = FakeClock() - acquire("PIXEL", "a", owner="claude:1", ttl=10, clock=clock.as_clock()) + acquire( + "PIXEL", "a", owner="claude:1", terms=Terms(ttl=10), clock=clock.as_clock() + ) clock.now += 5 - got = acquire("PIXEL", "b", owner="claude:1", ttl=10, clock=clock.as_clock()) + got = acquire( + "PIXEL", "b", owner="claude:1", terms=Terms(ttl=10), clock=clock.as_clock() + ) assert got.expires == 1015.0 assert got.note == "b" assert clock.slept == [] def test_waits_for_a_foreign_lease_to_expire(self) -> None: clock = FakeClock() - acquire("PIXEL", "other", owner="claude:2", ttl=3, clock=clock.as_clock()) + acquire( + "PIXEL", + "other", + owner="claude:2", + terms=Terms(ttl=3), + clock=clock.as_clock(), + ) got = acquire( - "PIXEL", "mine", owner="claude:1", ttl=10, wait=60, clock=clock.as_clock() + "PIXEL", + "mine", + owner="claude:1", + terms=Terms(ttl=10, wait=60), + clock=clock.as_clock(), ) assert got.owner == "claude:1" assert clock.slept == [1.0, 1.0, 1.0] def test_gives_up_naming_the_holder(self) -> None: clock = FakeClock() - acquire("PIXEL", "manga", owner="claude:2", ttl=1000, clock=clock.as_clock()) + acquire( + "PIXEL", + "manga", + owner="claude:2", + terms=Terms(ttl=1000), + clock=clock.as_clock(), + ) with pytest.raises( PhoneBusyError, match=r"held by claude:2 \(manga\).*waited 2s" ): - acquire("PIXEL", "mine", owner="claude:1", wait=2, clock=clock.as_clock()) + acquire( + "PIXEL", + "mine", + owner="claude:1", + terms=Terms(wait=2), + clock=clock.as_clock(), + ) def test_uses_the_real_clock_by_default(self) -> None: - got = acquire("PIXEL", owner="claude:1", ttl=5) + got = acquire("PIXEL", owner="claude:1", terms=Terms(ttl=5)) assert got.expires == pytest.approx(time.time() + 5, abs=2) def test_uses_the_real_owner_by_default(self) -> None: @@ -181,11 +212,16 @@ class TestStatus: assert status("PIXEL") is None def test_expired_is_free(self) -> None: - acquire("PIXEL", owner="claude:1", ttl=1, clock=FakeClock(now=100.0).as_clock()) + acquire( + "PIXEL", + owner="claude:1", + terms=Terms(ttl=1), + clock=FakeClock(now=100.0).as_clock(), + ) assert status("PIXEL", now=200.0) is None def test_live_describes_the_holder(self) -> None: - acquire("PIXEL", "deploy", owner="claude:1", ttl=1000) + acquire("PIXEL", "deploy", owner="claude:1", terms=Terms(ttl=1000)) live = status("PIXEL") assert live is not None assert live.describe().startswith( diff --git a/python_pkg/phone_lease/tests/test_main.py b/python_pkg/phone_lease/tests/test_main.py index e6ac0eb8..7ecd64ed 100644 --- a/python_pkg/phone_lease/tests/test_main.py +++ b/python_pkg/phone_lease/tests/test_main.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import runpy import sys @@ -11,6 +12,7 @@ from unittest.mock import patch import pytest from python_pkg.phone_lease import __main__ as cli +from python_pkg.phone_lease import _cleanup from python_pkg.phone_lease import lease as lease_mod if TYPE_CHECKING: @@ -40,7 +42,9 @@ def test_release_without_holding(capsys: pytest.CaptureFixture[str]) -> None: def test_foreign_holder_exits_3(capsys: pytest.CaptureFixture[str]) -> None: - lease_mod.acquire("PIXEL", "manga", owner="someone-else", ttl=1000) + lease_mod.acquire( + "PIXEL", "manga", owner="someone-else", terms=lease_mod.Terms(ttl=1000) + ) assert cli.main(["acquire", "PIXEL", "--wait", "0"]) == 3 assert "held by someone-else (manga)" in capsys.readouterr().err @@ -55,3 +59,59 @@ def test_module_entry_point() -> None: ): runpy.run_module("python_pkg.phone_lease", run_name="__main__") assert excinfo.value.code == 0 + + +def test_package_scopes_coexist_and_holds(capsys: pytest.CaptureFixture[str]) -> None: + with patch.dict("os.environ", {"PHONE_LEASE_OWNER": "me"}): + assert cli.main(["acquire", "PIXEL", "--package", "a.b"]) == 0 + assert "[pkg:a.b]" in capsys.readouterr().out + assert cli.main(["holds", "PIXEL", "--package", "a.b"]) == 0 + assert cli.main(["holds", "PIXEL", "--package", "c.d"]) == 1 + assert cli.main(["holds", "PIXEL", "--package", "a.b", "--owner", "x"]) == 1 + assert cli.main(["release", "PIXEL", "--package", "a.b"]) == 0 + + +def test_whole_phone_with_cleanup(capsys: pytest.CaptureFixture[str]) -> None: + with ( + patch.object(_cleanup, "spawn_reaper") as spawn, + patch.object(_cleanup, "run") as run, + ): + argv = ["acquire", "PIXEL", "--whole-phone", "--cleanup", "adb x", "--ttl", "9"] + assert cli.main(argv) == 0 + spawn.assert_called_once() + assert cli.main(["release", "PIXEL", "--whole-phone"]) == 0 + run.assert_called_once_with(("adb x",)) + assert "[whole-phone]" in capsys.readouterr().out + + +def test_status_json_and_text(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["status", "PIXEL", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == [] + lease_mod.acquire("PIXEL", "n", owner="o") + assert cli.main(["status", "PIXEL", "--json"]) == 0 + (only,) = json.loads(capsys.readouterr().out) + assert (only["owner"], only["scope"]) == ("o", "pkg:__screen__") + + +def test_whoami(capsys: pytest.CaptureFixture[str]) -> None: + with patch.dict("os.environ", {"PHONE_LEASE_OWNER": "claude:42"}): + assert cli.main(["whoami"]) == 0 + assert capsys.readouterr().out == "claude:42\n" + + +def test_reap_subcommand_delegates() -> None: + with patch.object(cli, "reap") as reap: + assert cli.main(["reap", "S", "--owner", "o", "--scope", "whole-phone"]) == 0 + reap.assert_called_once_with("S", "o", "whole-phone") + + +def test_serial_resolved_or_exit_4(capsys: pytest.CaptureFixture[str]) -> None: + with patch.dict("os.environ", {"ANDROID_SERIAL": "ENVPHONE"}): + assert cli.main(["status"]) == 0 + assert capsys.readouterr().out == "free\n" + with ( + patch.dict("os.environ", {"ANDROID_SERIAL": "", "ADB_SERIAL": ""}), + patch("python_pkg.phone_lease.serial._adb_serial", return_value=""), + ): + assert cli.main(["status"]) == 4 + assert "no phone serial" in capsys.readouterr().err diff --git a/python_pkg/phone_lease/tests/test_scopes.py b/python_pkg/phone_lease/tests/test_scopes.py new file mode 100644 index 00000000..0f8bea9c --- /dev/null +++ b/python_pkg/phone_lease/tests/test_scopes.py @@ -0,0 +1,200 @@ +"""Scoped leases: packages, the whole phone, cleanup, holds, and the reaper.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.phone_lease import ( + WHOLE_PHONE_MAX_TTL, + Clock, + Lease, + PhoneBusyError, + Terms, + _cleanup, + acquire, + holds, + reap, + release, + scope_of, + status, + status_all, +) +from python_pkg.phone_lease import lease as lease_mod + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +WHOLE = scope_of(whole_phone=True) + + +@dataclass +class Mocks: + """The two subprocess-touching hooks, replaced for every test.""" + + run: MagicMock + spawn: MagicMock + + +@pytest.fixture(autouse=True) +def mocks(tmp_path: Path) -> Iterator[Mocks]: + """Own lease directory; cleanup commands and reapers never really run.""" + with ( + patch.object(lease_mod, "LEASE_DIR", tmp_path / "leases"), + patch.object(_cleanup, "run", return_value=[]) as run, + patch.object(_cleanup, "spawn_reaper") as spawn, + ): + yield Mocks(run=run, spawn=spawn) + + +@pytest.fixture +def lease_dir(tmp_path: Path) -> Path: + """Where this test's lease files live.""" + return tmp_path / "leases" + + +def _take(owner: str, scope: str, cleanup: tuple[str, ...] = ()) -> Lease: + terms = Terms(wait=0, cleanup=cleanup) + return acquire("P", owner, scope=scope, terms=terms, owner=owner) + + +class TestScopeOf: + def test_names(self) -> None: + assert scope_of() == "pkg:__screen__" + assert scope_of("a.b") == "pkg:a.b" + assert scope_of("a.b", whole_phone=True) == "whole-phone" + + +class TestConflicts: + def test_different_packages_coexist(self) -> None: + _take("A", "pkg:a") + _take("B", "pkg:b") + assert {x.owner for x in status_all("P")} == {"A", "B"} + + def test_same_package_is_exclusive(self) -> None: + _take("A", "pkg:a") + with pytest.raises(PhoneBusyError, match=r"\[pkg:a\] held by A"): + _take("B", "pkg:a") + + def test_whole_phone_waits_for_every_foreign_lease(self) -> None: + _take("A", "pkg:a") + with pytest.raises(PhoneBusyError): + _take("B", WHOLE) + + def test_whole_phone_blocks_every_foreign_lease(self) -> None: + _take("A", WHOLE) + with pytest.raises(PhoneBusyError, match="whole-phone"): + _take("B", "pkg:z") + + def test_own_leases_never_conflict(self) -> None: + _take("A", "pkg:a") + assert _take("A", WHOLE).scope == WHOLE + + +class TestWholePhone: + def test_ttl_is_capped(self) -> None: + clock = Clock(now=lambda: 100.0, sleep=lambda _s: None) + got = acquire("P", scope=WHOLE, terms=Terms(ttl=9999), owner="A", clock=clock) + assert got.expires == 100.0 + WHOLE_PHONE_MAX_TTL + + def test_reaper_only_for_a_new_lease_with_cleanup(self, mocks: Mocks) -> None: + _take("A", WHOLE, cleanup=("undo",)) + _take("A", WHOLE, cleanup=("undo",)) + _take("A", "pkg:b") + mocks.spawn.assert_called_once_with("P", "A", WHOLE) + + def test_release_runs_cleanup_once(self, mocks: Mocks) -> None: + _take("A", WHOLE, cleanup=("undo",)) + assert release("P", scope=WHOLE, owner="A") is True + assert release("P", scope=WHOLE, owner="A") is False + mocks.run.assert_called_once_with(("undo",)) + + def test_expired_lease_is_cleaned_by_the_next_caller(self, mocks: Mocks) -> None: + early = Clock(now=lambda: 0.0, sleep=lambda _s: None) + acquire( + "P", + scope=WHOLE, + terms=Terms(ttl=5, cleanup=("undo",)), + owner="A", + clock=early, + ) + _take("B", "pkg:b") + mocks.run.assert_any_call(("undo",)) + assert [x.owner for x in status_all("P")] == ["B"] + + +class TestHoldsAndStatus: + def test_holds_scope_or_whole_phone(self) -> None: + _take("A", "pkg:a") + assert holds("P", "A", "pkg:a") is True + assert holds("P", "A", "pkg:b") is False + assert holds("P", "B", "pkg:a") is False + _take("C", "pkg:c") + release("P", scope="pkg:a", owner="A") + release("P", scope="pkg:c", owner="C") + _take("A", WHOLE) + assert holds("P", "A", "pkg:anything") is True + + def test_status_by_scope(self) -> None: + _take("A", "pkg:a") + assert status("P", scope="pkg:a") is not None + assert status("P") is None + + def test_reads_a_pre_scope_lease_file(self, lease_dir: Path) -> None: + lease_dir.mkdir() + (lease_dir / "P.json").write_text( + json.dumps({"serial": "P", "owner": "old", "expires": 9e12, "note": "n"}) + ) + (live,) = status_all("P") + assert (live.owner, live.scope) == ("old", "pkg:__screen__") + + def test_skips_entries_that_are_not_objects(self, lease_dir: Path) -> None: + lease_dir.mkdir() + good = {"serial": "P", "owner": "o", "expires": 9e12, "note": "n"} + (lease_dir / "P.json").write_text(json.dumps({"leases": [1, "x", good]})) + assert [x.owner for x in status_all("P")] == ["o"] + + def test_describe_names_non_screen_scopes(self) -> None: + assert "[pkg:a]" in _take("A", "pkg:a").describe() + assert "[" not in _take("B", "pkg:__screen__").describe() + + +class TestReap: + def _clock(self, start: float) -> tuple[Clock, list[float]]: + now = [start] + slept: list[float] = [] + + def sleep(secs: float) -> None: + slept.append(secs) + now[0] += secs + + return Clock(now=lambda: now[0], sleep=sleep), slept + + def test_runs_cleanup_after_expiry(self, mocks: Mocks) -> None: + clock, slept = self._clock(0.0) + acquire( + "P", + scope=WHOLE, + terms=Terms(ttl=12, cleanup=("undo",)), + owner="A", + clock=clock, + ) + assert reap("P", "A", WHOLE, clock=clock) is True + assert slept == [5.0, 5.0, 2.0] + mocks.run.assert_called_once_with(("undo",)) + + def test_released_first_means_nothing_to_do(self) -> None: + clock, _ = self._clock(0.0) + assert reap("P", "A", WHOLE, clock=clock) is False + + def test_stops_when_told(self) -> None: + _take("A", WHOLE) + assert reap("P", "A", WHOLE, alive=lambda: False) is False + + def test_defaults_to_the_real_clock(self) -> None: + assert reap("P", "nobody", WHOLE) is False