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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ajzz98xksbfak8CaunZWC
This commit is contained in:
Krzysztof Rudnicki 2026-09-25 15:05:57 +02:00
parent baa50cdb60
commit c0c51036fc
13 changed files with 979 additions and 145 deletions

View File

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

View File

@ -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:<package> (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 <changed-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"]
}

View File

@ -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 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, 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 * the leases are one JSON file per serial under ``~/.cache/phone-lease/``,
by ``flock`` so read-modify-write is atomic across processes; guarded by ``flock`` so read-modify-write is atomic across processes;
* the owner is the nearest ancestor ``claude`` process (one per session), so * a lease covers one app (``--package``), the real screen (the default), or
every call from the same session refreshes the same lease and calls from the whole phone (``--whole-phone``: Doze, sleep, reboot -- capped at five
another session wait, then fail naming the holder; minutes, with cleanup commands a detached reaper runs if nobody releases);
* it expires ``ttl`` seconds after the last refresh, so a session that just * the owner is the nearest ancestor ``claude`` process (one per session);
stops driving frees the phone without anyone remembering to release. * 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 Sessions that drive apps on their own virtual display
every deploy, which is every path that touches the phone from a session. (``~/.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 ( from python_pkg.phone_lease.lease import (
DEFAULT_TTL, DEFAULT_TTL,
DEFAULT_WAIT, DEFAULT_WAIT,
SCREEN,
WHOLE_PHONE,
WHOLE_PHONE_MAX_TTL,
Clock, Clock,
Lease, Lease,
PhoneBusyError, PhoneBusyError,
Terms,
acquire, acquire,
owner_id, scope_of,
release,
status,
) )
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__ = [ __all__ = [
"DEFAULT_TTL", "DEFAULT_TTL",
"DEFAULT_WAIT", "DEFAULT_WAIT",
"SCREEN",
"WHOLE_PHONE",
"WHOLE_PHONE_MAX_TTL",
"Clock", "Clock",
"Lease", "Lease",
"NoPhoneError",
"PhoneBusyError", "PhoneBusyError",
"Terms",
"acquire", "acquire",
"holds",
"owner_id", "owner_id",
"reap",
"release", "release",
"resolve_serial",
"scope_of",
"status", "status",
"status_all",
] ]

View File

@ -1,59 +1,115 @@
"""CLI: ``python3 -m python_pkg.phone_lease acquire|release|status <serial>``.""" """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 <pkg>`` (one app) or ``--whole-phone``; neither means
the real screen, which is what every caller meant before scopes existed.
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from dataclasses import asdict
import json
import sys import sys
from python_pkg.phone_lease.lease import ( from python_pkg.phone_lease.lease import (
DEFAULT_TTL, DEFAULT_TTL,
DEFAULT_WAIT, DEFAULT_WAIT,
PhoneBusyError, PhoneBusyError,
Terms,
acquire, acquire,
owner_id, scope_of,
release,
status,
) )
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: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="phone-lease", description=__doc__) parser = argparse.ArgumentParser(prog="phone-lease", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
take = sub.add_parser( 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( take.add_argument(
"--note", default="", help="what the holder is doing, shown to the other side" "--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("--ttl", type=float, default=DEFAULT_TTL)
take.add_argument("--wait", type=float, default=DEFAULT_WAIT) take.add_argument("--wait", type=float, default=DEFAULT_WAIT)
drop = sub.add_parser("release", help="drop the lease if this session holds it") take.add_argument(
drop.add_argument("serial") "--cleanup",
look = sub.add_parser("status", help="print the holder, or 'free'") action="append",
look.add_argument("serial") 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 return parser
def main(argv: list[str] | None = None) -> int: def _run(args: argparse.Namespace) -> int:
"""Run the CLI. Exit 0 ok, 1 nothing to release, 3 phone held elsewhere.""" if args.command == "whoami":
args = _build_parser().parse_args(argv) sys.stdout.write(f"{owner_id()}\n")
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")
return 0 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": 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") sys.stdout.write("released\n" if dropped else f"not held by {owner_id()}\n")
return 0 if dropped else 1 return 0 if dropped else 1
lease = status(args.serial) terms = Terms(ttl=args.ttl, wait=args.wait, cleanup=tuple(args.cleanup))
sys.stdout.write("free\n" if lease is None else f"{lease.describe()}\n") lease = acquire(serial, args.note, scope=scope, terms=terms)
sys.stdout.write(f"{lease.describe()}\n")
return 0 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__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

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

View File

@ -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:<package>`` -- 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 __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass, field
import fcntl import fcntl
import json import json
import os import os
@ -10,6 +21,9 @@ from pathlib import Path
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_pkg.phone_lease import _cleanup
from python_pkg.phone_lease.owner import owner_id
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -24,158 +38,188 @@ class Clock:
# Seconds a lease outlives its last refresh; a quiet session frees the phone. # Seconds a lease outlives its last refresh; a quiet session frees the phone.
DEFAULT_TTL = 180.0 DEFAULT_TTL = 180.0
# Seconds ``acquire`` waits on a foreign lease before giving up. # Seconds ``acquire`` waits on a foreign lease before giving up.
DEFAULT_WAIT = 60.0 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. # Where lease files live; overridable so tests never touch the real one.
LEASE_DIR = Path( LEASE_DIR = Path(
os.environ.get("PHONE_LEASE_DIR", str(Path.home() / ".cache" / "phone-lease")) 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): 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) @dataclass(frozen=True)
class Lease: class Lease:
"""What the lease file says.""" """One entry of the lease file."""
serial: str serial: str
owner: str owner: str
expires: float expires: float
note: str note: str
scope: str = f"pkg:{SCREEN}"
cleanup: tuple[str, ...] = field(default_factory=tuple)
def is_expired(self, now: float) -> bool: def is_expired(self, now: float) -> bool:
"""True once ``now`` has passed the expiry.""" """True once ``now`` has passed the expiry."""
return now >= self.expires 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: def describe(self) -> str:
"""One line for an error or a status readout.""" """One line for an error or a status readout."""
until = time.strftime("%H:%M:%S", time.localtime(self.expires)) 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: def scope_of(package: str | None = None, *, whole_phone: bool = False) -> str:
"""Identify the calling session. """The scope string for a package, the whole phone, or the real screen."""
if whole_phone:
``PHONE_LEASE_OWNER`` wins. Otherwise walk up from ``pid`` (this process return WHOLE_PHONE
by default) to the nearest ancestor whose ``comm`` is ``claude`` -- one return f"pkg:{package or SCREEN}"
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/<pid>/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 _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) LEASE_DIR.mkdir(parents=True, exist_ok=True)
safe = serial.replace("/", "_") safe = serial.replace("/", "_")
return LEASE_DIR / f"{safe}.lock", LEASE_DIR / f"{safe}.json" 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: try:
raw = json.loads(path.read_text(encoding="utf-8")) raw = json.loads(path.read_text(encoding="utf-8"))
except OSError, ValueError: except OSError, ValueError:
return None return []
try: items = raw.get("leases", [raw]) if isinstance(raw, dict) else []
return Lease(**raw) return [lease for lease in map(_parse, items) if lease is not None]
except TypeError:
return None
def _locked_update( def write_leases(path: Path, leases: list[Lease]) -> None:
serial: str, """Replace the lease file's contents with ``leases``."""
owner: str, path.write_text(
note: str, json.dumps({"leases": [asdict(lease) for lease in leases]}), encoding="utf-8"
ttl: float, )
now: float,
) -> Lease:
"""One atomic attempt: take or refresh the lease, or return the holder's.""" def locked_update(
lock_path, lease_path = _paths(serial) 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: with lock_path.open("w", encoding="utf-8") as lock:
fcntl.flock(lock, fcntl.LOCK_EX) fcntl.flock(lock, fcntl.LOCK_EX)
current = _read(lease_path) leases = read_leases(lease_path)
if ( expired = [lease for lease in leases if lease.is_expired(now)]
current is not None live = change([lease for lease in leases if not lease.is_expired(now)], now)
and current.owner != owner write_leases(lease_path, live)
and not current.is_expired(now) for lease in expired:
): _cleanup.run(lease.cleanup)
return current return live
fresh = Lease(serial=serial, owner=owner, expires=now + ttl, note=note)
lease_path.write_text(json.dumps(asdict(fresh)), encoding="utf-8")
return fresh 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( def acquire(
serial: str, serial: str,
note: str = "", note: str = "",
*, *,
scope: str = f"pkg:{SCREEN}",
terms: Terms | None = None,
owner: str | None = None, owner: str | None = None,
ttl: float = DEFAULT_TTL,
wait: float = DEFAULT_WAIT,
clock: Clock | None = None, clock: Clock | None = None,
) -> Lease: ) -> 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 Waits up to ``terms.wait`` seconds for conflicting foreign leases to
second, then raises :class:`PhoneBusyError` naming the holder. expire, polling once a second, then raises :class:`PhoneBusyError`.
""" """
me = owner_id() if owner is None else owner 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() clock = clock or Clock()
deadline = clock.now() + wait deadline = clock.now() + wait
while True: while True:
lease = _locked_update(serial, me, note, ttl, clock.now()) mine, blocker, is_new = _attempt(
if lease.owner == me: serial, Lease(serial, me, 0.0, note, scope, cleanup), ttl, clock.now()
return lease )
if mine is not None:
if is_new and cleanup:
_cleanup.spawn_reaper(serial, me, scope)
return mine
if clock.now() >= deadline: if clock.now() >= deadline:
msg = ( msg = (
f"{lease.describe()} -- waited {wait:.0f}s. Do not retry in a loop: " f"{blocker.describe() if blocker else serial} -- waited {wait:.0f}s. "
"drive a sandbox-flavor app on the phone's virtual display instead " "Do not retry "
"(~/.claude/scripts/phone_vd.sh, needs no lease), or the headless " "in a loop: drive the app on your own virtual display instead "
"emulator (~/.claude/scripts/phone_emu.sh); see the phone-deploy skill." "(~/.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) raise PhoneBusyError(msg)
clock.sleep(1.0) 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

View File

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

View File

@ -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/<pid>/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}"

View File

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

View File

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

View File

@ -13,6 +13,7 @@ from python_pkg.phone_lease import (
Clock, Clock,
Lease, Lease,
PhoneBusyError, PhoneBusyError,
Terms,
acquire, acquire,
owner_id, owner_id,
release, release,
@ -64,7 +65,7 @@ class TestOwnerId:
d.mkdir(parents=True) d.mkdir(parents=True)
(d / "comm").write_text(f"{comm}\n") (d / "comm").write_text(f"{comm}\n")
(d / "stat").write_text(f"{pid} ({comm}) S {ppid} 1 1 0 -1\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" assert owner_id(pid=300, environ={}) == "claude:200"
def test_falls_back_to_own_pid_without_a_claude_ancestor( def test_falls_back_to_own_pid_without_a_claude_ancestor(
@ -75,7 +76,7 @@ class TestOwnerId:
d.mkdir(parents=True) d.mkdir(parents=True)
(d / "comm").write_text("zsh\n") (d / "comm").write_text("zsh\n")
(d / "stat").write_text("300 (zsh) S 1 1 1 0 -1\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" assert owner_id(pid=300, environ={}) == "pid:300"
def test_unreadable_proc_falls_back_to_own_pid(self) -> None: 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: def test_takes_a_free_phone(self, lease_dir: Path) -> None:
clock = FakeClock() clock = FakeClock()
got = acquire( 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( assert got == Lease(
serial="PIXEL", owner="claude:1", expires=1010.0, note="deploy" serial="PIXEL", owner="claude:1", expires=1010.0, note="deploy"
@ -109,32 +114,58 @@ class TestAcquire:
def test_refreshes_the_same_owner(self) -> None: def test_refreshes_the_same_owner(self) -> None:
clock = FakeClock() 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 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.expires == 1015.0
assert got.note == "b" assert got.note == "b"
assert clock.slept == [] assert clock.slept == []
def test_waits_for_a_foreign_lease_to_expire(self) -> None: def test_waits_for_a_foreign_lease_to_expire(self) -> None:
clock = FakeClock() 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( 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 got.owner == "claude:1"
assert clock.slept == [1.0, 1.0, 1.0] assert clock.slept == [1.0, 1.0, 1.0]
def test_gives_up_naming_the_holder(self) -> None: def test_gives_up_naming_the_holder(self) -> None:
clock = FakeClock() 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( with pytest.raises(
PhoneBusyError, match=r"held by claude:2 \(manga\).*waited 2s" 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: 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) assert got.expires == pytest.approx(time.time() + 5, abs=2)
def test_uses_the_real_owner_by_default(self) -> None: def test_uses_the_real_owner_by_default(self) -> None:
@ -181,11 +212,16 @@ class TestStatus:
assert status("PIXEL") is None assert status("PIXEL") is None
def test_expired_is_free(self) -> 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 assert status("PIXEL", now=200.0) is None
def test_live_describes_the_holder(self) -> 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") live = status("PIXEL")
assert live is not None assert live is not None
assert live.describe().startswith( assert live.describe().startswith(

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
import runpy import runpy
import sys import sys
@ -11,6 +12,7 @@ from unittest.mock import patch
import pytest import pytest
from python_pkg.phone_lease import __main__ as cli 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 from python_pkg.phone_lease import lease as lease_mod
if TYPE_CHECKING: 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: 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 cli.main(["acquire", "PIXEL", "--wait", "0"]) == 3
assert "held by someone-else (manga)" in capsys.readouterr().err 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__") runpy.run_module("python_pkg.phone_lease", run_name="__main__")
assert excinfo.value.code == 0 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

View File

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