diff --git a/tests/integration/shutdown_signal/BUILD b/tests/integration/shutdown_signal/BUILD new file mode 100644 index 0000000000..bc47bd49de --- /dev/null +++ b/tests/integration/shutdown_signal/BUILD @@ -0,0 +1,54 @@ +# ******************************************************************************* +# 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 = "shutdown_signal_common", + hdrs = ["common.hpp"], +) + +cc_binary( + name = "control_daemon_mock", + srcs = ["control_daemon_mock.cpp"], + deps = [ + ":shutdown_signal_common", + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +cc_binary( + name = "shutdown_signal_process", + srcs = ["shutdown_signal_process.cpp"], + deps = [ + ":shutdown_signal_common", + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +integration_test( + name = "shutdown_signal", + srcs = ["shutdown_signal.py"], + binaries = [ + ":control_daemon_mock", + ":shutdown_signal_process", + "//score/launch_manager", + ], + config = ":shutdown_signal.json", +) diff --git a/tests/integration/shutdown_signal/common.hpp b/tests/integration/shutdown_signal/common.hpp new file mode 100644 index 0000000000..6e233ca40f --- /dev/null +++ b/tests/integration/shutdown_signal/common.hpp @@ -0,0 +1,25 @@ +/******************************************************************************** + * 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_SHUTDOWN_SIGNAL_COMMON_HPP +#define SCORE_TESTS_INTEGRATION_SHUTDOWN_SIGNAL_COMMON_HPP + +#include + +/// @brief Written by gtest_process from within its SIGTERM handler, containing +/// its PID as raw bytes. Its existence proves the Launch Manager delivered a +/// SIGTERM to request shutdown, and the PID lets control_daemon_mock confirm the +/// process was subsequently killed. It is written before the process blocks, so +/// it survives the subsequent SIGKILL (no code can run after SIGKILL). +constexpr std::string_view sigterm_received_file = "sigterm_received"; + +#endif // SCORE_TESTS_INTEGRATION_SHUTDOWN_SIGNAL_COMMON_HPP diff --git a/tests/integration/shutdown_signal/control_daemon_mock.cpp b/tests/integration/shutdown_signal/control_daemon_mock.cpp new file mode 100644 index 0000000000..e10c6db689 --- /dev/null +++ b/tests/integration/shutdown_signal/control_daemon_mock.cpp @@ -0,0 +1,91 @@ +/******************************************************************************** + * 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 +#include +#include +#include + +#include "common.hpp" +#include "tests/utils/test_helper/test_helper.hpp" +#include +#include + +// The Launch Manager shall shut a process down by sending it a SIGTERM, and, if +// the process does not terminate itself in time, a SIGKILL. +// +// The managed shutdown_signal_process installs a SIGTERM handler that records the SIGTERM +// (writing its PID to `sigterm_received_file`) and then blocks instead of +// terminating, forcing the Launch Manager to send SIGKILL. +// +// We drive the shutdown by activating "Running" (which starts shutdown_signal_process) and +// then switching back to "Startup". "Startup" no longer depends on shutdown_signal_process, +// so it is terminated, while the control daemon itself stays alive (it is part of +// "Startup") and can therefore assert the outcome. Switching to "Off" instead +// would terminate the control daemon too, so it could not run the assertion. +TEST(ShutdownSignal, Daemon) +{ + score::mw::lifecycle::ControlClient client{}; + ASSERT_TRUE(check_clean({test_end_location, sigterm_received_file})); + + 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); + EXPECT_TRUE(result.has_value()) << "Activating target Running failed: " << result.error().Message(); + } + + // Switching away from "Running" terminates shutdown_signal_process. Because it does not + // self-terminate on SIGTERM, the Launch Manager must escalate to SIGKILL for + // the transition to complete. + TEST_STEP("Activate RunTarget Startup") + { + score::cpp::stop_token stop_token; + auto result = client.ActivateRunTarget("Startup").Get(stop_token); + EXPECT_TRUE(result.has_value()) << "Activating target Startup failed: " << result.error().Message(); + } + + TEST_STEP("Verify SIGTERM was received and SIGKILL forced termination") + { + // SIGTERM was delivered: the process recorded its PID before blocking. + ASSERT_TRUE(std::filesystem::exists(sigterm_received_file)) + << "shutdown_signal_process did not receive a SIGTERM during shutdown"; + + // Read back the PID the process wrote from within its SIGTERM handler. + pid_t pid{}; + std::ifstream pid_file{std::string{sigterm_received_file}, std::ios::binary}; + ASSERT_TRUE(pid_file.read(reinterpret_cast(&pid), sizeof(pid))) + << "Failed to read the PID from " << sigterm_received_file; + + // SIGKILL forced termination: the process never self-terminates, so its + // absence proves the Launch Manager escalated to SIGKILL. + EXPECT_EQ(kill(pid, 0), -1) << "shutdown_signal_process (pid " << pid + << ") is still alive; it was not SIGKILLed"; + EXPECT_EQ(errno, ESRCH) << "unexpected errno probing shutdown_signal_process (pid " << pid << ")"; + } + + TEST_STEP("Activate RunTarget Off") + { + client.ActivateRunTarget("Off"); + } +} + +int main() +{ + return TestRunner(__FILE__, TerminationBehavior::kWait, TerminationNotification::kTestEnd).RunTests(); +} diff --git a/tests/integration/shutdown_signal/shutdown_signal.json b/tests/integration/shutdown_signal/shutdown_signal.json new file mode 100644 index 0000000000..a1bfc06223 --- /dev/null +++ b/tests/integration/shutdown_signal/shutdown_signal.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "defaults": { + "deployment_config": { + "bin_dir": "/tmp/tests/shutdown_signal", + "ready_timeout": 1.0, + "shutdown_timeout": 1.0, + "ready_recovery_action": { + "restart": { + "number_of_attempts": 0 + } + }, + "sandbox": { + "uid": 0, + "gid": 0, + "scheduling_policy": "SCHED_OTHER", + "scheduling_priority": 0 + } + } + }, + "components": { + "control_daemon": { + "component_properties": { + "binary_name": "control_daemon_mock", + "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" + } + } + }, + "shutdown_signal_process": { + "component_properties": { + "binary_name": "shutdown_signal_process", + "application_profile": { + "application_type": "Reporting" + } + }, + "deployment_config": { + "shutdown_timeout": 0.5, + "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", + "shutdown_signal_process" + ], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + }, + "Off": { + "depends_on": [], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + } + }, + "initial_run_target": "Startup", + "fallback_run_target": { + "depends_on": [] + } +} diff --git a/tests/integration/shutdown_signal/shutdown_signal.py b/tests/integration/shutdown_signal/shutdown_signal.py new file mode 100644 index 0000000000..3ae87407bb --- /dev/null +++ b/tests/integration/shutdown_signal/shutdown_signal.py @@ -0,0 +1,56 @@ +# ******************************************************************************* +# 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 + + +@add_test_properties( + fully_verifies=[ + "comp_req__launch_man__shutdown_signal", + ], + partially_verifies=[], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_shutdown_signal(target, setup_test, assert_test_results, remote_test_dir): + """ + Objective: Verifies that the Launch Manager shuts a process down by sending a + SIGTERM and, if the process does not terminate itself in time, escalates to a + SIGKILL. + + The control daemon activates the "Running" run target (starting the managed + shutdown_signal_process), then switches back to "Startup". The shutdown_signal_process installs a + SIGTERM handler that records its PID and then deliberately blocks instead of + terminating, forcing the Launch Manager to send SIGKILL. Finally the control + daemon activates "Off". + + Expected Behaviour: shutdown_signal_process receives a SIGTERM (proven by the + `sigterm_received` file, which holds the PID it wrote before blocking and + therefore survives SIGKILL) and is then force-terminated by SIGKILL (proven by + that PID no longer existing, since the process never self-terminates). + """ + + new_config_path = str(remote_test_dir / "etc/shutdown_signal.bin") + + run_until_file_deployed( + target=target, + binary_path=str(remote_test_dir / "launch_manager"), + file_path=remote_test_dir.parent / "test_end", + cwd=str(remote_test_dir), + args=["-c", new_config_path], + timeout_s=10.0, + ) + + assert_test_results({"control_daemon_mock.xml", "shutdown_signal_process.xml"}) diff --git a/tests/integration/shutdown_signal/shutdown_signal_process.cpp b/tests/integration/shutdown_signal/shutdown_signal_process.cpp new file mode 100644 index 0000000000..ec3bb7c7e3 --- /dev/null +++ b/tests/integration/shutdown_signal/shutdown_signal_process.cpp @@ -0,0 +1,71 @@ +/******************************************************************************** + * 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 "common.hpp" +#include "tests/utils/test_helper/test_helper.hpp" +#include +#include +#include +#include +#include + +namespace +{ +/// @brief SIGTERM handler installed by the process under test. +/// +/// It records its PID (so the outcome can be verified even after the process is +/// gone, since no code runs after SIGKILL) and then blocks forever instead of +/// terminating. Because it never self-terminates, the Launch Manager must +/// escalate to SIGKILL to shut it down; the recorded PID then lets +/// control_daemon_mock confirm that the process is truly gone. +void shutdownSignalHandler(int /*signum*/) +{ + // getpid()/open()/write()/pause() are all async-signal-safe, so this is safe + // to run from within a signal handler. The PID is written as raw bytes; no + // string encoding is needed. On a write failure nothing is recorded, which + // fails the SIGTERM assertion rather than masquerading as a graceful exit. + const pid_t pid = getpid(); + const int fd = open(sigterm_received_file.data(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) + { + static_cast(write(fd, &pid, sizeof(pid))); + static_cast(close(fd)); + } + + // Do NOT terminate: block until SIGKILL arrives so shutdown requires it. + while (true) + { + static_cast(pause()); + } +} +} // namespace + +TEST(ShutdownSignal, Process) +{ + // Remove any leftover file from a previous manual run. + ASSERT_TRUE(check_clean({sigterm_received_file}, false)); + + // Install our own SIGTERM handler. This must happen after the TestRunner + // constructor (which registers its default handler), so that ours takes + // precedence for the shutdown signal sent by the Launch Manager. + signal(SIGTERM, shutdownSignalHandler); + + // Report running so the Launch Manager considers this process ready and the + // "Running" run target can be activated. + score::mw::lifecycle::report_running(); +} + +int main() +{ + return TestRunner(__FILE__).RunTests(); +}