blob: 3753a5180c2b9259e3229610c5b205d26f933f27 [file]
#
# Copyright (c) 2021 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.
from __future__ import annotations
import contextlib
import dataclasses
import filecmp
import functools
import logging
import multiprocessing
import subprocess
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, Self, TypeAlias, TypeVar
from xmlrpc.server import SimpleXMLRPCServer
from chiptest.concurrency.context import StartStopContextMixin, TerminableResource, mp_wrapped_spawn_context
from chiptest.concurrency.process import ProcessConfig, WrappedProcess, with_annotated_exception
from chiptest.concurrency.work_queue import CancellableQueue, QueueCancelled
from chiptest.log_config import LogConfig
if TYPE_CHECKING:
from .test_definition import App
log = logging.getLogger(__name__)
_DEFAULT_CHIP_ROOT = Path(__file__).parent.parent.parent.parent.absolute()
PORT = 9000
if sys.platform == 'linux':
IP = '10.10.10.5'
else:
IP = '127.0.0.1'
@dataclasses.dataclass(frozen=True)
class XmlRpcFuncCall:
"""Represents a call to an XML-RPC function, with the function name and arguments."""
name: str
args: tuple[Any, ...]
XmlRpcFuncRet: TypeAlias = bool | Exception
class XmlRpcServerProcess(WrappedProcess[ProcessConfig, XmlRpcFuncCall, XmlRpcFuncRet], StartStopContextMixin):
"""
Wrapped subprocess that hosts the XML-RPC endpoint for `AppsRegister`.
This class provides the XML-RPC-facing side of the accessory control flow:
- `_proc_init()` creates `SimpleXMLRPCServer`, registers RPC methods from `APPS_RPC_FUNCS` (snake_case converted to camelCase),
and runs the server loop in a dedicated thread.
- Incoming RPC calls are converted into `XmlRpcFuncCall` requests and pushed into `self._work_queue`.
- The manager thread executes the requested `AppsRegister` method and sends either a `bool` result or an `Exception` through
`self._rsp_queue`.
- `_call()` reads that response and re-raises exceptions so XML-RPC receives the original failure.
"""
def _call(self, name: str, *args: Any) -> bool:
log.debug("Call: %s%r", name, args)
self._work_queue.put(XmlRpcFuncCall(name, args))
# Get the result from the response queue. If there was an exception, propagate it to the RPC server process, so that it can
# be handled by it, and in turn propagated back to the caller of the RPC function.
rsp = self._rsp_queue.get()
log.debug("Response for %s: %r", name, rsp)
if isinstance(rsp, Exception):
raise rsp
return rsp
def _create_func(self, name: str):
return lambda *args: self._call(name, *args)
@staticmethod
def _to_camel_case(name: str):
s = name.split("_")
return s[0] + "".join(word.capitalize() for word in s[1:])
def _proc_init(self, exit_stack: contextlib.ExitStack) -> None:
# Create the XMLRPC server and register functions.
server = XmlRpcServerProcess.Server(self._config.stop_timeout_sec)
for func in APPS_RPC_FUNCS:
server.register_function(self._create_func(func), self._to_camel_case(func))
# Set the log level for the process with the dedicated log level for RPC.
self._config.log_config.set_fmt(level=self._config.log_config.level_rpc)
# Start the server and register in the exit stack.
exit_stack.enter_context(server)
class Server(SimpleXMLRPCServer):
def __init__(self, stop_timeout_sec: float):
super().__init__(addr=(IP, PORT))
self._stop_timeout_sec = stop_timeout_sec
self._thread = threading.Thread(name="Server", target=self.serve_forever, daemon=True)
def __enter__(self) -> Self:
self._thread.start()
return self
def __exit__(self, *args: Any) -> None:
log.debug("Stopping XMLRPC Server thread")
self.shutdown()
log.debug("Waiting for XMLRPC Server thread to stop")
self._thread.join(timeout=self._stop_timeout_sec)
if not self._thread.is_alive():
log.debug("XMLRPC Server thread stopped")
else:
log.error("XMLRPC Server thread failed to stop")
class XmlRpcServerProcessManager(threading.Thread):
"""
Thread used to orchestrate the XML-RPC subprocess and dispatch loop.
The manager bridges three domains:
- Parent test code that starts/stops XML-RPC support.
- `XmlRpcServerProcess` (run in network namespace on Linux), which exposes XML-RPC and forwards calls as queue requests.
- `AppsRegister`, where requested operations are executed.
Lifecycle behavior:
- `start()` launches the manager thread and waits for initialization (`_init_done`) or failure (`_exception`).
- `run()` creates the wrapped spawn context, starts `XmlRpcServerProcess`, then consumes requests and executes validated
`AppsRegister` methods.
- `stop()` signals shutdown by cancelling the work and response queues, joins the thread, and always shuts down the
multiprocessing manager.
Return values and exceptions from `AppsRegister` execution are written to the response queue so the XML-RPC subprocess can
propagate them back to the RPC caller.
"""
def __init__(self, apps: AppsRegister, net_ns_wrapper: str | None, log_config: LogConfig) -> None:
super().__init__(name="XmlRpcMgr", daemon=True)
self.apps = apps
self.net_ns_wrapper = net_ns_wrapper
self.log_config = log_config
self._init_done = threading.Event()
self._exception: BaseException | None = None
self._stopped = False
self._mp_manager = multiprocessing.Manager()
self._proc_config = ProcessConfig(name="XmlRpcProc", log_config=log_config).with_formatted_name()
self._work_queue: CancellableQueue[XmlRpcFuncCall] = CancellableQueue(self._mp_manager)
self._rsp_queue: CancellableQueue[XmlRpcFuncRet] = CancellableQueue(self._mp_manager)
@with_annotated_exception
def start(self) -> None:
if self._stopped:
raise RuntimeError("Cannot start XMLRPC Manager after it has been stopped")
super().start()
try:
if not self._init_done.wait(timeout=self._proc_config.start_timeout_sec):
raise TimeoutError("Failed to start within timeout")
if isinstance(self._exception, KeyboardInterrupt):
raise self._exception
if self._exception is not None:
raise RuntimeError("XMLRPC Manager initialization failed") from self._exception
except BaseException as start_exc:
log.error("Stopping XMLRPC Manager after init error: %r", start_exc)
try:
self.stop()
except BaseException as stop_exc:
log.error("Error when stopping XMLRPC Manager after init error: %r", stop_exc)
raise stop_exc.with_traceback(stop_exc.__traceback__) from start_exc
raise
@with_annotated_exception
def stop(self) -> None:
if self._stopped:
log.debug("XMLRPC Manager is already stopped")
return
try:
# Cancel all queues to signal the subprocess and the manager thread to stop.
self._work_queue.cancel()
self._rsp_queue.cancel()
# Stopping the queues should result in the subprocess exiting, and in turn the manager thread.
self.join(timeout=self._proc_config.stop_timeout_sec)
# Propagate the exception risen in the thread.
if isinstance(self._exception, KeyboardInterrupt):
raise self._exception
if self._exception is not None:
raise RuntimeError("XMLRPC Manager failed") from self._exception
if self.is_alive():
raise TimeoutError("XMLRPC Manager failed to stop within timeout")
except KeyboardInterrupt:
raise
except BaseException as e:
log.error("Error when stopping XMLRPC Manager: %r", e)
raise
finally:
self._mp_manager.shutdown()
self._stopped = True
def run(self) -> None:
try:
with (mp_wrapped_spawn_context(wrapper_linux=self.net_ns_wrapper) as ctx,
XmlRpcServerProcess(ctx, self._mp_manager, self._proc_config, self._work_queue, self._rsp_queue) as server):
# Process is already successfully started when the context is entered.
self._init_done.set()
while True:
try:
server._rsp_queue.put(self._proc_work(server._work_queue.get()))
except QueueCancelled:
log.debug("Stopping on a cancel event")
break
except BaseException as e:
self._exception = e
self._init_done.set()
def _proc_work(self, func_call: XmlRpcFuncCall) -> XmlRpcFuncRet:
try:
# Check if the function exists and is registered as an RPC function.
if (func := getattr(self.apps, func_call.name, None)) is None or not callable(func):
raise RuntimeError(f'Function "{func_call.name}" does not exist in AppsRegister')
if func.__name__ not in APPS_RPC_FUNCS:
raise RuntimeError(f'Function "{func_call.name}" is not registered as an RPC function')
# Execute the function with given arguments and propagate the return value.
if not isinstance(ret := func(*func_call.args), bool):
raise RuntimeError(f'Function "{func_call.name}" returned invalid type {type(ret)}')
return ret
except Exception as e:
# Pass the exception to the XMLRPC server process. The server process will handle it and return it to the caller, so
# that the caller can also handle it.
return e
S = TypeVar("S", bound="AppsRegister")
P = ParamSpec("P")
R = TypeVar("R")
def with_accessories_lock(fn: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
"""Decorator to acquire self._accessories_lock around instance method calls.
As _accessories might be accessed either from the chiptest itself and from outside
via the XMLRPC server it's good to have it available only under mutex.
"""
@functools.wraps(fn)
def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
if (lock := getattr(self, "_accessories_lock", None)) is None:
return fn(self, *args, **kwargs)
with lock:
return fn(self, *args, **kwargs)
return wrapper
class AppsRegister(TerminableResource):
def __init__(self, net_ns_wrapper: str | None = None, log_config: LogConfig | None = None) -> None:
super().__init__()
self._accessories: dict[str, App] = {}
self._accessories_lock = threading.RLock()
self._net_ns_wrapper = net_ns_wrapper
self._log_config = log_config if log_config is not None else LogConfig()
self._server_manager: XmlRpcServerProcessManager | None = None
def resource_start(self) -> None:
if self._server_manager is None:
self._server_manager = XmlRpcServerProcessManager(self, self._net_ns_wrapper, self._log_config)
if self._server_manager.is_alive():
log.debug("XMLRPC server is already running")
return
log.debug("Starting XMLRPC Manager")
self._server_manager.start()
log.debug("XMLRPC Manager started")
return
def resource_terminate(self) -> None:
if self._server_manager is None:
log.debug("XMLRPC server is already down")
return
log.debug("Stopping XMLRPC Manager")
self._server_manager.stop()
self._server_manager = None
log.debug("XMLRPC Manager stopped")
# Legacy function aliases for backward compatibility.
def init(self) -> None:
self.resource_start()
uninit = resource_terminate
@property
@with_accessories_lock
def accessories(self):
"""List of registered accessory applications."""
return self._accessories.values()
@with_accessories_lock
def add(self, name: str, accessory: App) -> None:
self._accessories[name] = accessory
@with_accessories_lock
def remove(self, name: str) -> None:
self._accessories.pop(name)
@with_accessories_lock
def remove_all(self) -> None:
self._accessories.clear()
@with_accessories_lock
def get(self, name: str) -> App:
return self._accessories[name]
@with_accessories_lock
def kill(self, name: str) -> bool:
if accessory := self._accessories[name]:
return accessory.kill()
return False
@with_accessories_lock
def kill_all(self) -> bool:
# Make sure to do kill() on all of our apps, even if some of them returned False
results = [accessory.kill() for accessory in self._accessories.values()]
return all(results)
@with_accessories_lock
def start(self, name: str, args: list[str]) -> bool:
if accessory := self._accessories[name]:
# The args param comes directly from the sys.argv[2:] of Start.py and should contain a list of strings in
# key-value pair, e.g. [option1, value1, option2, value2, ...]
return accessory.start(self._create_command_line_options(args))
return False
@with_accessories_lock
def stop(self, name: str) -> bool:
if accessory := self._accessories[name]:
return accessory.stop()
return False
@with_accessories_lock
def reboot(self, name: str) -> bool:
if accessory := self._accessories[name]:
return accessory.stop() and accessory.start()
return False
@with_accessories_lock
def factory_reset_all(self) -> None:
for accessory in self._accessories.values():
accessory.factoryReset()
@with_accessories_lock
def factory_reset(self, name: str) -> bool:
if accessory := self._accessories[name]:
return accessory.factoryReset()
return False
@with_accessories_lock
def wait_for_message(self, name: str, message: list[str], timeoutInSeconds: float = 10) -> bool:
if accessory := self._accessories[name]:
# The message param comes directly from the sys.argv[2:] of WaitForMessage.py and should contain a list of strings that
# comprise the entire message to wait for
return accessory.waitForMessage(' '.join(message), timeoutInSeconds)
return False
def create_ota_image(self, otaImageFilePath: str, rawImageFilePath: str, rawImageContent: str, vid: str = '0xDEAD',
pid: str = '0xBEEF') -> bool:
# Write the raw image content
Path(rawImageFilePath).write_text(rawImageContent)
# Add an OTA header to the raw file
otaImageTool = _DEFAULT_CHIP_ROOT / 'src/app/ota_image_tool.py'
cmd = [str(otaImageTool), 'create', '-v', vid, '-p', pid, '-vn', '2',
'-vs', "2.0", '-da', 'sha256', rawImageFilePath, otaImageFilePath]
s = subprocess.Popen(cmd)
# We need to have some timeout so that in case the process hangs we don't wait infinitely in CI. 60 seconds is large enough
# for the OTA tool.
try:
s.communicate(timeout=60)
except subprocess.TimeoutExpired:
s.kill()
raise RuntimeError('OTA image tool timed out')
if s.returncode != 0:
raise RuntimeError('Cannot create OTA image file')
return True
def compare_files(self, file1: str | Path, file2: str | Path) -> bool:
if not filecmp.cmp(file1, file2, shallow=False):
raise RuntimeError(f'Files {file1} and {file2} do not match')
return True
def create_file(self, filePath: str | Path, fileContent: str) -> bool:
Path(filePath).write_text(fileContent)
return True
def delete_file(self, filePath: str | Path) -> bool:
Path(filePath).unlink(missing_ok=True)
return True
@staticmethod
def _create_command_line_options(args: list[str]) -> dict[str, str]:
try:
# Create a dictionary from the key-value pair list
return dict(zip(args[::2], args[1::2], strict=True))
except ValueError:
# args should contain a list of strings in key-value pair, e.g. [option1, value1, option2, value2, ...]
log.warning("Unexpected command line options %r - not key/value pairs (odd length)", args)
return {}
APPS_RPC_FUNCS = tuple(func.__name__ for func in (
AppsRegister.start, AppsRegister.stop, AppsRegister.reboot, AppsRegister.factory_reset, AppsRegister.wait_for_message,
AppsRegister.compare_files, AppsRegister.create_ota_image, AppsRegister.create_file, AppsRegister.delete_file
))
"""Functions of AppsRegister that are exposed via XMLRPC. The function names will be converted to camelCase for XMLRPC."""