diff --git a/tests/integration/lm_sigkill_children_survive/BUILD b/tests/integration/lm_sigkill_children_survive/BUILD new file mode 100644 index 0000000000..090585f290 --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/BUILD @@ -0,0 +1,58 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library") +load("//tests/utils/bazel:integration.bzl", "integration_test") + +cc_library( + name = "lm_sigkill_children_survive_common", + hdrs = ["common.hpp"], + deps = [ + "@googletest//:gtest_main", + ], +) + +cc_binary( + name = "control_client_test_driver", + srcs = ["control_client_test_driver.cpp"], + deps = [ + ":lm_sigkill_children_survive_common", + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +cc_binary( + name = "application_process", + srcs = ["application_process.cpp"], + deps = [ + ":lm_sigkill_children_survive_common", + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +integration_test( + name = "lm_sigkill_children_survive", + timeout = "short", + srcs = ["lm_sigkill_children_survive.py"], + binaries = [ + ":control_client_test_driver", + ":application_process", + "//score/launch_manager", + ], + config = ":lm_sigkill_children_survive.json", +) diff --git a/tests/integration/lm_sigkill_children_survive/application_process.cpp b/tests/integration/lm_sigkill_children_survive/application_process.cpp new file mode 100644 index 0000000000..c1c2b9619b --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/application_process.cpp @@ -0,0 +1,34 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +#include + +#include "common.hpp" +#include "tests/utils/test_helper/test_helper.hpp" +#include + +// Application process started by the Launch Manager. It publishes its PID and +// reports running, then blocks (in the TestRunner destructor) so it stays alive +// after the Launch Manager is killed. +TEST(LmSigkillChildrenSurvive, ApplicationProcess) +{ + // Publish our PID before reporting running: once the "Running" run target is + // active the test is guaranteed to find this file. + ASSERT_TRUE(write_pid(app_pid_file)); + + score::mw::lifecycle::report_running(); +} + +int main() +{ + return TestRunner(__FILE__).RunTests(); +} diff --git a/tests/integration/lm_sigkill_children_survive/common.hpp b/tests/integration/lm_sigkill_children_survive/common.hpp new file mode 100644 index 0000000000..93bab0f4bb --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/common.hpp @@ -0,0 +1,44 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +#ifndef SCORE_TESTS_INTEGRATION_LM_SIGKILL_CHILDREN_SURVIVE_COMMON_HPP +#define SCORE_TESTS_INTEGRATION_LM_SIGKILL_CHILDREN_SURVIVE_COMMON_HPP + +#include +#include +#include +#include +#include + +/// @brief PID files written by the managed processes (as decimal text) so the +/// test can track them by PID after the Launch Manager has been SIGKILLed. +constexpr std::string_view daemon_pid_file = "daemon_pid"; +constexpr std::string_view app_pid_file = "app_pid"; + +/// @brief Touched by the control daemon once both managed processes are running, +/// signalling the test that it may SIGKILL the Launch Manager. +constexpr std::string_view children_ready_file = "children_ready"; + +/// @brief Writes the current PID as decimal text to @p path. +/// @return AssertionSuccess if the PID was written successfully. +inline testing::AssertionResult write_pid(const std::string_view path) +{ + std::ofstream out{std::string{path}, std::ios::trunc}; + out << getpid(); + if (!out) + { + return testing::AssertionFailure() << "Failed to write PID to " << path; + } + return testing::AssertionSuccess(); +} + +#endif // SCORE_TESTS_INTEGRATION_LM_SIGKILL_CHILDREN_SURVIVE_COMMON_HPP diff --git a/tests/integration/lm_sigkill_children_survive/control_client_test_driver.cpp b/tests/integration/lm_sigkill_children_survive/control_client_test_driver.cpp new file mode 100644 index 0000000000..6ce5ce8650 --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/control_client_test_driver.cpp @@ -0,0 +1,53 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +#include + +#include "common.hpp" +#include "tests/utils/test_helper/test_helper.hpp" +#include +#include + +// Control daemon: starts the application process by activating the "Running" run +// target, then publishes readiness and blocks. It never switches away from +// "Running", so both it and the application process are still children of the +// Launch Manager when the test SIGKILLs it. +TEST(LmSigkillChildrenSurvive, ControlDaemon) +{ + score::mw::lifecycle::ControlClient client{}; + + ASSERT_TRUE(check_clean({daemon_pid_file, app_pid_file, children_ready_file}, /*strict=*/false)); + + TEST_STEP("Control daemon report running") + { + score::mw::lifecycle::report_running(); + } + + TEST_STEP("Activate RunTarget Running") + { + score::cpp::stop_token stop_token; + auto result = client.ActivateRunTarget("Running").Get(stop_token); + ASSERT_TRUE(result.has_value()) << "Activating target Running failed: " << result.error().Message(); + } + + // Publish our own PID and signal the test, which then SIGKILLs the Launch Manager. + TEST_STEP("Signal readiness") + { + ASSERT_TRUE(write_pid(daemon_pid_file)); + ASSERT_TRUE(touch_file(children_ready_file)); + } +} + +int main() +{ + return TestRunner(__FILE__).RunTests(); +} diff --git a/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.json b/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.json new file mode 100644 index 0000000000..076edc8b0a --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.json @@ -0,0 +1,107 @@ +{ + "schema_version": 1, + "defaults": { + "deployment_config": { + "bin_dir": "/tmp/tests/lm_sigkill_children_survive", + "ready_timeout": 1.0, + "shutdown_timeout": 1.0, + "ready_recovery_action": { + "restart": { + "number_of_attempts": 0 + } + }, + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + }, + "environmental_variables": { + "LD_LIBRARY_PATH": "/opt/lib" + }, + "sandbox": { + "uid": 0, + "gid": 0, + "scheduling_policy": "SCHED_OTHER", + "scheduling_priority": 0 + } + }, + "component_properties": { + "application_profile": { + "application_type": "Reporting", + "is_self_terminating": false, + "alive_supervision": { + "reporting_cycle": 0.1, + "min_indications": 1, + "max_indications": 3, + "failed_cycles_tolerance": 1 + } + }, + "ready_condition": { + "process_state": "Running" + } + } + }, + "components": { + "control_daemon": { + "component_properties": { + "binary_name": "control_client_test_driver", + "application_profile": { + "application_type": "State_Manager", + "alive_supervision": { + "min_indications": 0 + } + } + }, + "deployment_config": { + "ready_timeout": 1.0, + "shutdown_timeout": 1.0, + "environmental_variables": { + "PROCESSIDENTIFIER": "control_daemon" + } + } + }, + "application_process": { + "component_properties": { + "binary_name": "application_process", + "application_profile": { + "application_type": "Reporting" + } + }, + "deployment_config": { + "environmental_variables": { + "PROCESSIDENTIFIER": "DefaultPG_app0" + } + } + } + }, + "run_targets": { + "Startup": { + "depends_on": [ + "control_daemon" + ], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + }, + "Running": { + "depends_on": [ + "control_daemon", + "application_process" + ], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + } + }, + "initial_run_target": "Startup", + "alive_supervision": { + "evaluation_cycle": 0.05 + }, + "fallback_run_target": { + "depends_on": [] + } +} diff --git a/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.py b/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.py new file mode 100644 index 0000000000..1f04bdfb6e --- /dev/null +++ b/tests/integration/lm_sigkill_children_survive/lm_sigkill_children_survive.py @@ -0,0 +1,96 @@ +# ******************************************************************************* +# 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 tests.utils.testing_utils.run_until_file_deployed import run_until_file_deployed +from tests.utils.testing_utils.setup_test import setup_test +from tests.utils.testing_utils.test_results import assert_test_results +from attribute_plugin import add_test_properties + + +def _read_pid(target, pid_file): + code, out = target.execute(f"cat {pid_file}") + assert code == 0, f"Failed to read {pid_file}: {out!r}" + return int(out.decode().strip()) + + +def _pid_alive(target, pid): + return target.execute(f"kill -0 {pid}")[0] == 0 + + +@add_test_properties( + partially_verifies=[], + fully_verifies=["comp_req__launch_man__fast_shutdown_support"], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_lm_sigkill_children_survive( + target, setup_test, assert_test_results, remote_test_dir +): + """ + Objective: Verifies that processes started by the Launch Manager keep running + when the Launch Manager itself is killed with SIGKILL, i.e. without any chance + to tear its children down. + + The control daemon activates the "Running" run target, which starts the managed + application process, and then signals readiness. The test SIGKILLs only the + Launch Manager and checks that both the control daemon and the application + process are still alive afterwards. + + Expected Behaviour: After the Launch Manager is SIGKILLed, both child processes + remain running. + """ + + config_path = str(remote_test_dir / "etc/lm_sigkill_children_survive.bin") + ready_file = remote_test_dir / "children_ready" + daemon_pid_file = remote_test_dir / "daemon_pid" + app_pid_file = remote_test_dir / "app_pid" + + # Both children are up and reporting once the daemon touches the ready file. + # Keep the launch manager running so the test can SIGKILL it below. + proc = run_until_file_deployed( + target=target, + binary_path=str(remote_test_dir / "launch_manager"), + file_path=ready_file, + cwd=str(remote_test_dir), + args=["-c", config_path], + timeout_s=5.0, + stop_on_file=False, + ) + + daemon_pid = None + app_pid = None + try: + daemon_pid = _read_pid(target, daemon_pid_file) + app_pid = _read_pid(target, app_pid_file) + + # Kill the Launch Manager - and only the Launch Manager - via SIGKILL. + assert proc.is_running(), "Launch manager exited before it could be killed" + code, out = target.execute(f"kill -9 {proc.pid()}") + assert code == 0, f"Failed to SIGKILL launch manager (pid {proc.pid()}): {out!r}" + proc.wait(timeout_s=3.0) + + # The child processes must survive the death of their parent. + assert _pid_alive(target, daemon_pid), ( + f"Control daemon (pid {daemon_pid}) died with the launch manager" + ) + assert _pid_alive(target, app_pid), ( + f"Application process (pid {app_pid}) died with the launch manager" + ) + + assert_test_results( + {"control_client_test_driver.xml", "application_process.xml"} + ) + finally: + # Clean up the now-orphaned children so they do not leak on the target. + for pid in (daemon_pid, app_pid): + if pid is not None: + target.execute(f"kill -9 {pid}") diff --git a/tests/utils/testing_utils/run_until_file_deployed.py b/tests/utils/testing_utils/run_until_file_deployed.py index d765e11038..1d1d85c779 100644 --- a/tests/utils/testing_utils/run_until_file_deployed.py +++ b/tests/utils/testing_utils/run_until_file_deployed.py @@ -27,6 +27,7 @@ def run_until_file_deployed( poll_interval_s: float = 0.5, args=None, cwd: str = "/", + stop_on_file: bool = True, ) -> AsyncProcess: """Start a binary and block until a file appears on the target, then stop the process. @@ -37,7 +38,9 @@ def run_until_file_deployed( :param poll_interval_s: seconds between file checks (default: 0.5). :param args: optional list of arguments to pass to the binary. :param cwd: working directory on the target (default: "/"). - :return: the stopped :class:`AsyncProcess` handle. + :param stop_on_file: stop the process once the file appears (default: True); + set to False to keep the process running and return it still alive. + :return: the :class:`AsyncProcess` handle (stopped unless *stop_on_file* is False). :raises TimeoutError: if the file does not appear within *timeout_s*. :raises RuntimeError: if the process exits before the file appears. """ @@ -59,10 +62,11 @@ def run_until_file_deployed( exit_code, _ = target.execute(f"test -f {file_path}") if exit_code == 0: - exit_code = proc.stop() - assert exit_code == 0, ( - f"LCM did not exit cleanly, it died with code {exit_code}" - ) + if stop_on_file: + exit_code = proc.stop() + assert exit_code == 0, ( + f"LCM did not exit cleanly, it died with code {exit_code}" + ) return proc logger.debug(f"Waiting for {file_path}")