Source code for mesofield.devices.subprocesses.base

"""Generic external-app subprocess supervisor (Qt-free, stdlib only).

Launches an arbitrary command, streams its stdout, fires ``on_ready`` when a
handshake token appears, and ``on_finished(exit_code)`` when the process exits.
``terminate()`` does a graceful ``terminate`` then a ``kill`` fallback.

**Whole-tree teardown.** Stimulus apps spawn their own helper processes
(PsychoPy launches a separate *iohub* process that binds a UDP port; Panda3D may
fork workers). Killing only the launched parent orphans those grandchildren,
which then linger and hold their sockets -- so the next launch fails with "only
one usage of each socket address". To prevent that, the child is launched into a
kill-on-close container and the *whole tree* is reaped:

* Windows -- a **Job Object** with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE``. Every
  descendant joins the job; ``TerminateJobObject`` (or simply closing the last
  handle, e.g. when mesofield itself exits) kills the entire tree, even after the
  parent has already died on its own (a crash).
* POSIX -- a new **session/process group** (``start_new_session``); the group is
  signalled with ``killpg`` so descendants die with the leader.

The tree is also reaped the instant the parent exits (see :meth:`_read_stdout`),
so a stimulus that crashes on startup cannot leave a hung helper behind.

This is the reusable engine behind :class:`mesofield.devices.stimulus_base
.SubprocessStimulusDevice` -- it is deliberately framework-agnostic (no Qt, no
mesofield config) so it can babysit any external stimulus app (MousePortal,
PsychoPy, ...).  See ``mesofield/devices/mouseportal_device.py`` for a concrete
subclass-driven user.
"""

from __future__ import annotations

import os
import signal
import subprocess
import threading
from collections import deque
from typing import Callable, Mapping, Optional, Sequence

from mesofield.utils._logger import get_logger


class _WindowsJob:
    """A Windows Job Object that kills its whole process tree on close.

    Wraps the child in a job created with ``KILL_ON_JOB_CLOSE`` so that
    terminating the job (or merely dropping the last handle to it -- including
    when our own process exits) tears down every descendant the child spawned.
    All ``ctypes`` prototypes declare ``HANDLE`` argument types so 64-bit handles
    are not truncated on Win64. Construction is best-effort: any failure leaves
    :attr:`ok` False and the supervisor falls back to a plain process kill.

    Note: the parent is assigned to the job just after creation, so a descendant
    spawned in the microseconds before assignment can escape the job. Stimulus
    helpers we care about (PsychoPy's iohub) launch seconds later and are caught;
    the parent-exit reap (see :meth:`SubprocessSupervisor._read_stdout`) closes
    the rest of the gap. POSIX has no such race -- descendants inherit the
    process group at fork.
    """

    # JobObjectExtendedLimitInformation / JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
    _EXTENDED_LIMIT_INFO_CLASS = 9
    _KILL_ON_JOB_CLOSE = 0x00002000

    def __init__(self, proc: subprocess.Popen) -> None:
        self.ok = False
        self._handle = None
        self._k32 = None
        try:
            import ctypes
            from ctypes import wintypes

            class _BASIC_LIMIT(ctypes.Structure):
                _fields_ = [
                    ("PerProcessUserTimeLimit", ctypes.c_int64),
                    ("PerJobUserTimeLimit", ctypes.c_int64),
                    ("LimitFlags", wintypes.DWORD),
                    ("MinimumWorkingSetSize", ctypes.c_size_t),
                    ("MaximumWorkingSetSize", ctypes.c_size_t),
                    ("ActiveProcessLimit", wintypes.DWORD),
                    ("Affinity", ctypes.c_size_t),
                    ("PriorityClass", wintypes.DWORD),
                    ("SchedulingClass", wintypes.DWORD),
                ]

            class _IO_COUNTERS(ctypes.Structure):
                _fields_ = [
                    ("ReadOperationCount", ctypes.c_uint64),
                    ("WriteOperationCount", ctypes.c_uint64),
                    ("OtherOperationCount", ctypes.c_uint64),
                    ("ReadTransferCount", ctypes.c_uint64),
                    ("WriteTransferCount", ctypes.c_uint64),
                    ("OtherTransferCount", ctypes.c_uint64),
                ]

            class _EXTENDED_LIMIT(ctypes.Structure):
                _fields_ = [
                    ("BasicLimitInformation", _BASIC_LIMIT),
                    ("IoInfo", _IO_COUNTERS),
                    ("ProcessMemoryLimit", ctypes.c_size_t),
                    ("JobMemoryLimit", ctypes.c_size_t),
                    ("PeakProcessMemoryUsed", ctypes.c_size_t),
                    ("PeakJobMemoryUsed", ctypes.c_size_t),
                ]

            k32 = ctypes.WinDLL("kernel32", use_last_error=True)
            k32.CreateJobObjectW.restype = wintypes.HANDLE
            k32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
            k32.SetInformationJobObject.restype = wintypes.BOOL
            k32.SetInformationJobObject.argtypes = [
                wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD,
            ]
            k32.AssignProcessToJobObject.restype = wintypes.BOOL
            k32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
            k32.TerminateJobObject.restype = wintypes.BOOL
            k32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
            k32.CloseHandle.restype = wintypes.BOOL
            k32.CloseHandle.argtypes = [wintypes.HANDLE]

            handle = k32.CreateJobObjectW(None, None)
            if not handle:
                return
            info = _EXTENDED_LIMIT()
            info.BasicLimitInformation.LimitFlags = self._KILL_ON_JOB_CLOSE
            if not k32.SetInformationJobObject(
                handle, self._EXTENDED_LIMIT_INFO_CLASS,
                ctypes.byref(info), ctypes.sizeof(info),
            ):
                k32.CloseHandle(handle)
                return
            if not k32.AssignProcessToJobObject(handle, int(proc._handle)):  # type: ignore[attr-defined]
                k32.CloseHandle(handle)
                return
            self._k32 = k32
            self._handle = handle
            self.ok = True
        except Exception:
            self._handle = None
            self.ok = False

    def terminate_and_close(self) -> None:
        """Kill every process in the job and release the handle. Idempotent."""
        handle, self._handle = self._handle, None
        if handle is None or self._k32 is None:
            return
        try:
            self._k32.TerminateJobObject(handle, 1)
        finally:
            self._k32.CloseHandle(handle)


[docs] class SubprocessSupervisor: """Launch and supervise an external subprocess with a stdout handshake. Parameters ---------- command: Full argv to launch (e.g. ``[python_exe, "-m", "mouseportal", ...]``). ready_token: Substring printed by the child on stdout once it is ready. When seen, ``on_ready`` fires and :meth:`wait_ready` unblocks. cwd: Working directory for the child (so its relative asset paths resolve). env: Optional environment mapping; ``None`` inherits the parent's. on_ready / on_finished: Callbacks fired (from the reader thread) on the readiness handshake and on process exit, respectively. ``on_finished`` receives the exit code. name: Short label used in log lines and the reader thread name. """
[docs] def __init__( self, command: Sequence[str], *, ready_token: str, cwd: Optional[str] = None, env: Optional[Mapping[str, str]] = None, on_ready: Optional[Callable[[], None]] = None, on_finished: Optional[Callable[[int], None]] = None, name: str = "subprocess", ) -> None: self.command = list(command) self.ready_token = ready_token self.cwd = cwd self.env = dict(env) if env is not None else None self._on_ready = on_ready self._on_finished = on_finished self.name = name self.logger = get_logger(f"{__name__}.SubprocessSupervisor[{name}]") self._proc: Optional[subprocess.Popen] = None self._reader: Optional[threading.Thread] = None self._ready = threading.Event() # Whole-tree teardown handles (see module docstring). On POSIX the child # is its own session leader, so its pgid == pid; on Windows it is wrapped # in a kill-on-close Job Object. self._pgid: Optional[int] = None self._win_job: Optional[_WindowsJob] = None # Guards _reap_tree against the terminate() caller and the reader thread # racing to tear the tree down at once. self._reap_lock = threading.Lock() # Rolling tail of the child's merged stdout/stderr, so a failure # handler can show why it died before the readiness handshake. self._tail: deque[str] = deque(maxlen=200)
# -- lifecycle ------------------------------------------------------
[docs] def start(self) -> None: """Launch the subprocess (in its own group/job) and stream its stdout.""" self.logger.debug(f"Launching: {' '.join(self.command)} (cwd={self.cwd})") kwargs: dict = dict( cwd=self.cwd, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) # New session/group so killpg reaps the whole tree on POSIX. if os.name == "posix": kwargs["start_new_session"] = True self._proc = subprocess.Popen(self.command, **kwargs) self.logger.info(f"{self.name} launched (pid {self._proc.pid}).") if os.name == "posix": # start_new_session makes the child the leader; pgid == pid, and it # stays valid for killpg while any group member is alive. self._pgid = self._proc.pid elif os.name == "nt": job = _WindowsJob(self._proc) if job.ok: self._win_job = job else: self.logger.warning( f"{self.name}: could not create a kill-on-close Job Object; " f"falling back to a single-process kill (helper processes the " f"app spawns may survive)." ) self._reader = threading.Thread( target=self._read_stdout, name=f"{self.name}-stdout", daemon=True ) self._reader.start()
[docs] def wait_ready(self, timeout: Optional[float] = None) -> bool: """Block until the readiness handshake fires (or ``timeout``).""" return self._ready.wait(timeout=timeout)
def is_running(self) -> bool: return self._proc is not None and self._proc.poll() is None @property def pid(self) -> Optional[int]: """PID of the launched parent process (``None`` before launch).""" return self._proc.pid if self._proc is not None else None @property def output_tail(self) -> str: """The child's last ~4000 chars of merged stdout/stderr.""" return "\n".join(self._tail)[-4000:]
[docs] def terminate(self, timeout: float = 5.0) -> None: """Stop the subprocess and its whole tree. Graceful ``terminate`` on the parent first, ``kill`` as a fallback, then an unconditional tree reap (Job Object / process group) so descendants the app spawned -- e.g. PsychoPy's iohub helper -- never linger, even when the parent has already exited on its own. """ proc = self._proc try: if proc is not None and proc.poll() is None: if os.name == "posix": self._signal_group(signal.SIGTERM) else: proc.terminate() try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: self.logger.warning(f"{self.name} did not exit; killing.") if os.name == "posix": self._signal_group(signal.SIGKILL) else: proc.kill() try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: self.logger.error(f"{self.name} could not be killed.") finally: self._reap_tree()
# -- internals ------------------------------------------------------ def _signal_group(self, sig: int) -> None: """Send *sig* to the child's POSIX process group (best effort).""" if self._pgid is None: return try: os.killpg(self._pgid, sig) except (ProcessLookupError, PermissionError, OSError): pass def _reap_tree(self) -> None: """Kill any surviving descendants (orphaned helpers). Idempotent.""" with self._reap_lock: if os.name == "posix": self._signal_group(signal.SIGKILL) elif self._win_job is not None: self._win_job.terminate_and_close() self._win_job = None def _read_stdout(self) -> None: assert self._proc is not None and self._proc.stdout is not None try: for line in self._proc.stdout: line = line.rstrip("\n") self.logger.debug(f"[{self.name}] {line}") self._tail.append(line) if self.ready_token in line and not self._ready.is_set(): self._ready.set() if self._on_ready is not None: try: self._on_ready() except Exception as exc: self.logger.warning(f"on_ready callback failed: {exc}") finally: code = self._proc.wait() self.logger.info(f"{self.name} exited with code {code}") if self._on_finished is not None: try: self._on_finished(code) except Exception as exc: self.logger.warning(f"on_finished callback failed: {exc}") # The parent has exited (cleanly or by crashing). Reap the tree now # so a helper it spawned (PsychoPy's iohub, holding a UDP port) # cannot survive to block the next launch. self._reap_tree()