Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion score/itf/core/process/BUILD
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# *******************************************************************************
# Copyright (c) 2025 Contributors to the Eclipse Foundation
# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
Expand All @@ -18,8 +18,10 @@ py_library(
name = "process",
srcs = [
"__init__.py",
"async_process.py",
"console.py",
"process_wrapper.py",
"wrapped_process.py",
],
visibility = ["//visibility:public"],
deps = [
Expand Down
61 changes: 61 additions & 0 deletions score/itf/core/process/async_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
from abc import ABC, abstractmethod


class AsyncProcess(ABC):
"""Common interface for a non-blocking process execution handle.

Target implementation must conform to this contract so that :class:`WrappedProcess` can
manage process lifecycles regardless of the underlying execution backend.
"""

@abstractmethod
def pid(self) -> int:
"""Return the PID of the running process."""

@abstractmethod
def is_running(self) -> bool:
"""Return ``True`` if the process is still executing."""

@abstractmethod
def get_exit_code(self) -> int:
"""Return the exit code of the finished process.

The result is only meaningful after the process has stopped.
"""

@abstractmethod
def stop(self) -> int:
"""Terminate the running process, escalating to ``SIGKILL`` if needed.

:return: exit code of the stopped process.
"""

@abstractmethod
def wait(self, timeout_s: float = 15) -> int:
"""Block until the process finishes or *timeout_s* elapses.

:param timeout_s: maximum seconds to wait.
:return: exit code of the process.
:raises RuntimeError: on timeout.
"""

@abstractmethod
def get_output(self) -> str:
"""Return the captured stdout of the process.

Output is accumulated as the process runs. It is safe to call
while the process is still executing (returns what has been
captured so far) or after it has finished.
"""
123 changes: 123 additions & 0 deletions score/itf/core/process/wrapped_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import logging
import os
import signal

from score.itf.core.process.async_process import AsyncProcess


logger = logging.getLogger(__name__)


class WrappedProcess:
"""Unified process wrapper that works with any Target implementation.

Manages the lifecycle of a binary executed asynchronously through the
``Target.execute_async()`` → ``AsyncProcess`` interface.
"""

# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments

def __init__(
self,
target,
binary_path,
args=None,
cwd="/",
wait_on_exit=False,
wait_timeout=15,
enforce_clean_shutdown=False,
expected_exit_code=0,
**kwargs,
):
self.target = target
self.binary_path = binary_path
self.args = args if args is not None else []
self.cwd = cwd

self.ret_code = None
self.process = None

self._wait_on_exit = wait_on_exit
self._wait_timeout = wait_timeout
self.enforce_clean_shutdown = enforce_clean_shutdown
self.expected_exit_code = expected_exit_code
self.kwargs = kwargs

def __enter__(self):
self.process = self.target.execute_async(self.binary_path, args=self.args, cwd=self.cwd, **self.kwargs)
return self

def __exit__(self, exception_type, exception_value, traceback):
self.ret_code = self._handle_process_exit()
logger.debug(f"Application [{os.path.basename(self.binary_path)}] exit code: [{self.ret_code}]")
self._check_process_exit_code()

def pid(self):
return self.process.pid()

def is_running(self):
return self.process.is_running()

def get_exit_code(self):
return self.process.get_exit_code()

def stop(self):
return self.process.stop()

def wait(self, timeout_s=15):
return self.process.wait(timeout_s)

def get_output(self):
"""Return the captured stdout of the process."""
return self.process.get_output()

def _handle_process_exit(self):
if self._wait_on_exit:
return self.process.wait(self._wait_timeout)
# don't wait for process natural finish, just terminate it
if self.process.is_running():
return self.process.stop()
return self.process.get_exit_code()

def _check_process_exit_code(self):
signal_base = 128
acceptable_exit_codes = {
0,
signal_base + signal.SIGTERM,
self.expected_exit_code,
}

# If clean shutdown is not enforced, then SIGKILL is an acceptable exit code
if not self.enforce_clean_shutdown:
acceptable_exit_codes.add(signal_base + signal.SIGKILL)

if self.ret_code not in acceptable_exit_codes:
if self.ret_code == 55:
raise RuntimeError("Sanitizers failed")
if self.ret_code == signal_base + signal.SIGKILL:
raise RuntimeError(
f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates it was stopped with SIGKILL,"
" so it did not shut down gracefully, but enforce_clean_shutdown is flagged as True"
)
if self.ret_code == signal_base + signal.SIGSEGV:
raise RuntimeError(
f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates SIGSEGV occurred."
)
if self.ret_code == signal_base + signal.SIGABRT:
raise RuntimeError(
f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates SIGABRT occurred."
)
raise RuntimeError(f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates an error.")
34 changes: 33 additions & 1 deletion score/itf/core/target/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
# *******************************************************************************

from abc import ABC, abstractmethod
from typing import Set, Optional, Tuple
from typing import List, Set, Optional, Tuple

from score.itf.core.process.async_process import AsyncProcess
from score.itf.core.process.wrapped_process import WrappedProcess


class Target(ABC):
Expand Down Expand Up @@ -90,6 +93,32 @@ def remove_capability(self, capability: str) -> None:
def execute(self, command: str) -> Tuple[int, bytes]:
"""Execute a command on the target."""

@abstractmethod
def execute_async(
self,
binary_path: str,
args: Optional[List[str]] = None,
cwd: str = "/",
) -> AsyncProcess:
"""Start a binary without blocking and return an :class:`AsyncProcess` handle.

:param binary_path: path to the binary to execute.
:param args: list of string arguments for the binary (default: ``None``).
:param cwd: working directory inside the target environment.
:return: an :class:`AsyncProcess` instance.
"""

def wrap_exec(
self,
*args,
**kwargs,
) -> WrappedProcess:
return WrappedProcess(
self,
*args,
**kwargs,
)

@abstractmethod
def upload(self, local_path: str, remote_path: str) -> None:
"""Upload a file from the test host to the target."""
Expand All @@ -111,6 +140,9 @@ class UnsupportedTarget(Target):
def execute(self, command: str) -> Tuple[int, bytes]:
raise NotImplementedError("No target plugin selected: exec is unavailable")

def execute_async(self, binary_path: str, args: Optional[List[str]] = None, cwd: str = "/") -> AsyncProcess:
raise NotImplementedError("No target plugin selected: exec is unavailable")

def upload(self, local_path: str, remote_path: str) -> None:
raise NotImplementedError("No target plugin selected: upload is unavailable")

Expand Down
Loading
Loading