| # 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 logging | |
| import multiprocessing | |
| import multiprocessing.spawn | |
| import stat | |
| import sys | |
| import tempfile | |
| from abc import ABC, abstractmethod | |
| from collections.abc import Iterator | |
| from contextlib import contextmanager | |
| from multiprocessing.context import SpawnContext | |
| from pathlib import Path | |
| from types import TracebackType | |
| from typing import Literal, Self | |
| from python_path import PythonPath | |
| with PythonPath("../../../../src/python_testing/matter_testing_infrastructure", relative_to=__file__): | |
| from matter.testing.concurrency.context import TerminablePopen, TerminableResource, TerminableThread | |
| log = logging.getLogger(__name__) | |
| if sys.platform == "linux": | |
| # We have a private /run as we're running in unshare, so we can place it in any place under /run. We don't want it in /tmp, as | |
| # we remount it to worker-specific scratchpad. | |
| SYNC_MANAGER_PATH = "/run/python_pool_manager.sock" | |
| else: | |
| # Other platforms will fall back to their default. | |
| SYNC_MANAGER_PATH = None | |
| class StartStopContextMixin(ABC): | |
| """ | |
| Abstract mixin that adds context-manager lifecycle semantics (`start`/`stop`) to long-running or managed resources. | |
| Behavior: | |
| - Entering the context (`with ...`) calls `start()` and returns `self`. | |
| - Exiting the context always calls `stop()`. | |
| - Exit-time logging varies by outcome: | |
| - Normal exit: debug log for stopping. | |
| - `KeyboardInterrupt`: warning log indicating user interruption. | |
| - Other exceptions: error log with traceback/details, then propagation. | |
| """ | |
| @abstractmethod | |
| def start(self) -> None: | |
| """Initialize and begin the resource/process.""" | |
| @abstractmethod | |
| def stop(self) -> None: | |
| """Tear down and release the resource/process.""" | |
| @property | |
| @abstractmethod | |
| def name(self) -> str: | |
| """Human-readable identifier used in log messages.""" | |
| def __enter__(self) -> Self: | |
| self.start() | |
| return self | |
| def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> Literal[False]: | |
| match exc: | |
| case None: | |
| log.debug("Stopping %s", self.name) | |
| case KeyboardInterrupt(): | |
| log.warning("Interrupting %s per user request", self.name) | |
| case _: | |
| log.error("Stopping %s due to an exception: %r", self.name, exc) | |
| self.stop() | |
| return False | |
| @contextmanager | |
| def mp_wrapped_spawn_context(wrapper_linux: str | None) -> Iterator[SpawnContext]: | |
| """ | |
| Create platform-specific multiprocessing context. | |
| Linux: | |
| - We need to use spawn for the pool to have separate environment variables per runner, and to be able to use a wrapper script. | |
| - We need unshare wrapper script to have an option to run the subprocess in a separate network namespace or mount per-worker | |
| /tmp (initialized per-worker). | |
| """ | |
| source_context = multiprocessing.get_context("spawn") | |
| if sys.platform != "linux" or wrapper_linux is None: | |
| yield source_context | |
| return | |
| mp_wrapper_name: Path | None = None | |
| old_executable = multiprocessing.spawn.get_executable() | |
| executable = old_executable.decode('utf-8') if isinstance(old_executable, bytes) else old_executable | |
| try: | |
| with tempfile.NamedTemporaryFile("w", encoding="utf8", delete=False) as wrapper_file: | |
| mp_wrapper_name = Path(wrapper_file.name) | |
| wrapper_file.write('#!/bin/sh\n' | |
| f'exec {wrapper_linux} "{executable}" "$@"\n') | |
| mp_wrapper_name.chmod(mp_wrapper_name.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) | |
| source_context.set_executable(str(mp_wrapper_name)) | |
| yield source_context | |
| finally: | |
| # Restore the default executable. | |
| source_context.set_executable(old_executable) | |
| if mp_wrapper_name is not None: | |
| mp_wrapper_name.unlink() | |
| __all__ = [ | |
| "mp_wrapped_spawn_context", | |
| "StartStopContextMixin", | |
| "TerminablePopen", | |
| "TerminableResource", | |
| "TerminableThread", | |
| ] |