mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 17:47:05 +01:00
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:
parent
baa50cdb60
commit
c0c51036fc
@ -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."
|
||||
}
|
||||
20
docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json
Normal file
20
docs/superpowers/evidence/phone-lease-scopes-2026-09-25.json
Normal 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"]
|
||||
}
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
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())
|
||||
|
||||
69
python_pkg/phone_lease/_cleanup.py
Normal file
69
python_pkg/phone_lease/_cleanup.py
Normal 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,
|
||||
)
|
||||
@ -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 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/<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 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
|
||||
|
||||
103
python_pkg/phone_lease/ops.py
Normal file
103
python_pkg/phone_lease/ops.py
Normal 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
|
||||
36
python_pkg/phone_lease/owner.py
Normal file
36
python_pkg/phone_lease/owner.py
Normal 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}"
|
||||
62
python_pkg/phone_lease/serial.py
Normal file
62
python_pkg/phone_lease/serial.py
Normal 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
|
||||
116
python_pkg/phone_lease/tests/test_cleanup_serial.py
Normal file
116
python_pkg/phone_lease/tests/test_cleanup_serial.py
Normal 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"]
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
200
python_pkg/phone_lease/tests/test_scopes.py
Normal file
200
python_pkg/phone_lease/tests/test_scopes.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user