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

View File

@ -1,58 +1,114 @@
"""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 _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(serial, scope=scope)
sys.stdout.write("released\n" if dropped else f"not held by {owner_id()}\n")
return 0 if dropped else 1
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 nothing to release, 3 phone held elsewhere."""
"""Run the CLI. Exit 0 ok, 1 not held, 3 held elsewhere, 4 no phone."""
args = _build_parser().parse_args(argv)
if args.command == "acquire":
try:
lease = acquire(args.serial, args.note, ttl=args.ttl, wait=args.wait)
return _run(args)
except PhoneBusyError as exc:
sys.stderr.write(f"phone-lease: {exc}\n")
return 3
sys.stdout.write(f"{lease.describe()}\n")
return 0
if args.command == "release":
dropped = release(args.serial)
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")
return 0
except NoPhoneError as exc:
sys.stderr.write(f"phone-lease: {exc}\n")
return 4
if __name__ == "__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 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

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,
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(

View File

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

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