| # Copyright (c) 2026 Project CHIP Authors |
| # |
| # 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. |
| |
| import contextlib |
| import dataclasses |
| import enum |
| import functools |
| import logging |
| import multiprocessing |
| import os |
| import signal |
| import threading |
| import traceback |
| from abc import ABC, abstractmethod |
| from collections.abc import Callable, Iterator |
| from multiprocessing.context import SpawnContext |
| from multiprocessing.managers import SyncManager, ValueProxy |
| from types import TracebackType |
| from typing import ClassVar, Concatenate, Generic, ParamSpec, Protocol, Self, TypeVar |
| |
| from chiptest.concurrency.work_queue import CancellableQueue, EndOfQueue, QueueCancelled, wait_for_mp_managed |
| from chiptest.log_config import LogConfig |
| |
| log = logging.getLogger(__name__) |
| |
| |
| class WithName(Protocol): |
| @property |
| def name(self) -> str: ... |
| |
| |
| S = TypeVar("S", bound="WithName") |
| P = ParamSpec("P") |
| R = TypeVar("R") |
| |
| |
| def with_annotated_exception(fn: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]: |
| """Decorator to enrich exceptions from thread and process methods with their names for easier debugging.""" |
| |
| @functools.wraps(fn) |
| def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R: |
| try: |
| return fn(self, *args, **kwargs) |
| except BaseException as e: |
| kind = ("thread" if isinstance(self, threading.Thread) else |
| "process" if isinstance(self, (multiprocessing.Process, WrappedProcess)) else |
| self.__class__.__name__) |
| |
| e.add_note(f"Exception in {kind} {self.name}") |
| raise |
| |
| return wrapper |
| |
| |
| @dataclasses.dataclass |
| class ProcessConfig: |
| """Process configuration template.""" |
| |
| DEFAULT_START_TIMEOUT_SEC: ClassVar[float] = 4.0 |
| DEFAULT_STOP_TIMEOUT_SEC: ClassVar[float] = 4.0 |
| DEFAULT_TERMINATION_TIMEOUT_SEC: ClassVar[float] = 2.0 |
| |
| id: int = 0 |
| name: str = "Process{id}" |
| log_config: LogConfig = dataclasses.field(default_factory=LogConfig) |
| start_timeout_sec: float = DEFAULT_START_TIMEOUT_SEC |
| stop_timeout_sec: float = DEFAULT_STOP_TIMEOUT_SEC |
| termination_timeout_sec: float = DEFAULT_TERMINATION_TIMEOUT_SEC |
| |
| def with_formatted_name(self) -> Self: |
| """Format the name using the id and process hierarchy. Return a new instance with the formatted name.""" |
| name = self.name.format(id=self.id) |
| |
| # Indicate process hierarchy in the name so that it's easier to understand the dependencies in the logs. |
| if (parent_proc_name := multiprocessing.current_process().name) != "MainProcess": |
| name = f"{parent_proc_name}/{name}" |
| |
| return dataclasses.replace(self, name=name) |
| |
| |
| class ProcessPhase(enum.IntEnum): |
| """Phase of the process lifecycle.""" |
| |
| NOT_STARTED = enum.auto() |
| """The process has not started yet.""" |
| |
| UNINITIALIZED = enum.auto() |
| """The process has started but has not finished initialization yet.""" |
| |
| READY = enum.auto() |
| """The process has finished initialization and is ready to work. Can cycle between READY and WORKING phases multiple times.""" |
| |
| WORKING = enum.auto() |
| """The process is working on a task. Optional and can be used to distinguish between idle and busy processes.""" |
| |
| CLOSED = enum.auto() |
| """The process has finished all work and is closed (exited). Mind that it could have been closed due to an exception.""" |
| |
| |
| class ProcessState: |
| """ |
| Shared process state for synchronized lifecycle coordination and error propagation between the parent and child processes. |
| |
| Can be used as a context manager to synchronize access to the state. Needed only if accessing multiple fields atomically, |
| otherwise individual fields are synchronized internally. |
| """ |
| |
| def __init__(self, mp_manager: SyncManager, config: ProcessConfig) -> None: |
| self._config = config |
| self._phase: ValueProxy[ProcessPhase] = mp_manager.Value(object, ProcessPhase.NOT_STARTED) |
| self._exception: ValueProxy[BaseException | None] = mp_manager.Value(object, None) |
| self._state_changed = mp_manager.Condition() |
| |
| @property |
| def phase(self) -> ProcessPhase: |
| return self._phase.get() |
| |
| @phase.setter |
| def phase(self, value: ProcessPhase) -> None: |
| with self._state_changed: |
| self._phase.set(value) |
| self._state_changed.notify_all() |
| |
| @property |
| def exception(self) -> BaseException | None: |
| return self._exception.get() |
| |
| @exception.setter |
| def exception(self, value: BaseException | None) -> None: |
| with self._state_changed: |
| if isinstance(value, BaseException): |
| value.add_note(f"Exception in process {self._config.name}") |
| |
| # Traceback is not automatically propagated across process boundaries, so we need to add it to the exception |
| # manually as a note. |
| value.add_note("".join(traceback.format_exception(value))) |
| self._exception.set(value) |
| self._state_changed.notify_all() |
| |
| def __enter__(self) -> bool: |
| return self._state_changed.__enter__() |
| |
| def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None): |
| return self._state_changed.__exit__(exc_type, exc_val, exc_tb) |
| |
| def wait_for(self, predicate: Callable[[ProcessPhase, BaseException | None], bool], timeout: float | None = None): |
| """ |
| Wait until the predicate returns True for the current phase and/or exception. |
| |
| Timeout as for `Condition.wait_for()`, i.e. it can be a positive float for maximum wait time, or None to wait indefinitely. |
| |
| Returns the result of the predicate. |
| |
| We need the wait_for_mp_managed wrapper because otherwise we wouldn't be able to catch a KeyboardInterrupt for the condition |
| which is managed by multiprocessing.Manager, as the manager process explicitly ignores SIGINT. |
| """ |
| with self._state_changed: |
| return wait_for_mp_managed(self._state_changed, lambda: predicate(self._phase.get(), self._exception.get()), timeout) |
| |
| @contextlib.contextmanager |
| def working_context(self) -> Iterator[None]: |
| """Context manager to set the process phase to WORKING for the duration of the context.""" |
| self.phase = ProcessPhase.WORKING |
| try: |
| yield |
| finally: |
| self.phase = ProcessPhase.READY |
| |
| |
| WorkerConfigT = TypeVar("WorkerConfigT", bound=ProcessConfig) |
| WorkRequestT = TypeVar("WorkRequestT") |
| WorkResponseT = TypeVar("WorkResponseT") |
| |
| |
| class WrappedProcess(ABC, Generic[WorkerConfigT, WorkRequestT, WorkResponseT]): |
| """ |
| Base class for wrapped Python subprocesses. |
| |
| WrappedProcess owns a `multiprocessing.Process` and coordinates its lifecycle through shared `ProcessState` and work/response |
| queues. |
| |
| Intended wrapping flow: |
| 1. The caller enters `mp_wrapped_spawn_context()` from `concurrent/context.py`. |
| 2. The context always uses the spawn start method, and on Linux can optionally install a temporary wrapper executable that |
| launches Python through `wrapper_linux`. |
| 3. That `SpawnContext` is passed to WrappedProcess and used to create the child process. |
| 4. Parent-side lifecycle is driven by `start()` / `stop()`. |
| 5. Child-side lifecycle is driven by `run()` and the `_proc_*` hook methods. |
| 6. On context exit, `mp_wrapped_spawn_context()` restores the original multiprocessing executable and removes the wrapper. |
| |
| The lifecycle of the subprocess is coordinated through `ProcessState` and follows these phases: |
| 1. Parent calls `start()`. |
| 2. Subprocess enters `run()` and sets phase `UNINITIALIZED`. |
| 3. `_proc_init()` is invoked once for subclass-specific initialization. All |
| resources that require cleanup should be registered in the provided `ExitStack`. |
| 4. On success, phase becomes `READY`. |
| 5. `_proc_work()` executes the main loop (by default waits for cancellation). Implementations may optionally toggle between |
| `READY` and `WORKING` to expose idle vs active periods. You can use working_context() for that. |
| 6. After finishing work, the resources initialized in `_proc_init()` are cleaned up by the exit stack. |
| 7. Phase becomes `CLOSED` when the subprocess exits. |
| |
| Error handling behavior: |
| - Unhandled exceptions from subprocess code are stored in `state.exception`. |
| - `start()` waits for initialization and fails if the process reports an exception, closes early, or times out. |
| - `stop()` attempts graceful shutdown first, then escalates with SIGINT, SIGTERM, and SIGKILL with configured timeouts. |
| |
| Subclasses are expected to implement `_proc_init()`, and may optionally override `_proc_work()`. |
| """ |
| # Methods run in the parent process. |
| |
| def __init__(self, mp_context: SpawnContext, mp_manager: SyncManager, config: WorkerConfigT, |
| work_queue: CancellableQueue[WorkRequestT], rsp_queue: CancellableQueue[WorkResponseT]) -> None: |
| # Neither mp_context or mp_manager should be saved as fields, as they are not picklable between processes. They can be used |
| # to initialize some shared resources in the constructor. |
| |
| self._config = config |
| self._work_queue = work_queue |
| self._rsp_queue = rsp_queue |
| self.state = ProcessState(mp_manager, config) |
| |
| # Create multiprocessing.Process in the given context. |
| self._proc = mp_context.Process(target=self.run, name=self._config.name) |
| self._stopped = False |
| |
| def __getstate__(self) -> dict[str, object]: |
| state = self.__dict__.copy() |
| # Drop _proc field to exclude it from pickling on process spawn. |
| state.pop("_proc", None) |
| return state |
| |
| @property |
| def name(self) -> str: |
| return self._config.name |
| |
| @with_annotated_exception |
| def start(self) -> None: |
| """ |
| Start the subprocess and wait until initialization completes. |
| |
| After triggering the process start, wait until phase leaves `NOT_STARTED`/`UNINITIALIZED` and then validate final startup |
| state: `READY` is success, and `CLOSED` or any recorded exception is failure. |
| """ |
| if self._stopped: |
| raise RuntimeError("Cannot start a process that has been already stopped") |
| |
| log.debug("Starting process %s", self.name) |
| self._proc.start() |
| |
| try: |
| if not self.state.wait_for(lambda phase, _: phase not in (ProcessPhase.NOT_STARTED, ProcessPhase.UNINITIALIZED), |
| self._config.start_timeout_sec): |
| raise TimeoutError("Timeout when waiting for initialization") |
| |
| with self.state: |
| # Propagate KeyboardInterrupt as is. |
| if isinstance(self.state.exception, KeyboardInterrupt): |
| raise self.state.exception |
| |
| if self.state.exception is not None: |
| raise RuntimeError("Process failed initialization") from self.state.exception |
| |
| match self.state.phase: |
| case ProcessPhase.READY: |
| log.debug("Process %s started successfully", self.name) |
| case ProcessPhase.CLOSED: |
| raise RuntimeError("Process closed immediately after initialization") |
| case _: |
| raise RuntimeError(f"Process is in an unexpected state: {self.state.phase!r}") |
| except BaseException as start_exc: |
| if not isinstance(start_exc, KeyboardInterrupt): |
| log.error("Stopping process %s on failure during initialization", self.name) |
| try: |
| self.stop() |
| except KeyboardInterrupt: |
| raise |
| except BaseException as stop_exc: |
| log.error("Error when stopping process %s after failure during initialization: %r", self.name, stop_exc) |
| raise stop_exc.with_traceback(stop_exc.__traceback__) from start_exc |
| raise |
| |
| def has_stopped(self, timeout: float) -> bool: |
| self._proc.join(timeout) |
| if not self._proc.is_alive(): |
| log.debug("Process %s got stopped", self.name) |
| return True |
| return False |
| |
| @with_annotated_exception |
| def stop(self) -> None: |
| """ |
| Stop the subprocess with escalating termination signals. |
| |
| The method waits for a graceful exit first (typically triggered when the work queue is cancelled by its owner). If still |
| alive, it escalates to SIGINT, then SIGTERM, then SIGKILL. |
| """ |
| if self._stopped: |
| log.debug("Process %s is already stopped", self.name) |
| return |
| |
| try: |
| # First check if the process is alive before trying to join, as it is an error to attempt to join a process before it |
| # has been started. |
| if not self._proc.is_alive(): |
| log.debug("Process %s hasn't been started yet or has been already stopped", self.name) |
| return |
| |
| # Wait for the external work queue to be cancelled by its owner, which should signal the process to gracefully stop. |
| if self.has_stopped(self._config.stop_timeout_sec): |
| return |
| |
| log.debug("Sending interrupt signal to process %s", self.name) |
| if self._proc.pid is not None: |
| os.kill(self._proc.pid, signal.SIGINT) # TODO Python 3.14: self._proc.interrupt() |
| if self.has_stopped(self._config.stop_timeout_sec): |
| return |
| |
| log.warning("Timeout when waiting for process %s to stop. Sending terminate signal", self.name) |
| self._proc.terminate() |
| if self.has_stopped(self._config.termination_timeout_sec): |
| return |
| |
| log.warning("Timeout when waiting for process %s to terminate. Sending kill signal", self.name) |
| self._proc.kill() |
| if self.has_stopped(self._config.termination_timeout_sec): |
| return |
| |
| raise TimeoutError(f"Failed to terminate the process {self.name}. May become a zombie") |
| finally: |
| self._stopped = True |
| if isinstance(self.state.exception, KeyboardInterrupt): |
| raise self.state.exception |
| if self.state.exception is not None: |
| raise RuntimeError("Process reported an exception during execution") from self.state.exception |
| |
| # Methods run in the subprocess. |
| |
| def run(self) -> None: |
| """ |
| Subprocess entrypoint coordinating init/work/cleanup and state. |
| |
| This method drives the lifecycle hooks in order: `_proc_init()` -> `_proc_work()`. It updates `state.phase` and captures |
| unexpected exceptions in `state.exception`. |
| |
| We use several nested try/except blocks: |
| 1. The outermost block captures any exception that passed through other filters, ensures that it is stored in state and |
| ensures that the process phase is set to CLOSED at the end. |
| 2. The middle block filters out expected exceptions related to normal process shutdown (like `QueueCancelled`), so that they |
| are not treated as errors in scope of the exit stack. |
| 3. The innermost blocks around `_proc_init()` and `_proc_work()` enrich the exceptions with additional context about the |
| failure stage. |
| """ |
| try: |
| # Signal that the process has started initialization. |
| self.state.phase = ProcessPhase.UNINITIALIZED |
| |
| # Initialize global logger in the subprocess. |
| self._config.log_config.set_fmt() |
| |
| with contextlib.ExitStack() as stack: |
| try: |
| try: |
| log.debug("Initializing") |
| self._proc_init(stack) |
| self.state.phase = ProcessPhase.READY |
| log.debug("Initialized successfully") |
| except BaseException as e: |
| e.add_note("Failure during process initialization") |
| raise |
| |
| try: |
| self._proc_work() |
| except BaseException as e: |
| e.add_note("Failure during process work") |
| raise |
| |
| # Capture exceptions that are not errors. |
| except QueueCancelled: |
| log.warning("Received a cancel event") |
| except EndOfQueue: |
| log.debug("Received end of work signal") |
| except BaseException as e: |
| if isinstance(e, KeyboardInterrupt): |
| log.debug("Process interrupted by user") |
| else: |
| log.error("Process failed with an exception: %r", e) |
| self.state.exception = e |
| finally: |
| self.state.phase = ProcessPhase.CLOSED |
| |
| @abstractmethod |
| def _proc_init(self, exit_stack: contextlib.ExitStack) -> None: |
| """Initialize subprocess resources before work begins. |
| |
| The long-lived resources that need cleanup should be registered in the provided `ExitStack`. |
| """ |
| |
| def _proc_work(self) -> None: |
| """ |
| Perform the work. |
| |
| Default behavior waits for cancellation. Override to consume `self._work_queue` and publish activity by toggling |
| `state.phase` between `READY` and `WORKING` as appropriate. |
| """ |
| self._work_queue.wait_for_cancelled() |