Stabilize task execution and UI lifecycle
This commit is contained in:
1 parent
c78c201e94
commit
4927c4f0b9
35 files changed
+6680
-239
No files matched your search
@@ -0,0 +1,189 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Cross-process exclusive ownership for a local mobile device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from artemis.config.paths import get_temp_dir
|
||||
|
||||
|
||||
class DeviceBusyError(RuntimeError):
|
||||
"""Raised when another Artemis process already owns a device."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceLockOwner:
|
||||
pid: int
|
||||
process_created_at: float
|
||||
token: str
|
||||
device_id: str
|
||||
description: str
|
||||
acquired_at: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict) -> DeviceLockOwner:
|
||||
return cls(
|
||||
pid=int(value["pid"]),
|
||||
process_created_at=float(value["process_created_at"]),
|
||||
token=str(value["token"]),
|
||||
device_id=str(value["device_id"]),
|
||||
description=str(value.get("description", "unknown task")),
|
||||
acquired_at=str(value.get("acquired_at", "unknown time")),
|
||||
)
|
||||
|
||||
|
||||
class DeviceExecutionLock:
|
||||
"""A PID-aware lock shared by UI, CLI, and MCP task processes.
|
||||
|
||||
Atomic file creation provides the inter-process exclusion. PID creation
|
||||
time prevents a stale lock from blocking a device after a process exits or
|
||||
after the operating system reuses the same PID.
|
||||
"""
|
||||
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
|
||||
|
||||
def __init__(self, device_id: str, description: str = "Artemis task"):
|
||||
self.device_id = device_id
|
||||
self.description = description
|
||||
self.token = uuid.uuid4().hex
|
||||
digest = hashlib.sha256(device_id.encode("utf-8", errors="replace")).hexdigest()[:20]
|
||||
self.path = get_temp_dir("device-locks") / f"device-{digest}.lock"
|
||||
self._acquired = False
|
||||
|
||||
@staticmethod
|
||||
def _current_process_created_at() -> float:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
return float(psutil.Process(os.getpid()).create_time())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(owner.pid)
|
||||
if not process.is_running():
|
||||
return False
|
||||
if owner.process_created_at <= 0:
|
||||
return True
|
||||
return abs(process.create_time() - owner.process_created_at) < 1.0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _read_owner(path: Path) -> DeviceLockOwner | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return DeviceLockOwner.from_dict(payload)
|
||||
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def _remove_stale_lock(self) -> bool:
|
||||
owner = self._read_owner(self.path)
|
||||
if owner is not None:
|
||||
if self._owner_is_alive(owner):
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
if time.time() - self.path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
try:
|
||||
self.path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def acquire(self) -> None:
|
||||
owner_payload = {
|
||||
"pid": os.getpid(),
|
||||
"process_created_at": self._current_process_created_at(),
|
||||
"token": self.token,
|
||||
"device_id": self.device_id,
|
||||
"description": self.description,
|
||||
"acquired_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
for _ in range(4):
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if self._remove_stale_lock():
|
||||
continue
|
||||
owner = self._read_owner(self.path)
|
||||
owner_text = (
|
||||
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
|
||||
if owner is not None
|
||||
else "another process"
|
||||
)
|
||||
raise DeviceBusyError(
|
||||
f"Device '{self.device_id}' is already controlled by {owner_text}. "
|
||||
"Wait for that task to finish or stop it before starting another task."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
self._acquired = True
|
||||
return
|
||||
|
||||
raise DeviceBusyError(f"Could not acquire the execution lock for '{self.device_id}'.")
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
return
|
||||
try:
|
||||
owner = self._read_owner(self.path)
|
||||
if owner is not None and owner.token == self.token:
|
||||
self.path.unlink(missing_ok=True)
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_locks(cls) -> int:
|
||||
"""Remove lock files whose owning process is no longer alive."""
|
||||
removed = 0
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
for path in lock_dir.glob("device-*.lock"):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
Reference in new issue
Block a user