merge(runtime): integrate process and task state fixes

This commit is contained in:
somew1nd committed 2026-09-01 11:39:56 -07:00
commit e9b821c0ec
14 files changed
+918 -197

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ from artemis.runtime.adb_endpoint import (
current_adb_endpoint,
)
from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.daemon_client import (
ensure_daemon_running,
get_daemon_session,
@@ -78,6 +79,7 @@ __all__ = [
"get_server_status",
"is_daemon_running",
"is_port_in_use",
"pid_is_alive",
"ProcessSupervisor",
"process_supervisor",
"read_server_info",
+4 -8
View File
@@ -28,6 +28,7 @@ import time
import uuid
from artemis.config.paths import get_temp_dir
from artemis.runtime.process_probe import pid_is_alive
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -77,16 +78,11 @@ class ScreenAwakeLease:
@staticmethod
def _owner_is_alive(payload: dict) -> bool:
try:
import psutil
pid = int(payload["pid"])
process = psutil.Process(pid)
if not process.is_running():
return False
created_at = float(payload.get("process_created_at", 0.0))
return created_at <= 0 or abs(process.create_time() - created_at) < 1.0
except Exception:
created_at = float(payload.get("process_created_at", 0.0) or 0.0)
except (KeyError, TypeError, ValueError):
return False
return pid_is_alive(pid, created_at if created_at > 0 else None)
@staticmethod
def _read_payload(path: Path) -> dict | None:
+131 -44
View File
@@ -27,6 +27,7 @@ from typing import Any
import uuid
from artemis.config.paths import get_temp_dir
from artemis.runtime.process_probe import pid_is_alive
class DeviceBusyError(RuntimeError):
@@ -202,22 +203,22 @@ class DeviceExecutionLock:
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
if owner.pid <= 0:
return False
try:
import psutil
if owner.process_created_at <= 0:
# Legacy owner payloads without a create time cannot use PID-reuse
# protection; require an Artemis-looking process before trusting
# the record (a recycled PID must not keep a lock alive forever).
try:
import psutil
process = psutil.Process(owner.pid)
if not process.is_running():
return False
if owner.process_created_at <= 0:
try:
name = process.name().lower()
cmdline = " ".join(process.cmdline()).lower()
return any(k in name or k in cmdline for k in ("python", "artemis", "pytest"))
except Exception:
process = psutil.Process(owner.pid)
if not process.is_running():
return False
return abs(process.create_time() - owner.process_created_at) < 1.0
except Exception:
return False
name = process.name().lower()
cmdline = " ".join(process.cmdline()).lower()
return any(k in name or k in cmdline for k in ("python", "artemis", "pytest"))
except Exception:
return False
return pid_is_alive(owner.pid, owner.process_created_at)
@staticmethod
def _read_owner(path: Path) -> DeviceLockOwner | None:
@@ -268,6 +269,108 @@ class DeviceExecutionLock:
payload["lock_scope"] = str(lock_scope)
return payload
@classmethod
def _ticket_targets_device(
cls,
ticket_owner: DeviceLockOwner,
*,
target_lock_id: str,
target_scope: str | None,
include_pending: bool,
) -> bool:
"""Return whether a queue ticket belongs to the given device's queue.
A concrete ticket matches only its own device. A ticket whose device id
is still ``pending``/``any`` is a submission placeholder that no worker
has claimed yet; with ``include_pending=True`` it matches any device
with a compatible lock scope (used by listings, and by
``_build_device_queue`` which then narrows the match down to a single
parked device), while ``include_pending=False`` excludes it.
"""
ticket_dev = cls._normalize_device_id(ticket_owner.device_id)
ticket_lock_id = cls._normalize_lock_id(
ticket_owner.device_id,
ticket_owner.lock_scope,
)
if ticket_lock_id == target_lock_id:
return True
if not include_pending:
return False
same_scope = (
not ticket_owner.lock_scope
or not target_scope
or ticket_owner.lock_scope == target_scope
)
return ticket_dev in ("pending", "any") and same_scope
@classmethod
def _build_device_queue(
cls,
wait_files: list[Path],
*,
target_lock_id: str,
target_scope: str | None,
) -> list[Path]:
"""Return the FIFO wait-ticket queue that gates ``target_lock_id``.
Concrete tickets belong to exactly their own device's queue. An
unclaimed ``pending``/``any`` ticket is *parked* on at most one device:
pending tickets, taken in FIFO order, occupy the idle scope-compatible
candidate devices in sorted lock-id order. Historically such a ticket
was counted into every device's queue, so a single unclaimed
head-of-queue ticket blocked all devices at once. Parking preserves
submission order on the one device the ticket is presumed to claim
while leaving every other idle device schedulable.
The pending submission itself cannot starve: its ticket file keeps its
original timestamp, so once a worker claims a concrete device the
ticket sorts ahead of every younger ticket for that device.
"""
entries: list[tuple[Path, str | None]] = []
known_lock_ids: set[str] = set()
for wait_path in wait_files:
owner = cls._read_owner(wait_path)
if owner is None:
continue
if cls._normalize_device_id(owner.device_id) in ("pending", "any"):
if cls._ticket_targets_device(
owner,
target_lock_id=target_lock_id,
target_scope=target_scope,
include_pending=True,
):
entries.append((wait_path, None))
continue
same_scope = (
not owner.lock_scope
or not target_scope
or owner.lock_scope == target_scope
)
if not same_scope:
continue
lock_id = cls._normalize_lock_id(owner.device_id, owner.lock_scope)
known_lock_ids.add(lock_id)
entries.append((wait_path, lock_id))
lock_dir = get_temp_dir("device-locks")
idle_candidates = sorted(
lock_id
for lock_id in known_lock_ids
if not (lock_dir / f"artemis-device-{lock_id}.lock").exists()
)
parked: dict[Path, str] = {}
next_candidate = 0
for wait_path, lock_id in entries:
if lock_id is None and next_candidate < len(idle_candidates):
parked[wait_path] = idle_candidates[next_candidate]
next_candidate += 1
return [
wait_path
for wait_path, lock_id in entries
if lock_id == target_lock_id or parked.get(wait_path) == target_lock_id
]
@classmethod
def reserve(
cls,
@@ -547,27 +650,15 @@ class DeviceExecutionLock:
)
is_eligible = is_reentrant or bool(all_wait_files and all_wait_files[0] == self._queue_path)
else:
# In multi-device parallel mode, per-device FIFO applies
target_dev = self.clean_device_id
device_queue: list[Path] = []
for wait_path in all_wait_files:
ticket_owner = self._read_owner(wait_path)
if ticket_owner is None:
continue
ticket_dev = self._normalize_device_id(ticket_owner.device_id)
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)
# In multi-device parallel mode, per-device FIFO applies.
# Unclaimed pending/any tickets are parked on at most one
# idle device instead of gating the head of every device's
# queue (see _build_device_queue).
device_queue = self._build_device_queue(
all_wait_files,
target_lock_id=self.clean_device_id,
target_scope=self.lock_scope,
)
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
if is_eligible and self._try_acquire_owner_lock():
@@ -729,15 +820,11 @@ class DeviceExecutionLock:
if owner.token in active_tokens:
continue
target_dev = cls._normalize_device_id(owner.device_id)
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
if clean_target is not None and not cls._ticket_targets_device(
owner,
target_lock_id=clean_target,
target_scope=target_scope,
include_pending=True,
):
continue
+98
View File
@@ -0,0 +1,98 @@
# 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.
"""Single source of truth for PID liveness probing across the Artemis codebase.
Semantics (decided by the maintainers):
- psutil only. No bare ``os.kill(pid, 0)`` fallback: on Windows, CPython
implements ``os.kill(pid, 0)`` for non-CTRL signals via
``OpenProcess``/``TerminateProcess`` handle probing, which is both unreliable
for dead pids and semantically surprising.
- ``psutil.NoSuchProcess`` -> dead. Zombie processes -> dead.
- ``psutil.AccessDenied`` / ``PermissionError`` -> **alive**. When liveness
cannot be determined the probe defaults to "alive": it is always safer to
skip reaping a resource than to reap one that is still owned.
- ``created_at`` (a ``psutil``-style process create time) enables PID-reuse
protection: when provided and the probed process's create time differs by
one second or more, the original process is dead and its PID was recycled.
- Any other unexpected error -> alive (default-alive), with a warning log.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def pid_is_alive(pid: int, created_at: float | None = None) -> bool:
"""Return whether the process identified by ``pid`` is still alive.
Args:
pid: The process id to probe. Non-positive or unparsable pids are dead.
created_at: Optional expected process create time (as reported by
``psutil.Process.create_time()``). When given and > 0, a create-time
mismatch of >= 1 second means the PID was recycled -> dead.
Returns:
True when the process is alive or liveness cannot be determined
(default-alive); False when the process is definitively gone, a
zombie, or its PID has been recycled by another process.
"""
try:
pid = int(pid)
except (TypeError, ValueError):
return False
if pid <= 0:
return False
try:
import psutil
except Exception as exc: # pragma: no cover - psutil is a hard dependency
logger.warning(f"psutil unavailable while probing pid {pid}: {exc}; assuming alive")
return True
try:
process = psutil.Process(pid)
if not process.is_running():
return False
try:
if process.status() == psutil.STATUS_ZOMBIE:
return False
except psutil.NoSuchProcess:
return False
except (psutil.AccessDenied, PermissionError):
# Cannot inspect the status; keep the default-alive verdict.
pass
if created_at is not None and created_at > 0:
try:
if abs(process.create_time() - float(created_at)) >= 1.0:
# Same PID, different birth time: the PID was recycled.
return False
except psutil.NoSuchProcess:
return False
except (psutil.AccessDenied, PermissionError):
pass
return True
except psutil.NoSuchProcess:
return False
except (psutil.AccessDenied, PermissionError):
return True
except Exception as exc:
logger.warning(f"Could not determine liveness of pid {pid}: {exc}; assuming alive")
return True
+5 -34
View File
@@ -168,31 +168,9 @@ def _reconcile_orphaned_sessions() -> int:
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
from artemis.runtime.process_probe import pid_is_alive
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
return any(pid_is_alive(pid) for pid in pids)
def find_server_pids(port: int = 8000) -> list[int]:
@@ -210,17 +188,10 @@ def find_server_pids(port: int = 8000) -> list[int]:
if info and info.get("port") == port:
saved_pid = info.get("pid")
if saved_pid and isinstance(saved_pid, int):
try:
import psutil
from artemis.runtime.process_probe import pid_is_alive
if psutil.pid_exists(saved_pid):
discovered.add(saved_pid)
except Exception:
try:
os.kill(saved_pid, 0)
discovered.add(saved_pid)
except OSError:
pass
if pid_is_alive(saved_pid):
discovered.add(saved_pid)
# 2. Check port listeners with platform-native tools
# 2a. lsof (macOS & Linux)