feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -19,6 +19,14 @@ from artemis.runtime.device_lock import (
|
||||
DeviceBusyError,
|
||||
DeviceExecutionLock,
|
||||
)
|
||||
from artemis.runtime.adb_endpoint import (
|
||||
ADB_ENDPOINT_ID_ENV,
|
||||
AdbEndpoint,
|
||||
AdbSession,
|
||||
AdbTarget,
|
||||
InvalidAdbEndpoint,
|
||||
current_adb_endpoint,
|
||||
)
|
||||
from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool
|
||||
from artemis.runtime.daemon_client import (
|
||||
ensure_daemon_running,
|
||||
@@ -49,12 +57,18 @@ from artemis.runtime.server_lifecycle import (
|
||||
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
||||
|
||||
__all__ = [
|
||||
"ADB_ENDPOINT_ID_ENV",
|
||||
"AdbEndpoint",
|
||||
"AdbSession",
|
||||
"AdbTarget",
|
||||
"ConcurrencyMode",
|
||||
"DeviceBusyError",
|
||||
"DeviceExecutionLock",
|
||||
"DevicePool",
|
||||
"DeviceStatus",
|
||||
"InvalidAdbEndpoint",
|
||||
"clear_server_info",
|
||||
"current_adb_endpoint",
|
||||
"device_pool",
|
||||
"ensure_daemon_running",
|
||||
"ensure_device_awake",
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# 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.
|
||||
|
||||
"""Immutable ADB endpoint and task target primitives.
|
||||
|
||||
The selected endpoint is a user preference. An :class:`AdbTarget` is an
|
||||
execution snapshot. Keeping those concepts separate prevents a queued or
|
||||
running task from silently moving to another ADB server when the preference
|
||||
changes in the Admin Console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Mapping, MutableMapping, Sequence
|
||||
|
||||
from artemis.config import settings
|
||||
from artemis.config.constants import DEFAULT_ADB_HOST, DEFAULT_ADB_PORT
|
||||
from artemis.toolchain import toolchain
|
||||
|
||||
|
||||
ADB_ENDPOINT_ID_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
|
||||
_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
_SAFE_HOST_PATTERN = re.compile(r"^[A-Za-z0-9._:\-\[\]]+$")
|
||||
|
||||
|
||||
class InvalidAdbEndpoint(ValueError):
|
||||
"""Raised when an ADB server endpoint is malformed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdbEndpoint:
|
||||
"""Network address of one ADB server."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@classmethod
|
||||
def create(cls, host: str, port: int) -> AdbEndpoint:
|
||||
clean_host = str(host).strip()
|
||||
if clean_host.startswith("[") and clean_host.endswith("]"):
|
||||
clean_host = clean_host[1:-1]
|
||||
if not clean_host:
|
||||
raise InvalidAdbEndpoint("ADB server host cannot be empty.")
|
||||
if len(clean_host) > 253 or not _SAFE_HOST_PATTERN.fullmatch(clean_host):
|
||||
raise InvalidAdbEndpoint(
|
||||
"Enter a valid IP address or host name without a URL scheme or path."
|
||||
)
|
||||
if not 1 <= int(port) <= 65535:
|
||||
raise InvalidAdbEndpoint("ADB server port must be between 1 and 65535.")
|
||||
return cls(host=clean_host, port=int(port))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> AdbEndpoint:
|
||||
return cls.create(str(value.get("host", "")), int(value.get("port", 0)))
|
||||
|
||||
@classmethod
|
||||
def local(cls) -> AdbEndpoint:
|
||||
return cls(DEFAULT_ADB_HOST, DEFAULT_ADB_PORT)
|
||||
|
||||
@property
|
||||
def socket(self) -> str:
|
||||
host = self.host
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
return f"tcp:{host}:{self.port}"
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""Stable identity used for task snapshots and device-lock scoping."""
|
||||
return self.socket.lower()
|
||||
|
||||
@property
|
||||
def is_local_default(self) -> bool:
|
||||
return self.host.lower() in _LOCAL_HOSTS and self.port == DEFAULT_ADB_PORT
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
return "local" if self.is_local_default else "remote"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
**asdict(self),
|
||||
"socket": self.socket,
|
||||
"identity": self.identity,
|
||||
"mode": self.mode,
|
||||
"is_local_default": self.is_local_default,
|
||||
}
|
||||
|
||||
def apply_to_environment(
|
||||
self,
|
||||
environment: MutableMapping[str, str] | None = None,
|
||||
) -> MutableMapping[str, str]:
|
||||
target = environment if environment is not None else os.environ
|
||||
target["ADB_HOST"] = self.host
|
||||
target["ADB_PORT"] = str(self.port)
|
||||
target["ADB_SERVER_SOCKET"] = self.socket
|
||||
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
||||
return target
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdbTarget:
|
||||
"""A device serial bound to the ADB endpoint that discovered it."""
|
||||
|
||||
endpoint: AdbEndpoint
|
||||
serial: str | None = None
|
||||
|
||||
@property
|
||||
def lock_scope(self) -> str:
|
||||
return self.endpoint.identity
|
||||
|
||||
@property
|
||||
def lock_key(self) -> str:
|
||||
return f"{self.lock_scope}/{self.serial or 'pending'}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"endpoint": self.endpoint.to_dict(), "serial": self.serial}
|
||||
|
||||
|
||||
class AdbSession:
|
||||
"""Execute ADB commands against one explicit, immutable endpoint."""
|
||||
|
||||
def __init__(self, endpoint: AdbEndpoint, adb_path: str | None = None) -> None:
|
||||
self.endpoint = endpoint
|
||||
self.adb_path = adb_path or toolchain.resolve("adb")
|
||||
|
||||
def command(self, arguments: Sequence[str]) -> list[str]:
|
||||
if not self.adb_path:
|
||||
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
|
||||
return [
|
||||
self.adb_path,
|
||||
"-H",
|
||||
self.endpoint.host,
|
||||
"-P",
|
||||
str(self.endpoint.port),
|
||||
*arguments,
|
||||
]
|
||||
|
||||
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
environment = dict(base if base is not None else os.environ)
|
||||
self.endpoint.apply_to_environment(environment)
|
||||
return environment
|
||||
|
||||
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
||||
kwargs.setdefault("env", self.environment())
|
||||
return subprocess.run(self.command(arguments), **kwargs)
|
||||
|
||||
|
||||
def current_adb_endpoint() -> AdbEndpoint:
|
||||
"""Return the process preference as an immutable endpoint snapshot."""
|
||||
host = settings.ADB_HOST or os.environ.get("ADB_HOST") or DEFAULT_ADB_HOST
|
||||
port = settings.ADB_PORT or os.environ.get("ADB_PORT") or DEFAULT_ADB_PORT
|
||||
return AdbEndpoint.create(str(host), int(port))
|
||||
@@ -62,11 +62,24 @@ def is_daemon_running(
|
||||
return False
|
||||
|
||||
|
||||
def daemon_log_path(port: int = DEFAULT_DAEMON_PORT) -> Path:
|
||||
"""Return the log file path used by a background-spawned Daemon on the given port."""
|
||||
from artemis.config.paths import get_app_dir
|
||||
|
||||
return get_app_dir() / "logs" / f"daemon-{port}.log"
|
||||
|
||||
|
||||
def spawn_daemon(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
) -> subprocess.Popen | None:
|
||||
"""Silently spawn the Artemis Daemon server in the background as a detached process."""
|
||||
"""Silently spawn the Artemis Daemon server in the background as a detached process.
|
||||
|
||||
The server is always launched via ``sys.executable -m`` (never through the
|
||||
``artemis``/``artemis-admin`` console-script shims) so the long-lived process
|
||||
chain never holds a lock on ``Scripts/artemis.exe`` — on Windows, a resident
|
||||
shim blocks ``uv sync``/reinstalls with "os error 32" until it exits.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
@@ -81,11 +94,21 @@ def spawn_daemon(
|
||||
str(port),
|
||||
]
|
||||
|
||||
# Persist daemon output for post-mortem debugging (daemon mode is the
|
||||
# default for `artemis restart`, so silent DEVNULL would hide crashes).
|
||||
log_handle: Any = subprocess.DEVNULL
|
||||
try:
|
||||
log_file = daemon_log_path(port)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_handle = open(log_file, "ab")
|
||||
except Exception:
|
||||
log_handle = subprocess.DEVNULL
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"cwd": str(ROOT_DIR),
|
||||
"env": env,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"stdout": log_handle,
|
||||
"stderr": subprocess.STDOUT if log_handle is not subprocess.DEVNULL else subprocess.DEVNULL,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
}
|
||||
|
||||
@@ -105,6 +128,13 @@ def spawn_daemon(
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not auto-spawn Artemis Daemon: {exc}")
|
||||
return None
|
||||
finally:
|
||||
# The child inherited the handle; the parent's copy is no longer needed.
|
||||
if log_handle is not subprocess.DEVNULL:
|
||||
try:
|
||||
log_handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_daemon_running(
|
||||
|
||||
+107
-17
@@ -50,6 +50,7 @@ class DeviceLockOwner:
|
||||
acquired_at: str
|
||||
session_id: str | None = None
|
||||
ingress: str | None = None
|
||||
lock_scope: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict) -> DeviceLockOwner:
|
||||
@@ -66,6 +67,11 @@ class DeviceLockOwner:
|
||||
else None
|
||||
),
|
||||
ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
|
||||
lock_scope=(
|
||||
str(value["lock_scope"])
|
||||
if value.get("lock_scope") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +90,7 @@ class DeviceExecutionLock:
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
|
||||
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
|
||||
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
|
||||
LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_device_id(device_id: str | None) -> str:
|
||||
@@ -96,6 +103,16 @@ class DeviceExecutionLock:
|
||||
clean = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
|
||||
return clean or "default"
|
||||
|
||||
@classmethod
|
||||
def _normalize_lock_id(
|
||||
cls,
|
||||
device_id: str | None,
|
||||
lock_scope: str | None = None,
|
||||
) -> str:
|
||||
clean_device = cls._normalize_device_id(device_id)
|
||||
clean_scope = cls._normalize_device_id(lock_scope) if lock_scope else None
|
||||
return f"{clean_scope}__{clean_device}" if clean_scope else clean_device
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str = "default",
|
||||
@@ -105,9 +122,11 @@ class DeviceExecutionLock:
|
||||
concurrency_mode: str | None = None,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
lock_scope: str | None = None,
|
||||
):
|
||||
self.device_id = device_id or "default"
|
||||
self.clean_device_id = self._normalize_device_id(self.device_id)
|
||||
self.lock_scope = lock_scope or os.getenv(self.LOCK_SCOPE_ENV) or None
|
||||
self.clean_device_id = self._normalize_lock_id(self.device_id, self.lock_scope)
|
||||
self.description = description
|
||||
self.max_concurrency = max_concurrency
|
||||
self.concurrency_mode = (
|
||||
@@ -130,8 +149,15 @@ class DeviceExecutionLock:
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def _device_lock_path(cls, device_id: str | None) -> Path:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
def _device_lock_path(
|
||||
cls,
|
||||
device_id: str | None,
|
||||
lock_scope: str | None = None,
|
||||
) -> Path:
|
||||
clean_id = cls._normalize_lock_id(
|
||||
device_id,
|
||||
lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
|
||||
)
|
||||
return get_temp_dir("device-locks") / f"artemis-device-{clean_id}.lock"
|
||||
|
||||
@classmethod
|
||||
@@ -224,6 +250,7 @@ class DeviceExecutionLock:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
lock_scope: str | None = None,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"pid": os.getpid(),
|
||||
@@ -237,6 +264,8 @@ class DeviceExecutionLock:
|
||||
payload["session_id"] = str(session_id)
|
||||
if ingress:
|
||||
payload["ingress"] = str(ingress)
|
||||
if lock_scope:
|
||||
payload["lock_scope"] = str(lock_scope)
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
@@ -247,6 +276,7 @@ class DeviceExecutionLock:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
lock_scope: str | None = None,
|
||||
) -> str:
|
||||
"""Reserve a FIFO position in the global queue before a worker process is spawned."""
|
||||
token = uuid.uuid4().hex
|
||||
@@ -258,6 +288,7 @@ class DeviceExecutionLock:
|
||||
description,
|
||||
session_id=session_id,
|
||||
ingress=ingress,
|
||||
lock_scope=lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None,
|
||||
)
|
||||
for _ in range(8):
|
||||
path = queue_dir / f"{time.time_ns():020d}-{token}.wait"
|
||||
@@ -299,6 +330,7 @@ class DeviceExecutionLock:
|
||||
device_id: str = "pending",
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
lock_scope: str | None = None,
|
||||
) -> bool:
|
||||
"""Transfer a submission ticket to its worker process."""
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
@@ -326,6 +358,9 @@ class DeviceExecutionLock:
|
||||
payload["session_id"] = str(session_id)
|
||||
if ingress:
|
||||
payload["ingress"] = str(ingress)
|
||||
effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
|
||||
if effective_scope:
|
||||
payload["lock_scope"] = str(effective_scope)
|
||||
try:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError:
|
||||
@@ -352,6 +387,7 @@ class DeviceExecutionLock:
|
||||
self.description,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
lock_scope=self.lock_scope,
|
||||
)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
@@ -361,6 +397,7 @@ class DeviceExecutionLock:
|
||||
self.device_id,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
lock_scope=self.lock_scope,
|
||||
)
|
||||
path = self._find_reserved_ticket()
|
||||
if path is None:
|
||||
@@ -397,6 +434,7 @@ class DeviceExecutionLock:
|
||||
self.description,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
lock_scope=self.lock_scope,
|
||||
)
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
@@ -474,7 +512,11 @@ class DeviceExecutionLock:
|
||||
o
|
||||
for o in active_owners.values()
|
||||
if not (
|
||||
(o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token))
|
||||
o.pid == os.getpid()
|
||||
and (
|
||||
(self.session_id and o.session_id == self.session_id)
|
||||
or o.token == self.token
|
||||
)
|
||||
)
|
||||
]
|
||||
if len(active_other_owners) >= effective_concurrency:
|
||||
@@ -513,7 +555,18 @@ class DeviceExecutionLock:
|
||||
if ticket_owner is None:
|
||||
continue
|
||||
ticket_dev = self._normalize_device_id(ticket_owner.device_id)
|
||||
if ticket_dev == target_dev or ticket_dev in ("pending", "any"):
|
||||
ticket_lock_id = self._normalize_lock_id(
|
||||
ticket_owner.device_id,
|
||||
ticket_owner.lock_scope,
|
||||
)
|
||||
same_scope = (
|
||||
not ticket_owner.lock_scope
|
||||
or not self.lock_scope
|
||||
or ticket_owner.lock_scope == self.lock_scope
|
||||
)
|
||||
if ticket_lock_id == target_dev or (
|
||||
ticket_dev in ("pending", "any") and same_scope
|
||||
):
|
||||
device_queue.append(wait_path)
|
||||
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
|
||||
|
||||
@@ -585,14 +638,13 @@ class DeviceExecutionLock:
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if not lock_dir.exists():
|
||||
return {}
|
||||
active_map: dict[str, DeviceLockOwner] = {}
|
||||
seen_tokens = set()
|
||||
owners: list[DeviceLockOwner] = []
|
||||
seen_tokens: set[str] = set()
|
||||
|
||||
for lock_path in sorted(lock_dir.glob("artemis-device-*.lock")):
|
||||
owner = cls._get_active_owner_for_path(lock_path)
|
||||
if owner is not None and owner.token not in seen_tokens:
|
||||
dev_key = cls._normalize_device_id(owner.device_id)
|
||||
active_map[dev_key] = owner
|
||||
owners.append(owner)
|
||||
seen_tokens.add(owner.token)
|
||||
|
||||
# Also check legacy global lock if still present
|
||||
@@ -600,22 +652,45 @@ class DeviceExecutionLock:
|
||||
if global_lock.exists():
|
||||
owner = cls._get_active_owner_for_path(global_lock)
|
||||
if owner is not None and owner.token not in seen_tokens:
|
||||
dev_key = cls._normalize_device_id(owner.device_id)
|
||||
active_map[dev_key] = owner
|
||||
owners.append(owner)
|
||||
seen_tokens.add(owner.token)
|
||||
|
||||
active_map: dict[str, DeviceLockOwner] = {}
|
||||
owners_by_device: dict[str, list[DeviceLockOwner]] = {}
|
||||
for owner in owners:
|
||||
key = cls._normalize_device_id(owner.device_id)
|
||||
owners_by_device.setdefault(key, []).append(owner)
|
||||
for device_key, device_owners in owners_by_device.items():
|
||||
if len(device_owners) == 1:
|
||||
active_map[device_key] = device_owners[0]
|
||||
continue
|
||||
for owner in device_owners:
|
||||
scoped_key = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
|
||||
active_map[scoped_key] = owner
|
||||
return active_map
|
||||
|
||||
@classmethod
|
||||
def get_active_owner(cls, device_id: str | None = None) -> DeviceLockOwner | None:
|
||||
def get_active_owner(
|
||||
cls,
|
||||
device_id: str | None = None,
|
||||
lock_scope: str | None = None,
|
||||
) -> DeviceLockOwner | None:
|
||||
"""Return the live process owning the lock for a specific device, or any device if None."""
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
if device_id is not None:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
effective_scope = lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None
|
||||
clean_id = cls._normalize_lock_id(device_id, effective_scope)
|
||||
dev_path = lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
owner = cls._get_active_owner_for_path(dev_path)
|
||||
if owner is not None:
|
||||
return owner
|
||||
if effective_scope:
|
||||
legacy_path = lock_dir / (
|
||||
f"artemis-device-{cls._normalize_device_id(device_id)}.lock"
|
||||
)
|
||||
owner = cls._get_active_owner_for_path(legacy_path)
|
||||
if owner is not None:
|
||||
return owner
|
||||
# Check legacy global lock as fallback
|
||||
global_path = lock_dir / "artemis-global-device.lock"
|
||||
if global_path.exists():
|
||||
@@ -636,7 +711,10 @@ class DeviceExecutionLock:
|
||||
if not queue_dir.exists():
|
||||
return []
|
||||
|
||||
clean_target = cls._normalize_device_id(device_id) if device_id else None
|
||||
target_scope = os.getenv(cls.LOCK_SCOPE_ENV) or None
|
||||
clean_target = (
|
||||
cls._normalize_lock_id(device_id, target_scope) if device_id else None
|
||||
)
|
||||
active_tokens = {o.token for o in cls.get_active_owners().values()}
|
||||
queued: list[dict[str, Any]] = []
|
||||
|
||||
@@ -652,7 +730,15 @@ class DeviceExecutionLock:
|
||||
continue
|
||||
|
||||
target_dev = cls._normalize_device_id(owner.device_id)
|
||||
if clean_target is not None and target_dev not in (clean_target, "pending", "any"):
|
||||
target_lock_id = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
|
||||
same_scope = (
|
||||
not target_scope
|
||||
or not owner.lock_scope
|
||||
or owner.lock_scope == target_scope
|
||||
)
|
||||
if clean_target is not None and target_lock_id != clean_target and not (
|
||||
target_dev in ("pending", "any") and same_scope
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -665,6 +751,7 @@ class DeviceExecutionLock:
|
||||
"goal": owner.description,
|
||||
"device_id": owner.device_id,
|
||||
"device_serial": owner.device_id,
|
||||
"adb_endpoint_id": owner.lock_scope,
|
||||
"pid": owner.pid,
|
||||
"token": owner.token,
|
||||
"ingress": owner.ingress or "unknown",
|
||||
@@ -681,7 +768,10 @@ class DeviceExecutionLock:
|
||||
if not lock_dir.exists():
|
||||
return False
|
||||
if device_id is not None:
|
||||
clean_id = cls._normalize_device_id(device_id)
|
||||
clean_id = cls._normalize_lock_id(
|
||||
device_id,
|
||||
os.getenv(cls.LOCK_SCOPE_ENV) or None,
|
||||
)
|
||||
return (
|
||||
lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
).exists() or (lock_dir / "artemis-global-device.lock").exists()
|
||||
@@ -694,7 +784,7 @@ class DeviceExecutionLock:
|
||||
"""Revalidate a previously read owner before performing a destructive action."""
|
||||
target_dev = device_id or expected.device_id
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
clean_id = cls._normalize_device_id(target_dev)
|
||||
clean_id = cls._normalize_lock_id(target_dev, expected.lock_scope)
|
||||
path = lock_dir / f"artemis-device-{clean_id}.lock"
|
||||
if not path.exists():
|
||||
path = lock_dir / "artemis-global-device.lock"
|
||||
|
||||
+233
-38
@@ -18,8 +18,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
@@ -62,8 +65,28 @@ class DeviceStatus:
|
||||
class DevicePool:
|
||||
"""Manages discovery and assignment across all connected Android devices."""
|
||||
|
||||
# A snapshot younger than this is returned without touching adb, so a burst
|
||||
# of concurrent enumerations (UI polling, readiness probes, submissions)
|
||||
# collapses into a single `adb devices` call.
|
||||
CACHE_TTL = 1.0
|
||||
# When enumeration fails outright (adb server restarting, subprocess error),
|
||||
# the last successful snapshot keeps answering for this long. Real
|
||||
# disconnects are not failures -- adb reports them as a successful listing
|
||||
# without the device -- so serving stale here only bridges server-level blips.
|
||||
STALE_ON_ERROR_TTL = 10.0
|
||||
# Until one enumeration has succeeded, `adb devices` may need to spawn the
|
||||
# adb server itself, which routinely exceeds the hot-path budget.
|
||||
HOT_QUERY_TIMEOUT = 2.0
|
||||
COLD_QUERY_TIMEOUT = 8.0
|
||||
|
||||
def __init__(self, adb_path: str | None = None):
|
||||
self._adb_path = adb_path
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cached_raw: list[tuple[str, str, str | None, str | None]] | None = None
|
||||
self._cached_at = 0.0
|
||||
self._warmed = False
|
||||
self._sync_query_gate = threading.Lock()
|
||||
self._async_inflight: tuple[asyncio.AbstractEventLoop, asyncio.Task] | None = None
|
||||
|
||||
def _resolve_adb(self) -> str | None:
|
||||
if self._adb_path:
|
||||
@@ -73,32 +96,41 @@ class DevicePool:
|
||||
except Exception:
|
||||
return shutil.which("adb")
|
||||
|
||||
def _query_adb_devices_sync(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
|
||||
"""Run `adb devices -l` synchronously to parse attached devices."""
|
||||
def _current_query_timeout(self) -> float:
|
||||
return self.HOT_QUERY_TIMEOUT if self._warmed else self.COLD_QUERY_TIMEOUT
|
||||
|
||||
def _query_adb_devices_sync(
|
||||
self, timeout: float | None = None
|
||||
) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
"""Run `adb devices -l` synchronously. Returns None when the query
|
||||
itself failed, as opposed to an empty list of attached devices."""
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return []
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[adb, "devices", "-l"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
timeout=timeout if timeout is not None else self._current_query_timeout(),
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
return None
|
||||
return self._parse_device_lines(res.stdout.splitlines())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error querying adb devices: {exc}")
|
||||
return []
|
||||
return None
|
||||
|
||||
async def _query_adb_devices_async(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
|
||||
"""Run `adb devices -l` asynchronously to parse attached devices."""
|
||||
async def _query_adb_devices_async(
|
||||
self, timeout: float | None = None
|
||||
) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
"""Run `adb devices -l` asynchronously. Returns None when the query
|
||||
itself failed, as opposed to an empty list of attached devices."""
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return []
|
||||
return None
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -108,9 +140,12 @@ class DevicePool:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout if timeout is not None else self._current_query_timeout(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return None
|
||||
return self._parse_device_lines(stdout.decode(errors="replace").splitlines())
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error querying adb devices asynchronously: {exc}")
|
||||
@@ -119,7 +154,7 @@ class DevicePool:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_device_lines(lines: list[str]) -> list[tuple[str, str, str | None, str | None]]:
|
||||
@@ -143,16 +178,134 @@ class DevicePool:
|
||||
results.append((serial, state, model, product))
|
||||
return results
|
||||
|
||||
def list_devices(self) -> list[DeviceStatus]:
|
||||
"""Synchronously list all connected devices along with their active lock state."""
|
||||
raw_devices = self._query_adb_devices_sync()
|
||||
def _cached_snapshot(
|
||||
self, *, allow_stale: bool
|
||||
) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
with self._cache_lock:
|
||||
if self._cached_raw is None:
|
||||
return None
|
||||
age = time.monotonic() - self._cached_at
|
||||
if age <= self.CACHE_TTL or (allow_stale and age <= self.STALE_ON_ERROR_TTL):
|
||||
return list(self._cached_raw)
|
||||
return None
|
||||
|
||||
def _store_snapshot(self, raw: list[tuple[str, str, str | None, str | None]]) -> None:
|
||||
with self._cache_lock:
|
||||
self._cached_raw = list(raw)
|
||||
self._cached_at = time.monotonic()
|
||||
self._warmed = True
|
||||
|
||||
def _enumerate_sync(self) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
"""Return the raw enumeration, or None when the query failed and no
|
||||
usable snapshot exists -- never an ambiguous empty list on failure."""
|
||||
cached = self._cached_snapshot(allow_stale=False)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# The gate serializes concurrent enumerations: followers block until the
|
||||
# leader finishes, then hit the snapshot it just stored.
|
||||
with self._sync_query_gate:
|
||||
cached = self._cached_snapshot(allow_stale=False)
|
||||
if cached is not None:
|
||||
return cached
|
||||
raw = self._query_adb_devices_sync()
|
||||
if raw is None:
|
||||
return self._cached_snapshot(allow_stale=True)
|
||||
self._store_snapshot(raw)
|
||||
return raw
|
||||
|
||||
async def _enumerate_async(self) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
cached = self._cached_snapshot(allow_stale=False)
|
||||
if cached is not None:
|
||||
return cached
|
||||
loop = asyncio.get_running_loop()
|
||||
inflight = self._async_inflight
|
||||
if inflight is not None and inflight[0] is loop and not inflight[1].done():
|
||||
# Shield followers so one cancelled waiter cannot abort the shared query.
|
||||
return await asyncio.shield(inflight[1])
|
||||
task = loop.create_task(self._enumerate_async_uncached())
|
||||
self._async_inflight = (loop, task)
|
||||
try:
|
||||
return await task
|
||||
finally:
|
||||
if self._async_inflight is not None and self._async_inflight[1] is task:
|
||||
self._async_inflight = None
|
||||
|
||||
async def _enumerate_async_uncached(self) -> list[tuple[str, str, str | None, str | None]] | None:
|
||||
raw = await self._query_adb_devices_async()
|
||||
if raw is None:
|
||||
return self._cached_snapshot(allow_stale=True)
|
||||
self._store_snapshot(raw)
|
||||
return raw
|
||||
|
||||
async def _start_adb_server(self, timeout: float) -> None:
|
||||
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb,
|
||||
"start-server",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except Exception as exc:
|
||||
logger.debug(f"adb start-server warm-up did not complete: {exc}")
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def warm_up_async(
|
||||
self,
|
||||
*,
|
||||
server_timeout: float = 10.0,
|
||||
settle_timeout: float = 3.0,
|
||||
poll_interval: float = 0.5,
|
||||
) -> bool:
|
||||
"""Start the adb server and complete one successful enumeration.
|
||||
|
||||
Meant to run once before an entrypoint starts accepting task
|
||||
submissions, so the first requests never race an adb cold start.
|
||||
Devices reconnect asynchronously after the server comes up, so the
|
||||
settle window keeps polling until at least one device reaches the
|
||||
ready state (or the window closes). Returns True once any enumeration
|
||||
succeeded -- zero attached devices is still a warm pool.
|
||||
"""
|
||||
if self._resolve_adb() is None:
|
||||
return False
|
||||
await self._start_adb_server(timeout=server_timeout)
|
||||
deadline = time.monotonic() + max(settle_timeout, 0.0)
|
||||
succeeded = False
|
||||
while True:
|
||||
raw = await self._query_adb_devices_async()
|
||||
if raw is not None:
|
||||
succeeded = True
|
||||
self._store_snapshot(raw)
|
||||
if any(state == "device" for _, state, _, _ in raw):
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
return succeeded
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
def _build_statuses(
|
||||
self, raw_devices: list[tuple[str, str, str | None, str | None]]
|
||||
) -> list[DeviceStatus]:
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
|
||||
devices: list[DeviceStatus] = []
|
||||
for serial, state, model, product in raw_devices:
|
||||
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
|
||||
clean_id = DeviceExecutionLock._normalize_device_id(serial)
|
||||
owner = active_owners.get(clean_id)
|
||||
clean_id = DeviceExecutionLock._normalize_lock_id(
|
||||
serial,
|
||||
os.getenv(DeviceExecutionLock.LOCK_SCOPE_ENV) or None,
|
||||
)
|
||||
owner = active_owners.get(clean_id) or active_owners.get(
|
||||
DeviceExecutionLock._normalize_device_id(serial)
|
||||
)
|
||||
|
||||
status = DeviceStatus(
|
||||
serial=serial,
|
||||
@@ -169,31 +322,73 @@ class DevicePool:
|
||||
devices.append(status)
|
||||
return devices
|
||||
|
||||
def list_devices(self) -> list[DeviceStatus]:
|
||||
"""Synchronously list all connected devices along with their active lock state.
|
||||
|
||||
The raw adb enumeration may be served from a short-lived snapshot;
|
||||
lock ownership is always computed fresh. A failed enumeration collapses
|
||||
to an empty list -- use try_list_devices when the caller must tell the
|
||||
two apart.
|
||||
"""
|
||||
return self._build_statuses(self._enumerate_sync() or [])
|
||||
|
||||
async def list_devices_async(self) -> list[DeviceStatus]:
|
||||
"""Asynchronously list all connected devices along with their active lock state."""
|
||||
raw_devices = await self._query_adb_devices_async()
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
"""Asynchronously list all connected devices along with their active lock state.
|
||||
|
||||
devices: list[DeviceStatus] = []
|
||||
for serial, state, model, product in raw_devices:
|
||||
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
|
||||
clean_id = DeviceExecutionLock._normalize_device_id(serial)
|
||||
owner = active_owners.get(clean_id)
|
||||
The raw adb enumeration may be served from a short-lived snapshot;
|
||||
lock ownership is always computed fresh. A failed enumeration collapses
|
||||
to an empty list -- use try_list_devices_async when the caller must tell
|
||||
the two apart.
|
||||
"""
|
||||
return self._build_statuses(await self._enumerate_async() or [])
|
||||
|
||||
status = DeviceStatus(
|
||||
serial=serial,
|
||||
state=state,
|
||||
model=model,
|
||||
product=product,
|
||||
is_emulator=is_emu,
|
||||
is_busy=owner is not None,
|
||||
active_pid=owner.pid if owner else None,
|
||||
active_task_desc=owner.description if owner else None,
|
||||
active_session_id=owner.session_id if owner else None,
|
||||
acquired_at=owner.acquired_at if owner else None,
|
||||
def try_list_devices(self) -> list[DeviceStatus] | None:
|
||||
"""Like list_devices, but returns None when enumeration failed and no
|
||||
usable snapshot exists, so callers can distinguish "could not ask adb"
|
||||
from "adb answered: no devices attached"."""
|
||||
raw = self._enumerate_sync()
|
||||
return None if raw is None else self._build_statuses(raw)
|
||||
|
||||
async def try_list_devices_async(self) -> list[DeviceStatus] | None:
|
||||
"""Async variant of try_list_devices."""
|
||||
raw = await self._enumerate_async()
|
||||
return None if raw is None else self._build_statuses(raw)
|
||||
|
||||
@staticmethod
|
||||
def _explicit_serial_error(
|
||||
requested_serial: str, devices: list[DeviceStatus] | None
|
||||
) -> str | None:
|
||||
# Fail open on an indeterminate or empty enumeration (adb missing, server
|
||||
# blip, devices still handshaking): the submission proceeds and fails
|
||||
# downstream with a clear no-device error instead of a false hard reject.
|
||||
if not devices:
|
||||
return None
|
||||
norm = DeviceExecutionLock._normalize_device_id
|
||||
state_by_serial = {norm(d.serial): d.state for d in devices}
|
||||
dev_state = state_by_serial.get(norm(requested_serial))
|
||||
if dev_state == "device":
|
||||
return None
|
||||
if dev_state is None:
|
||||
return (
|
||||
f"Device '{requested_serial}' is not connected. "
|
||||
f"Attached devices: {sorted(state_by_serial)}."
|
||||
)
|
||||
devices.append(status)
|
||||
return devices
|
||||
return f"Device '{requested_serial}' is attached but not ready (state: '{dev_state}')."
|
||||
|
||||
def validate_explicit_serial(self, requested_serial: str) -> str | None:
|
||||
"""Return a rejection message for an explicitly requested serial, or None.
|
||||
|
||||
Only a successful, non-empty enumeration may reject: it is the single
|
||||
authority for the strict device-binding check shared by the admission
|
||||
paths (admin console router/queue and the MCP task runner).
|
||||
"""
|
||||
return self._explicit_serial_error(requested_serial, self.try_list_devices())
|
||||
|
||||
async def validate_explicit_serial_async(self, requested_serial: str) -> str | None:
|
||||
"""Async variant of validate_explicit_serial."""
|
||||
return self._explicit_serial_error(
|
||||
requested_serial, await self.try_list_devices_async()
|
||||
)
|
||||
|
||||
def get_ready_devices(self) -> list[DeviceStatus]:
|
||||
"""Return devices in authorized 'device' state."""
|
||||
|
||||
@@ -30,6 +30,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from artemis.config.paths import ROOT_DIR, get_server_info_file
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
@@ -51,8 +53,9 @@ def is_port_in_use(port: int, host: str = "127.0.0.1", timeout: float = 0.5) ->
|
||||
|
||||
def write_server_info(
|
||||
port: int,
|
||||
host: str = "0.0.0.0",
|
||||
host: str = "127.0.0.1",
|
||||
pid: int | None = None,
|
||||
lifecycle_token: str | None = None,
|
||||
) -> Path:
|
||||
"""Persist current Artemis server runtime metadata to well-known path."""
|
||||
info_file = get_server_info_file()
|
||||
@@ -67,6 +70,8 @@ def write_server_info(
|
||||
"sys_executable": sys.executable,
|
||||
"cmdline": sys.argv,
|
||||
}
|
||||
if lifecycle_token:
|
||||
data["lifecycle_token"] = lifecycle_token
|
||||
info_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.debug(f"Saved Artemis server info (PID: {data['pid']}, port: {port}) to {info_file}")
|
||||
except Exception as e:
|
||||
@@ -91,17 +96,105 @@ def read_server_info() -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def clear_server_info() -> None:
|
||||
"""Remove the persisted server metadata file if it exists."""
|
||||
def clear_server_info(
|
||||
*,
|
||||
port: int | None = None,
|
||||
lifecycle_token: str | None = None,
|
||||
) -> None:
|
||||
"""Remove persisted metadata, optionally only for the matching server.
|
||||
|
||||
Scoped removal keeps ``artemis stop --port <unused>`` and a late shutdown
|
||||
finalizer from deleting metadata written by a different server instance.
|
||||
"""
|
||||
info_file = get_server_info_file()
|
||||
try:
|
||||
if info_file.exists():
|
||||
if port is not None or lifecycle_token is not None:
|
||||
info = read_server_info()
|
||||
if not info:
|
||||
return
|
||||
if port is not None and info.get("port") != port:
|
||||
return
|
||||
if lifecycle_token is not None and info.get("lifecycle_token") != lifecycle_token:
|
||||
return
|
||||
info_file.unlink()
|
||||
logger.debug(f"Cleared server info file at {info_file}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error clearing server info file {info_file}: {e}")
|
||||
|
||||
|
||||
def request_graceful_shutdown(port: int, timeout: float = 1.5) -> bool:
|
||||
"""Ask a locally managed Artemis server to run its shutdown lifecycle.
|
||||
|
||||
The per-process token in the server metadata prevents this control request
|
||||
from being accepted remotely or accidentally sent to an unrelated service
|
||||
using the same port. A missing/stale token simply makes the caller fall back
|
||||
to process termination.
|
||||
"""
|
||||
info = read_server_info()
|
||||
if not info or info.get("port") != port:
|
||||
return False
|
||||
token = info.get("lifecycle_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return False
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/api/system/shutdown",
|
||||
method="POST",
|
||||
headers={
|
||||
"User-Agent": "Artemis-Lifecycle-Client/1.0",
|
||||
"X-Artemis-Lifecycle-Token": token,
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status in (200, 202)
|
||||
except (OSError, urllib.error.URLError, urllib.error.HTTPError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _reconcile_orphaned_sessions() -> int:
|
||||
"""Immediately mark sessions whose worker PIDs died during forced stop."""
|
||||
try:
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
|
||||
return session_repo.reconcile_orphaned_sessions()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not reconcile orphaned sessions after server stop: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
def _any_pid_alive(pids: list[int]) -> bool:
|
||||
"""Return whether any target process is still running (zombies excluded)."""
|
||||
if not pids:
|
||||
return False
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for pid in pids:
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
|
||||
return True
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
except psutil.AccessDenied:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return any(_pid_exists_portably(pid) for pid in pids)
|
||||
|
||||
|
||||
def _pid_exists_portably(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_server_pids(port: int = 8000) -> list[int]:
|
||||
"""Find all process IDs associated with the Artemis server listening on `port`.
|
||||
|
||||
@@ -243,15 +336,15 @@ def get_server_status(port: int = 8000) -> dict[str, Any]:
|
||||
|
||||
def stop_server(
|
||||
port: int = 8000,
|
||||
timeout: float = 4.0,
|
||||
timeout: float = 12.0,
|
||||
force: bool = False,
|
||||
) -> tuple[bool, str, list[int]]:
|
||||
"""Gracefully terminate any running Artemis server on `port`.
|
||||
|
||||
Args:
|
||||
port: TCP port to inspect and terminate.
|
||||
timeout: Maximum seconds to wait for graceful process exit.
|
||||
force: If True, immediately send SIGKILL without waiting.
|
||||
timeout: Maximum seconds to wait for the server shutdown lifecycle.
|
||||
force: If True, skip the shutdown API and terminate the process tree.
|
||||
|
||||
Returns:
|
||||
tuple (success, message, affected_pids)
|
||||
@@ -259,7 +352,7 @@ def stop_server(
|
||||
pids = find_server_pids(port)
|
||||
|
||||
if not pids and not is_port_in_use(port):
|
||||
clear_server_info()
|
||||
clear_server_info(port=port)
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
return True, f"No active server detected on port {port}.", []
|
||||
|
||||
@@ -271,15 +364,46 @@ def stop_server(
|
||||
stopped_pids: list[int] = []
|
||||
current_pid = os.getpid()
|
||||
|
||||
# A normal stop is an application lifecycle request, not an OS signal.
|
||||
# This matters on Windows where taskkill /F and os.kill(..., SIGTERM) both
|
||||
# bypass FastAPI's shutdown hook. Give Uvicorn enough time to cancel task
|
||||
# workers, persist terminal session state, and release IPC/device resources.
|
||||
graceful_requested = False
|
||||
if not force:
|
||||
graceful_requested = request_graceful_shutdown(
|
||||
port,
|
||||
timeout=min(2.0, max(0.25, timeout)),
|
||||
)
|
||||
if graceful_requested:
|
||||
deadline = time.time() + max(0.25, timeout)
|
||||
while time.time() < deadline:
|
||||
if not is_port_in_use(port) and not _any_pid_alive(pids):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if not is_port_in_use(port) and not _any_pid_alive(pids):
|
||||
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
clear_server_info(port=port)
|
||||
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
|
||||
msg = f"Artemis server stopped gracefully{pid_str}. Port {port} is released."
|
||||
if cleaned_locks:
|
||||
msg += f" Cleaned up {cleaned_locks} device lock(s)."
|
||||
return True, msg, pids
|
||||
|
||||
logger.warning(
|
||||
f"Artemis server on port {port} did not exit within {timeout}s; "
|
||||
"falling back to process-tree termination."
|
||||
)
|
||||
|
||||
for pid in pids:
|
||||
if pid == current_pid:
|
||||
# Don't terminate self during stop_server call
|
||||
continue
|
||||
try:
|
||||
if force:
|
||||
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
|
||||
else:
|
||||
ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout)
|
||||
ProcessSupervisor.terminate_tree(
|
||||
pid,
|
||||
timeout_seconds=0.5 if force or graceful_requested else timeout,
|
||||
)
|
||||
stopped_pids.append(pid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to terminate process {pid}: {e}")
|
||||
@@ -304,7 +428,8 @@ def stop_server(
|
||||
|
||||
# Cleanup stale device execution locks left by terminated server
|
||||
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
clear_server_info()
|
||||
reconciled_sessions = _reconcile_orphaned_sessions()
|
||||
clear_server_info(port=port)
|
||||
|
||||
port_free = not is_port_in_use(port)
|
||||
if port_free:
|
||||
@@ -312,6 +437,8 @@ def stop_server(
|
||||
msg = f"Artemis server stopped{pid_str}. Port {port} is released."
|
||||
if cleaned_locks:
|
||||
msg += f" Cleaned up {cleaned_locks} device lock(s)."
|
||||
if reconciled_sessions:
|
||||
msg += f" Reconciled {reconciled_sessions} interrupted session(s)."
|
||||
return True, msg, stopped_pids
|
||||
else:
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user