mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +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
|
Two sessions driving the same phone interleave their taps -- on 2026-09-20 a
|
||||||
tap meant for the workout sandbox landed in another session's manga reader,
|
tap meant for the workout sandbox landed in another session's manga reader,
|
||||||
twice. This package hands the phone to one owner at a time:
|
twice. This package hands out *scoped* leases:
|
||||||
|
|
||||||
* the lease is a JSON file per serial under ``~/.cache/phone-lease/``, guarded
|
* the leases are one JSON file per serial under ``~/.cache/phone-lease/``,
|
||||||
by ``flock`` so read-modify-write is atomic across processes;
|
guarded by ``flock`` so read-modify-write is atomic across processes;
|
||||||
* the owner is the nearest ancestor ``claude`` process (one per session), so
|
* a lease covers one app (``--package``), the real screen (the default), or
|
||||||
every call from the same session refreshes the same lease and calls from
|
the whole phone (``--whole-phone``: Doze, sleep, reboot -- capped at five
|
||||||
another session wait, then fail naming the holder;
|
minutes, with cleanup commands a detached reaper runs if nobody releases);
|
||||||
* it expires ``ttl`` seconds after the last refresh, so a session that just
|
* the owner is the nearest ancestor ``claude`` process (one per session);
|
||||||
stops driving frees the phone without anyone remembering to release.
|
* the serial is always resolved (:mod:`.serial`), never a placeholder, so
|
||||||
|
every caller agrees on which phone a lease is for;
|
||||||
|
* a lease expires ``ttl`` seconds after its last refresh.
|
||||||
|
|
||||||
``android_ui`` takes the lease on every command and ``phone_deploy.sh`` on
|
Sessions that drive apps on their own virtual display
|
||||||
every deploy, which is every path that touches the phone from a session.
|
(``~/.claude/scripts/phone_vd.sh``) need only a package lease, so several of
|
||||||
|
them can work on one phone at the same time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from python_pkg.phone_lease.lease import (
|
from python_pkg.phone_lease.lease import (
|
||||||
DEFAULT_TTL,
|
DEFAULT_TTL,
|
||||||
DEFAULT_WAIT,
|
DEFAULT_WAIT,
|
||||||
|
SCREEN,
|
||||||
|
WHOLE_PHONE,
|
||||||
|
WHOLE_PHONE_MAX_TTL,
|
||||||
Clock,
|
Clock,
|
||||||
Lease,
|
Lease,
|
||||||
PhoneBusyError,
|
PhoneBusyError,
|
||||||
|
Terms,
|
||||||
acquire,
|
acquire,
|
||||||
owner_id,
|
scope_of,
|
||||||
release,
|
|
||||||
status,
|
|
||||||
)
|
)
|
||||||
|
from python_pkg.phone_lease.ops import holds, reap, release, status, status_all
|
||||||
|
from python_pkg.phone_lease.owner import owner_id
|
||||||
|
from python_pkg.phone_lease.serial import NoPhoneError, resolve_serial
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_TTL",
|
"DEFAULT_TTL",
|
||||||
"DEFAULT_WAIT",
|
"DEFAULT_WAIT",
|
||||||
|
"SCREEN",
|
||||||
|
"WHOLE_PHONE",
|
||||||
|
"WHOLE_PHONE_MAX_TTL",
|
||||||
"Clock",
|
"Clock",
|
||||||
"Lease",
|
"Lease",
|
||||||
|
"NoPhoneError",
|
||||||
"PhoneBusyError",
|
"PhoneBusyError",
|
||||||
|
"Terms",
|
||||||
"acquire",
|
"acquire",
|
||||||
|
"holds",
|
||||||
"owner_id",
|
"owner_id",
|
||||||
|
"reap",
|
||||||
"release",
|
"release",
|
||||||
|
"resolve_serial",
|
||||||
|
"scope_of",
|
||||||
"status",
|
"status",
|
||||||
|
"status_all",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,59 +1,115 @@
|
|||||||
"""CLI: ``python3 -m python_pkg.phone_lease acquire|release|status <serial>``."""
|
"""CLI: ``python3 -m python_pkg.phone_lease acquire|release|status|holds|reap``.
|
||||||
|
|
||||||
|
The serial is optional everywhere; see :func:`serial.resolve_serial`.
|
||||||
|
Scope flags: ``--package <pkg>`` (one app) or ``--whole-phone``; neither means
|
||||||
|
the real screen, which is what every caller meant before scopes existed.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from dataclasses import asdict
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from python_pkg.phone_lease.lease import (
|
from python_pkg.phone_lease.lease import (
|
||||||
DEFAULT_TTL,
|
DEFAULT_TTL,
|
||||||
DEFAULT_WAIT,
|
DEFAULT_WAIT,
|
||||||
PhoneBusyError,
|
PhoneBusyError,
|
||||||
|
Terms,
|
||||||
acquire,
|
acquire,
|
||||||
owner_id,
|
scope_of,
|
||||||
release,
|
|
||||||
status,
|
|
||||||
)
|
)
|
||||||
|
from python_pkg.phone_lease.ops import holds, reap, release, status_all
|
||||||
|
from python_pkg.phone_lease.owner import owner_id
|
||||||
|
from python_pkg.phone_lease.serial import NoPhoneError, resolve_serial
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("serial", nargs="?", default=None)
|
||||||
|
which = parser.add_mutually_exclusive_group()
|
||||||
|
which.add_argument("--package", default=None, help="lease one app only")
|
||||||
|
which.add_argument(
|
||||||
|
"--whole-phone",
|
||||||
|
action="store_true",
|
||||||
|
help="phone-wide changes (Doze, sleep, reboot); capped at 300 s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_parser() -> argparse.ArgumentParser:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="phone-lease", description=__doc__)
|
parser = argparse.ArgumentParser(prog="phone-lease", description=__doc__)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
take = sub.add_parser(
|
take = sub.add_parser(
|
||||||
"acquire", help="take or refresh the lease (exit 3 when held elsewhere)"
|
"acquire", help="take or refresh a lease (exit 3 when held elsewhere)"
|
||||||
)
|
)
|
||||||
take.add_argument("serial")
|
_scoped(take)
|
||||||
take.add_argument(
|
take.add_argument(
|
||||||
"--note", default="", help="what the holder is doing, shown to the other side"
|
"--note", default="", help="what the holder is doing, shown to the other side"
|
||||||
)
|
)
|
||||||
take.add_argument("--ttl", type=float, default=DEFAULT_TTL)
|
take.add_argument("--ttl", type=float, default=DEFAULT_TTL)
|
||||||
take.add_argument("--wait", type=float, default=DEFAULT_WAIT)
|
take.add_argument("--wait", type=float, default=DEFAULT_WAIT)
|
||||||
drop = sub.add_parser("release", help="drop the lease if this session holds it")
|
take.add_argument(
|
||||||
drop.add_argument("serial")
|
"--cleanup",
|
||||||
look = sub.add_parser("status", help="print the holder, or 'free'")
|
action="append",
|
||||||
look.add_argument("serial")
|
default=[],
|
||||||
|
help="command to run once on release or expiry (repeatable)",
|
||||||
|
)
|
||||||
|
_scoped(sub.add_parser("release", help="drop a lease this session holds"))
|
||||||
|
look = sub.add_parser("status", help="list live leases, or 'free'")
|
||||||
|
look.add_argument("serial", nargs="?", default=None)
|
||||||
|
look.add_argument("--json", action="store_true", help="machine-readable list")
|
||||||
|
ask = sub.add_parser("holds", help="exit 0 if OWNER may act on the scope")
|
||||||
|
_scoped(ask)
|
||||||
|
ask.add_argument("--owner", default=None, help="defaults to this session")
|
||||||
|
sub.add_parser("whoami", help="print this session's owner id")
|
||||||
|
reaper = sub.add_parser("reap", help="internal: run cleanup when a lease expires")
|
||||||
|
reaper.add_argument("serial")
|
||||||
|
reaper.add_argument("--owner", required=True)
|
||||||
|
reaper.add_argument("--scope", required=True)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def _run(args: argparse.Namespace) -> int:
|
||||||
"""Run the CLI. Exit 0 ok, 1 nothing to release, 3 phone held elsewhere."""
|
if args.command == "whoami":
|
||||||
args = _build_parser().parse_args(argv)
|
sys.stdout.write(f"{owner_id()}\n")
|
||||||
if args.command == "acquire":
|
|
||||||
try:
|
|
||||||
lease = acquire(args.serial, args.note, ttl=args.ttl, wait=args.wait)
|
|
||||||
except PhoneBusyError as exc:
|
|
||||||
sys.stderr.write(f"phone-lease: {exc}\n")
|
|
||||||
return 3
|
|
||||||
sys.stdout.write(f"{lease.describe()}\n")
|
|
||||||
return 0
|
return 0
|
||||||
|
if args.command == "reap":
|
||||||
|
reap(args.serial, args.owner, args.scope)
|
||||||
|
return 0
|
||||||
|
serial = resolve_serial(args.serial)
|
||||||
|
if args.command == "status":
|
||||||
|
live = status_all(serial)
|
||||||
|
if args.json:
|
||||||
|
sys.stdout.write(json.dumps([asdict(x) for x in live]) + "\n")
|
||||||
|
else:
|
||||||
|
lines = [x.describe() for x in live] or ["free"]
|
||||||
|
sys.stdout.write("\n".join(lines) + "\n")
|
||||||
|
return 0
|
||||||
|
scope = scope_of(args.package, whole_phone=args.whole_phone)
|
||||||
|
if args.command == "holds":
|
||||||
|
return 0 if holds(serial, args.owner or owner_id(), scope) else 1
|
||||||
if args.command == "release":
|
if args.command == "release":
|
||||||
dropped = release(args.serial)
|
dropped = release(serial, scope=scope)
|
||||||
sys.stdout.write("released\n" if dropped else f"not held by {owner_id()}\n")
|
sys.stdout.write("released\n" if dropped else f"not held by {owner_id()}\n")
|
||||||
return 0 if dropped else 1
|
return 0 if dropped else 1
|
||||||
lease = status(args.serial)
|
terms = Terms(ttl=args.ttl, wait=args.wait, cleanup=tuple(args.cleanup))
|
||||||
sys.stdout.write("free\n" if lease is None else f"{lease.describe()}\n")
|
lease = acquire(serial, args.note, scope=scope, terms=terms)
|
||||||
|
sys.stdout.write(f"{lease.describe()}\n")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run the CLI. Exit 0 ok, 1 not held, 3 held elsewhere, 4 no phone."""
|
||||||
|
args = _build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
return _run(args)
|
||||||
|
except PhoneBusyError as exc:
|
||||||
|
sys.stderr.write(f"phone-lease: {exc}\n")
|
||||||
|
return 3
|
||||||
|
except NoPhoneError as exc:
|
||||||
|
sys.stderr.write(f"phone-lease: {exc}\n")
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@ -10,6 +21,9 @@ from pathlib import Path
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from python_pkg.phone_lease import _cleanup
|
||||||
|
from python_pkg.phone_lease.owner import owner_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
@ -24,158 +38,188 @@ class Clock:
|
|||||||
|
|
||||||
# Seconds a lease outlives its last refresh; a quiet session frees the phone.
|
# Seconds a lease outlives its last refresh; a quiet session frees the phone.
|
||||||
DEFAULT_TTL = 180.0
|
DEFAULT_TTL = 180.0
|
||||||
|
|
||||||
# Seconds ``acquire`` waits on a foreign lease before giving up.
|
# Seconds ``acquire`` waits on a foreign lease before giving up.
|
||||||
DEFAULT_WAIT = 60.0
|
DEFAULT_WAIT = 60.0
|
||||||
|
# A whole-phone lease stops everyone else, so it can never be held for long.
|
||||||
|
WHOLE_PHONE_MAX_TTL = 300.0
|
||||||
|
SCREEN = "__screen__"
|
||||||
|
WHOLE_PHONE = "whole-phone"
|
||||||
# Where lease files live; overridable so tests never touch the real one.
|
# Where lease files live; overridable so tests never touch the real one.
|
||||||
LEASE_DIR = Path(
|
LEASE_DIR = Path(
|
||||||
os.environ.get("PHONE_LEASE_DIR", str(Path.home() / ".cache" / "phone-lease"))
|
os.environ.get("PHONE_LEASE_DIR", str(Path.home() / ".cache" / "phone-lease"))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set this to name the owner explicitly (CI, a script without a claude parent).
|
|
||||||
OWNER_ENV = "PHONE_LEASE_OWNER"
|
|
||||||
|
|
||||||
|
|
||||||
class PhoneBusyError(RuntimeError):
|
class PhoneBusyError(RuntimeError):
|
||||||
"""Raised when a fresh lease belongs to another owner."""
|
"""Raised when a fresh, conflicting lease belongs to another owner."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Lease:
|
class Lease:
|
||||||
"""What the lease file says."""
|
"""One entry of the lease file."""
|
||||||
|
|
||||||
serial: str
|
serial: str
|
||||||
owner: str
|
owner: str
|
||||||
expires: float
|
expires: float
|
||||||
note: str
|
note: str
|
||||||
|
scope: str = f"pkg:{SCREEN}"
|
||||||
|
cleanup: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
def is_expired(self, now: float) -> bool:
|
def is_expired(self, now: float) -> bool:
|
||||||
"""True once ``now`` has passed the expiry."""
|
"""True once ``now`` has passed the expiry."""
|
||||||
return now >= self.expires
|
return now >= self.expires
|
||||||
|
|
||||||
|
def conflicts_with(self, owner: str, scope: str) -> bool:
|
||||||
|
"""Whether this lease stops ``owner`` from taking ``scope``."""
|
||||||
|
if self.owner == owner:
|
||||||
|
return False
|
||||||
|
return WHOLE_PHONE in (self.scope, scope) or self.scope == scope
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
"""One line for an error or a status readout."""
|
"""One line for an error or a status readout."""
|
||||||
until = time.strftime("%H:%M:%S", time.localtime(self.expires))
|
until = time.strftime("%H:%M:%S", time.localtime(self.expires))
|
||||||
return f"phone {self.serial} held by {self.owner} ({self.note}) until {until}"
|
what = "" if self.scope == f"pkg:{SCREEN}" else f" [{self.scope}]"
|
||||||
|
return (
|
||||||
|
f"phone {self.serial}{what} held by {self.owner} ({self.note}) "
|
||||||
|
f"until {until}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def owner_id(pid: int | None = None, environ: dict[str, str] | None = None) -> str:
|
def scope_of(package: str | None = None, *, whole_phone: bool = False) -> str:
|
||||||
"""Identify the calling session.
|
"""The scope string for a package, the whole phone, or the real screen."""
|
||||||
|
if whole_phone:
|
||||||
``PHONE_LEASE_OWNER`` wins. Otherwise walk up from ``pid`` (this process
|
return WHOLE_PHONE
|
||||||
by default) to the nearest ancestor whose ``comm`` is ``claude`` -- one
|
return f"pkg:{package or SCREEN}"
|
||||||
per session -- and fall back to this process's own pid when there is none.
|
|
||||||
"""
|
|
||||||
env = os.environ if environ is None else environ
|
|
||||||
explicit = env.get(OWNER_ENV)
|
|
||||||
if explicit:
|
|
||||||
return explicit
|
|
||||||
current = os.getpid() if pid is None else pid
|
|
||||||
origin = current
|
|
||||||
while current > 1:
|
|
||||||
try:
|
|
||||||
comm = Path(f"/proc/{current}/comm").read_text(encoding="utf-8").strip()
|
|
||||||
stat = Path(f"/proc/{current}/stat").read_text(encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
if comm == "claude":
|
|
||||||
return f"claude:{current}"
|
|
||||||
# Field 4 of /proc/<pid>/stat is the parent pid; the comm field before
|
|
||||||
# it may contain spaces, so split after its closing paren.
|
|
||||||
current = int(stat.rsplit(")", 1)[1].split()[1])
|
|
||||||
return f"pid:{origin}"
|
|
||||||
|
|
||||||
|
|
||||||
def _paths(serial: str) -> tuple[Path, Path]:
|
def lease_paths(serial: str) -> tuple[Path, Path]:
|
||||||
|
"""The lock file and the lease file for ``serial``."""
|
||||||
LEASE_DIR.mkdir(parents=True, exist_ok=True)
|
LEASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
safe = serial.replace("/", "_")
|
safe = serial.replace("/", "_")
|
||||||
return LEASE_DIR / f"{safe}.lock", LEASE_DIR / f"{safe}.json"
|
return LEASE_DIR / f"{safe}.lock", LEASE_DIR / f"{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
def _read(path: Path) -> Lease | None:
|
def _parse(raw: object) -> Lease | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lease = Lease(**raw)
|
||||||
|
except TypeError:
|
||||||
|
return None
|
||||||
|
return Lease(**{**asdict(lease), "cleanup": tuple(lease.cleanup)})
|
||||||
|
|
||||||
|
|
||||||
|
def read_leases(path: Path) -> list[Lease]:
|
||||||
|
"""Every lease in the file. Pre-scope files hold one bare object."""
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except OSError, ValueError:
|
except OSError, ValueError:
|
||||||
return None
|
return []
|
||||||
try:
|
items = raw.get("leases", [raw]) if isinstance(raw, dict) else []
|
||||||
return Lease(**raw)
|
return [lease for lease in map(_parse, items) if lease is not None]
|
||||||
except TypeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _locked_update(
|
def write_leases(path: Path, leases: list[Lease]) -> None:
|
||||||
serial: str,
|
"""Replace the lease file's contents with ``leases``."""
|
||||||
owner: str,
|
path.write_text(
|
||||||
note: str,
|
json.dumps({"leases": [asdict(lease) for lease in leases]}), encoding="utf-8"
|
||||||
ttl: float,
|
)
|
||||||
now: float,
|
|
||||||
) -> Lease:
|
|
||||||
"""One atomic attempt: take or refresh the lease, or return the holder's."""
|
def locked_update(
|
||||||
lock_path, lease_path = _paths(serial)
|
serial: str, change: Callable[[list[Lease], float], list[Lease]], now: float
|
||||||
|
) -> list[Lease]:
|
||||||
|
"""Apply ``change`` to the live leases under the lock.
|
||||||
|
|
||||||
|
Expired leases are dropped on the way in; their cleanup commands run after
|
||||||
|
the lock is released, so a slow ``adb`` never blocks another session.
|
||||||
|
"""
|
||||||
|
lock_path, lease_path = lease_paths(serial)
|
||||||
with lock_path.open("w", encoding="utf-8") as lock:
|
with lock_path.open("w", encoding="utf-8") as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
current = _read(lease_path)
|
leases = read_leases(lease_path)
|
||||||
if (
|
expired = [lease for lease in leases if lease.is_expired(now)]
|
||||||
current is not None
|
live = change([lease for lease in leases if not lease.is_expired(now)], now)
|
||||||
and current.owner != owner
|
write_leases(lease_path, live)
|
||||||
and not current.is_expired(now)
|
for lease in expired:
|
||||||
):
|
_cleanup.run(lease.cleanup)
|
||||||
return current
|
return live
|
||||||
fresh = Lease(serial=serial, owner=owner, expires=now + ttl, note=note)
|
|
||||||
lease_path.write_text(json.dumps(asdict(fresh)), encoding="utf-8")
|
|
||||||
return fresh
|
def _attempt(
|
||||||
|
serial: str, want: Lease, ttl: float, now: float
|
||||||
|
) -> tuple[Lease | None, Lease | None, bool]:
|
||||||
|
"""One locked try at ``want``: (granted lease, blocker, newly taken)."""
|
||||||
|
out: list[Lease | None] = [None, None]
|
||||||
|
fresh = [True]
|
||||||
|
|
||||||
|
def take(live: list[Lease], at: float) -> list[Lease]:
|
||||||
|
blocker = next(
|
||||||
|
(x for x in live if x.conflicts_with(want.owner, want.scope)), None
|
||||||
|
)
|
||||||
|
if blocker is not None:
|
||||||
|
out[1] = blocker
|
||||||
|
return live
|
||||||
|
prior = next(
|
||||||
|
(x for x in live if (x.owner, x.scope) == (want.owner, want.scope)), None
|
||||||
|
)
|
||||||
|
fresh[0] = prior is None
|
||||||
|
granted = Lease(**{**asdict(want), "expires": at + ttl})
|
||||||
|
out[0] = granted
|
||||||
|
return [x for x in live if x is not prior] + [granted]
|
||||||
|
|
||||||
|
locked_update(serial, take, now)
|
||||||
|
return out[0], out[1], fresh[0]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Terms:
|
||||||
|
"""How long to hold, how long to wait, and what to undo afterwards."""
|
||||||
|
|
||||||
|
ttl: float = DEFAULT_TTL
|
||||||
|
wait: float = DEFAULT_WAIT
|
||||||
|
cleanup: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def acquire(
|
def acquire(
|
||||||
serial: str,
|
serial: str,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
*,
|
*,
|
||||||
|
scope: str = f"pkg:{SCREEN}",
|
||||||
|
terms: Terms | None = None,
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
ttl: float = DEFAULT_TTL,
|
|
||||||
wait: float = DEFAULT_WAIT,
|
|
||||||
clock: Clock | None = None,
|
clock: Clock | None = None,
|
||||||
) -> Lease:
|
) -> Lease:
|
||||||
"""Take or refresh the lease on ``serial``.
|
"""Take or refresh a lease on ``serial`` for one ``scope`` (see :func:`scope_of`).
|
||||||
|
|
||||||
Waits up to ``wait`` seconds for a foreign lease to expire, polling once a
|
Waits up to ``terms.wait`` seconds for conflicting foreign leases to
|
||||||
second, then raises :class:`PhoneBusyError` naming the holder.
|
expire, polling once a second, then raises :class:`PhoneBusyError`.
|
||||||
"""
|
"""
|
||||||
me = owner_id() if owner is None else owner
|
me = owner_id() if owner is None else owner
|
||||||
|
terms = terms or Terms()
|
||||||
|
ttl = terms.ttl
|
||||||
|
if scope == WHOLE_PHONE:
|
||||||
|
ttl = min(ttl, WHOLE_PHONE_MAX_TTL)
|
||||||
|
cleanup = terms.cleanup
|
||||||
|
wait = terms.wait
|
||||||
clock = clock or Clock()
|
clock = clock or Clock()
|
||||||
deadline = clock.now() + wait
|
deadline = clock.now() + wait
|
||||||
while True:
|
while True:
|
||||||
lease = _locked_update(serial, me, note, ttl, clock.now())
|
mine, blocker, is_new = _attempt(
|
||||||
if lease.owner == me:
|
serial, Lease(serial, me, 0.0, note, scope, cleanup), ttl, clock.now()
|
||||||
return lease
|
)
|
||||||
|
if mine is not None:
|
||||||
|
if is_new and cleanup:
|
||||||
|
_cleanup.spawn_reaper(serial, me, scope)
|
||||||
|
return mine
|
||||||
if clock.now() >= deadline:
|
if clock.now() >= deadline:
|
||||||
msg = (
|
msg = (
|
||||||
f"{lease.describe()} -- waited {wait:.0f}s. Do not retry in a loop: "
|
f"{blocker.describe() if blocker else serial} -- waited {wait:.0f}s. "
|
||||||
"drive a sandbox-flavor app on the phone's virtual display instead "
|
"Do not retry "
|
||||||
"(~/.claude/scripts/phone_vd.sh, needs no lease), or the headless "
|
"in a loop: drive the app on your own virtual display instead "
|
||||||
"emulator (~/.claude/scripts/phone_emu.sh); see the phone-deploy skill."
|
"(~/.claude/scripts/phone_vd.sh, needs only a package lease), or the "
|
||||||
|
"headless emulator (~/.claude/scripts/phone_emu.sh); see the "
|
||||||
|
"phone-deploy skill."
|
||||||
)
|
)
|
||||||
raise PhoneBusyError(msg)
|
raise PhoneBusyError(msg)
|
||||||
clock.sleep(1.0)
|
clock.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
def release(serial: str, *, owner: str | None = None) -> bool:
|
|
||||||
"""Drop the lease if ``owner`` holds it. Returns whether anything was dropped."""
|
|
||||||
me = owner_id() if owner is None else owner
|
|
||||||
lock_path, lease_path = _paths(serial)
|
|
||||||
with lock_path.open("w", encoding="utf-8") as lock:
|
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
||||||
current = _read(lease_path)
|
|
||||||
if current is None or current.owner != me:
|
|
||||||
return False
|
|
||||||
lease_path.unlink()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def status(serial: str, now: float | None = None) -> Lease | None:
|
|
||||||
"""The live lease on ``serial``, or None when free or expired."""
|
|
||||||
_, lease_path = _paths(serial)
|
|
||||||
current = _read(lease_path)
|
|
||||||
if current is None or current.is_expired(time.time() if now is None else now):
|
|
||||||
return None
|
|
||||||
return current
|
|
||||||
|
|||||||
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,
|
Clock,
|
||||||
Lease,
|
Lease,
|
||||||
PhoneBusyError,
|
PhoneBusyError,
|
||||||
|
Terms,
|
||||||
acquire,
|
acquire,
|
||||||
owner_id,
|
owner_id,
|
||||||
release,
|
release,
|
||||||
@ -64,7 +65,7 @@ class TestOwnerId:
|
|||||||
d.mkdir(parents=True)
|
d.mkdir(parents=True)
|
||||||
(d / "comm").write_text(f"{comm}\n")
|
(d / "comm").write_text(f"{comm}\n")
|
||||||
(d / "stat").write_text(f"{pid} ({comm}) S {ppid} 1 1 0 -1\n")
|
(d / "stat").write_text(f"{pid} ({comm}) S {ppid} 1 1 0 -1\n")
|
||||||
with patch.object(lease_mod.Path, "read_text", _proc_reader(proc)):
|
with patch.object(Path, "read_text", _proc_reader(proc)):
|
||||||
assert owner_id(pid=300, environ={}) == "claude:200"
|
assert owner_id(pid=300, environ={}) == "claude:200"
|
||||||
|
|
||||||
def test_falls_back_to_own_pid_without_a_claude_ancestor(
|
def test_falls_back_to_own_pid_without_a_claude_ancestor(
|
||||||
@ -75,7 +76,7 @@ class TestOwnerId:
|
|||||||
d.mkdir(parents=True)
|
d.mkdir(parents=True)
|
||||||
(d / "comm").write_text("zsh\n")
|
(d / "comm").write_text("zsh\n")
|
||||||
(d / "stat").write_text("300 (zsh) S 1 1 1 0 -1\n")
|
(d / "stat").write_text("300 (zsh) S 1 1 1 0 -1\n")
|
||||||
with patch.object(lease_mod.Path, "read_text", _proc_reader(proc)):
|
with patch.object(Path, "read_text", _proc_reader(proc)):
|
||||||
assert owner_id(pid=300, environ={}) == "pid:300"
|
assert owner_id(pid=300, environ={}) == "pid:300"
|
||||||
|
|
||||||
def test_unreadable_proc_falls_back_to_own_pid(self) -> None:
|
def test_unreadable_proc_falls_back_to_own_pid(self) -> None:
|
||||||
@ -100,7 +101,11 @@ class TestAcquire:
|
|||||||
def test_takes_a_free_phone(self, lease_dir: Path) -> None:
|
def test_takes_a_free_phone(self, lease_dir: Path) -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
got = acquire(
|
got = acquire(
|
||||||
"PIXEL", "deploy", owner="claude:1", ttl=10, clock=clock.as_clock()
|
"PIXEL",
|
||||||
|
"deploy",
|
||||||
|
owner="claude:1",
|
||||||
|
terms=Terms(ttl=10),
|
||||||
|
clock=clock.as_clock(),
|
||||||
)
|
)
|
||||||
assert got == Lease(
|
assert got == Lease(
|
||||||
serial="PIXEL", owner="claude:1", expires=1010.0, note="deploy"
|
serial="PIXEL", owner="claude:1", expires=1010.0, note="deploy"
|
||||||
@ -109,32 +114,58 @@ class TestAcquire:
|
|||||||
|
|
||||||
def test_refreshes_the_same_owner(self) -> None:
|
def test_refreshes_the_same_owner(self) -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
acquire("PIXEL", "a", owner="claude:1", ttl=10, clock=clock.as_clock())
|
acquire(
|
||||||
|
"PIXEL", "a", owner="claude:1", terms=Terms(ttl=10), clock=clock.as_clock()
|
||||||
|
)
|
||||||
clock.now += 5
|
clock.now += 5
|
||||||
got = acquire("PIXEL", "b", owner="claude:1", ttl=10, clock=clock.as_clock())
|
got = acquire(
|
||||||
|
"PIXEL", "b", owner="claude:1", terms=Terms(ttl=10), clock=clock.as_clock()
|
||||||
|
)
|
||||||
assert got.expires == 1015.0
|
assert got.expires == 1015.0
|
||||||
assert got.note == "b"
|
assert got.note == "b"
|
||||||
assert clock.slept == []
|
assert clock.slept == []
|
||||||
|
|
||||||
def test_waits_for_a_foreign_lease_to_expire(self) -> None:
|
def test_waits_for_a_foreign_lease_to_expire(self) -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
acquire("PIXEL", "other", owner="claude:2", ttl=3, clock=clock.as_clock())
|
acquire(
|
||||||
|
"PIXEL",
|
||||||
|
"other",
|
||||||
|
owner="claude:2",
|
||||||
|
terms=Terms(ttl=3),
|
||||||
|
clock=clock.as_clock(),
|
||||||
|
)
|
||||||
got = acquire(
|
got = acquire(
|
||||||
"PIXEL", "mine", owner="claude:1", ttl=10, wait=60, clock=clock.as_clock()
|
"PIXEL",
|
||||||
|
"mine",
|
||||||
|
owner="claude:1",
|
||||||
|
terms=Terms(ttl=10, wait=60),
|
||||||
|
clock=clock.as_clock(),
|
||||||
)
|
)
|
||||||
assert got.owner == "claude:1"
|
assert got.owner == "claude:1"
|
||||||
assert clock.slept == [1.0, 1.0, 1.0]
|
assert clock.slept == [1.0, 1.0, 1.0]
|
||||||
|
|
||||||
def test_gives_up_naming_the_holder(self) -> None:
|
def test_gives_up_naming_the_holder(self) -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
acquire("PIXEL", "manga", owner="claude:2", ttl=1000, clock=clock.as_clock())
|
acquire(
|
||||||
|
"PIXEL",
|
||||||
|
"manga",
|
||||||
|
owner="claude:2",
|
||||||
|
terms=Terms(ttl=1000),
|
||||||
|
clock=clock.as_clock(),
|
||||||
|
)
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
PhoneBusyError, match=r"held by claude:2 \(manga\).*waited 2s"
|
PhoneBusyError, match=r"held by claude:2 \(manga\).*waited 2s"
|
||||||
):
|
):
|
||||||
acquire("PIXEL", "mine", owner="claude:1", wait=2, clock=clock.as_clock())
|
acquire(
|
||||||
|
"PIXEL",
|
||||||
|
"mine",
|
||||||
|
owner="claude:1",
|
||||||
|
terms=Terms(wait=2),
|
||||||
|
clock=clock.as_clock(),
|
||||||
|
)
|
||||||
|
|
||||||
def test_uses_the_real_clock_by_default(self) -> None:
|
def test_uses_the_real_clock_by_default(self) -> None:
|
||||||
got = acquire("PIXEL", owner="claude:1", ttl=5)
|
got = acquire("PIXEL", owner="claude:1", terms=Terms(ttl=5))
|
||||||
assert got.expires == pytest.approx(time.time() + 5, abs=2)
|
assert got.expires == pytest.approx(time.time() + 5, abs=2)
|
||||||
|
|
||||||
def test_uses_the_real_owner_by_default(self) -> None:
|
def test_uses_the_real_owner_by_default(self) -> None:
|
||||||
@ -181,11 +212,16 @@ class TestStatus:
|
|||||||
assert status("PIXEL") is None
|
assert status("PIXEL") is None
|
||||||
|
|
||||||
def test_expired_is_free(self) -> None:
|
def test_expired_is_free(self) -> None:
|
||||||
acquire("PIXEL", owner="claude:1", ttl=1, clock=FakeClock(now=100.0).as_clock())
|
acquire(
|
||||||
|
"PIXEL",
|
||||||
|
owner="claude:1",
|
||||||
|
terms=Terms(ttl=1),
|
||||||
|
clock=FakeClock(now=100.0).as_clock(),
|
||||||
|
)
|
||||||
assert status("PIXEL", now=200.0) is None
|
assert status("PIXEL", now=200.0) is None
|
||||||
|
|
||||||
def test_live_describes_the_holder(self) -> None:
|
def test_live_describes_the_holder(self) -> None:
|
||||||
acquire("PIXEL", "deploy", owner="claude:1", ttl=1000)
|
acquire("PIXEL", "deploy", owner="claude:1", terms=Terms(ttl=1000))
|
||||||
live = status("PIXEL")
|
live = status("PIXEL")
|
||||||
assert live is not None
|
assert live is not None
|
||||||
assert live.describe().startswith(
|
assert live.describe().startswith(
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import runpy
|
import runpy
|
||||||
import sys
|
import sys
|
||||||
@ -11,6 +12,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.phone_lease import __main__ as cli
|
from python_pkg.phone_lease import __main__ as cli
|
||||||
|
from python_pkg.phone_lease import _cleanup
|
||||||
from python_pkg.phone_lease import lease as lease_mod
|
from python_pkg.phone_lease import lease as lease_mod
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -40,7 +42,9 @@ def test_release_without_holding(capsys: pytest.CaptureFixture[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_foreign_holder_exits_3(capsys: pytest.CaptureFixture[str]) -> None:
|
def test_foreign_holder_exits_3(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
lease_mod.acquire("PIXEL", "manga", owner="someone-else", ttl=1000)
|
lease_mod.acquire(
|
||||||
|
"PIXEL", "manga", owner="someone-else", terms=lease_mod.Terms(ttl=1000)
|
||||||
|
)
|
||||||
assert cli.main(["acquire", "PIXEL", "--wait", "0"]) == 3
|
assert cli.main(["acquire", "PIXEL", "--wait", "0"]) == 3
|
||||||
assert "held by someone-else (manga)" in capsys.readouterr().err
|
assert "held by someone-else (manga)" in capsys.readouterr().err
|
||||||
|
|
||||||
@ -55,3 +59,59 @@ def test_module_entry_point() -> None:
|
|||||||
):
|
):
|
||||||
runpy.run_module("python_pkg.phone_lease", run_name="__main__")
|
runpy.run_module("python_pkg.phone_lease", run_name="__main__")
|
||||||
assert excinfo.value.code == 0
|
assert excinfo.value.code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_scopes_coexist_and_holds(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with patch.dict("os.environ", {"PHONE_LEASE_OWNER": "me"}):
|
||||||
|
assert cli.main(["acquire", "PIXEL", "--package", "a.b"]) == 0
|
||||||
|
assert "[pkg:a.b]" in capsys.readouterr().out
|
||||||
|
assert cli.main(["holds", "PIXEL", "--package", "a.b"]) == 0
|
||||||
|
assert cli.main(["holds", "PIXEL", "--package", "c.d"]) == 1
|
||||||
|
assert cli.main(["holds", "PIXEL", "--package", "a.b", "--owner", "x"]) == 1
|
||||||
|
assert cli.main(["release", "PIXEL", "--package", "a.b"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_phone_with_cleanup(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(_cleanup, "spawn_reaper") as spawn,
|
||||||
|
patch.object(_cleanup, "run") as run,
|
||||||
|
):
|
||||||
|
argv = ["acquire", "PIXEL", "--whole-phone", "--cleanup", "adb x", "--ttl", "9"]
|
||||||
|
assert cli.main(argv) == 0
|
||||||
|
spawn.assert_called_once()
|
||||||
|
assert cli.main(["release", "PIXEL", "--whole-phone"]) == 0
|
||||||
|
run.assert_called_once_with(("adb x",))
|
||||||
|
assert "[whole-phone]" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_json_and_text(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert cli.main(["status", "PIXEL", "--json"]) == 0
|
||||||
|
assert json.loads(capsys.readouterr().out) == []
|
||||||
|
lease_mod.acquire("PIXEL", "n", owner="o")
|
||||||
|
assert cli.main(["status", "PIXEL", "--json"]) == 0
|
||||||
|
(only,) = json.loads(capsys.readouterr().out)
|
||||||
|
assert (only["owner"], only["scope"]) == ("o", "pkg:__screen__")
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with patch.dict("os.environ", {"PHONE_LEASE_OWNER": "claude:42"}):
|
||||||
|
assert cli.main(["whoami"]) == 0
|
||||||
|
assert capsys.readouterr().out == "claude:42\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reap_subcommand_delegates() -> None:
|
||||||
|
with patch.object(cli, "reap") as reap:
|
||||||
|
assert cli.main(["reap", "S", "--owner", "o", "--scope", "whole-phone"]) == 0
|
||||||
|
reap.assert_called_once_with("S", "o", "whole-phone")
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_resolved_or_exit_4(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with patch.dict("os.environ", {"ANDROID_SERIAL": "ENVPHONE"}):
|
||||||
|
assert cli.main(["status"]) == 0
|
||||||
|
assert capsys.readouterr().out == "free\n"
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"ANDROID_SERIAL": "", "ADB_SERIAL": ""}),
|
||||||
|
patch("python_pkg.phone_lease.serial._adb_serial", return_value=""),
|
||||||
|
):
|
||||||
|
assert cli.main(["status"]) == 4
|
||||||
|
assert "no phone serial" in capsys.readouterr().err
|
||||||
|
|||||||
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