diff --git a/score/launch_manager/daemon/src/process_group_manager/details/BUILD b/score/launch_manager/daemon/src/process_group_manager/details/BUILD index 8ef0ee7e..679ab14c 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/BUILD +++ b/score/launch_manager/daemon/src/process_group_manager/details/BUILD @@ -19,6 +19,7 @@ cc_library( strip_include_prefix = "/score/launch_manager/daemon/src/process_group_manager/details", visibility = ["//score/launch_manager/daemon/src/process_group_manager:__pkg__"], deps = [ + ":safe_process_map", "//score/launch_manager/daemon/src/configuration:configuration_manager", "//score/launch_manager/daemon/src/control:control_client_channel", "//score/launch_manager/daemon/src/osal:ipc_comms", @@ -35,6 +36,7 @@ cc_library( visibility = ["//score/launch_manager/daemon/src/process_group_manager:__pkg__"], deps = [ ":process_info_node", + ":safe_process_map", "//score/launch_manager/daemon/src/common:identifier_hash", "//score/launch_manager/daemon/src/configuration:configuration_manager", "//score/launch_manager/daemon/src/control:control_client_channel", @@ -51,11 +53,19 @@ cc_library( strip_include_prefix = "/score/launch_manager/daemon/src/process_group_manager/details", visibility = ["//score/launch_manager/daemon/src/process_group_manager:__pkg__"], deps = [ - ":process_info_node", "//score/launch_manager/daemon/src/process_group_manager:iprocess", ], ) +cc_test( + name = "safeprocessmap_UT", + srcs = ["safeprocessmap_UT.cpp"], + deps = [ + ":safe_process_map", + "@googletest//:gtest_main", + ], +) + cc_library( name = "os_handler", srcs = ["os_handler.cpp"], @@ -67,6 +77,18 @@ cc_library( ":safe_process_map", "//score/launch_manager/daemon/src/common:log", "//score/launch_manager/daemon/src/process_group_manager:iprocess", + "@score_baselibs//score/os:sys_wait", + ], +) + +cc_test( + name = "oshandler_UT", + srcs = ["oshandler_UT.cpp"], + deps = [ + ":os_handler", + ":safe_process_map", + "@googletest//:gtest_main", + "@score_baselibs//score/os/mocklib:sys_wait_mock", ], ) diff --git a/score/launch_manager/daemon/src/process_group_manager/details/os_handler.cpp b/score/launch_manager/daemon/src/process_group_manager/details/os_handler.cpp index 4a86ca12..dbde7520 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/os_handler.cpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/os_handler.cpp @@ -21,12 +21,12 @@ namespace internal { void OsHandler::run(void) { while (is_running_) { - int32_t status = 0; - osal::ProcessID targetProcessId = 0; + int32_t wait_status = 0; + auto result = sys_wait_.wait(&wait_status); - if (osal::OsalReturnType::kSuccess == process_interface_.waitForTermination(targetProcessId, status)) { - if (-1 == safe_process_map_.findTerminated(targetProcessId, status)) { - LM_LOG_ERROR() << "[os handler: out of resources]"; + if (result.has_value() && result.value() > 0) { + if (-1 == safe_process_map_.findTerminated(result.value(), wait_status)) { + LM_LOG_ERROR() << "No more resources available to track process with PID " << result.value() << "(SafeProcessMap capacity exceeded)."; } } else { // This process has no children to wait for at present, diff --git a/score/launch_manager/daemon/src/process_group_manager/details/os_handler.hpp b/score/launch_manager/daemon/src/process_group_manager/details/os_handler.hpp index a626e55f..a9527f0f 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/os_handler.hpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/os_handler.hpp @@ -18,7 +18,7 @@ #include #include -#include "score/mw/launch_manager/process_group_manager/iprocess.hpp" +#include "score/os/sys_wait.h" #include "score/mw/launch_manager/process_group_manager/details/safe_process_map.hpp" namespace score { @@ -39,15 +39,13 @@ constexpr std::chrono::milliseconds OS_HANDLER_LOOP_DELAY{100}; // TODO - Defin /// There will only be one instance of OsHandler during the Launch Manager's lifetime. class OsHandler final { public: - /// @brief Constructs an OsHandler with safe process map and OS abstraction layer process interfaces + /// @brief Constructs an OsHandler with safe process map and SysWait interface. /// This constructor initializes the OsHandler, starts its execution thread, and prepares it to handle process terminations. /// @param map A reference to a SafeProcessMap that stores the mapping of processes to be managed. - /// @param process_interface A reference to an implementation of osal::IProcess, which provides the necessary - /// methods for process management, including waiting for process termination. - /// @note The lifetime of the object passed as `process_interface` must extend at least as long as - /// the lifetime of this OsHandler instance to avoid accessing dangling references. - OsHandler(SafeProcessMap& map, osal::IProcess& process_interface) - : safe_process_map_(map), process_interface_(process_interface) { + /// @param sys_wait A reference to a score::os::SysWait instance used to wait for child process termination. + /// Defaults to the production singleton. A mock can be injected for testing. + OsHandler(SafeProcessMap& map, score::os::SysWait& sys_wait = score::os::SysWait::instance()) + : safe_process_map_(map), sys_wait_(sys_wait) { } /// @brief Stops and and destroy the execution of the OsHandler's thread by setting the is_running_ flag to false, @@ -84,8 +82,8 @@ class OsHandler final { /// @brief Indicates whether the os handler's thread is currently running. std::atomic_bool is_running_{true}; - /// @brief Interface to the process functionality provided by the OSAL. - osal::IProcess& process_interface_; + /// @brief Interface to wait for child process termination. + score::os::SysWait& sys_wait_; /// @brief Thread object to manage execution of the run method. std::thread os_handler_{&score::lcm::internal::OsHandler::run, this}; diff --git a/score/launch_manager/daemon/src/process_group_manager/details/oshandler_UT.cpp b/score/launch_manager/daemon/src/process_group_manager/details/oshandler_UT.cpp new file mode 100644 index 00000000..bfd4e76e --- /dev/null +++ b/score/launch_manager/daemon/src/process_group_manager/details/oshandler_UT.cpp @@ -0,0 +1,218 @@ +/******************************************************************************** + * Copyright (c) 2025 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 "score/mw/launch_manager/process_group_manager/details/os_handler.hpp" + +#include "score/mw/launch_manager/process_group_manager/details/safe_process_map.hpp" +#include "score/mw/launch_manager/common/constants.hpp" +#include "score/os/mocklib/sys_wait_mock.h" + +using namespace testing; +using namespace score::lcm::internal; + +namespace +{ + +class MockTerminationCallback : public ITerminationCallback +{ + public: + MOCK_METHOD(void, terminated, (int32_t process_status), (override)); +}; + +class OsHandlerTest : public ::testing::Test +{ + protected: + void SetUp() override + { + RecordProperty("TestType", "interface-test"); + RecordProperty("DerivationTechnique", "explorative-testing"); + } + + static constexpr uint32_t kCapacity = 32U; + + SafeProcessMap process_map_{kCapacity}; + score::os::MockGuard sys_wait_mock_; + std::unique_ptr sut_; +}; + +TEST_F(OsHandlerTest, WaitReturnsProcessId_FindTerminatedIsCalled) +{ + RecordProperty("Description", + "When sys_wait returns a valid pid, OsHandler calls findTerminated and the termination callback " + "is invoked."); + + // given — insert a callback for pid 1000 + NiceMock callback; + process_map_.insertIfNotTerminated(1000, &callback); + + constexpr int32_t kExitStatus = 42; + EXPECT_CALL(callback, terminated(kExitStatus)).Times(AtLeast(1)); + + // sys_wait returns pid 1000 once, then blocks with error + EXPECT_CALL(*sys_wait_mock_, wait(_)) + .WillOnce([](std::int32_t* stat_loc) -> score::cpp::expected { + *stat_loc = kExitStatus; + return 1000; + }) + .WillRepeatedly(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))); + + // when + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + sut_.reset(); +} + +TEST_F(OsHandlerTest, WaitReturnsError_OsHandlerSleepsAndDoesNotCallFindTerminated) +{ + RecordProperty("Description", + "When sys_wait returns an error, OsHandler sleeps and does not call findTerminated."); + + // given — insert a callback that should NOT be invoked + StrictMock callback; + process_map_.insertIfNotTerminated(2000, &callback); + + EXPECT_CALL(*sys_wait_mock_, wait(_)) + .WillRepeatedly(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))); + + // when + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + sut_.reset(); + + // then — StrictMock ensures terminated() was never called +} + +TEST_F(OsHandlerTest, WaitReturnsZeroPid_OsHandlerSleepsAndDoesNotCallFindTerminated) +{ + RecordProperty("Description", + "When sys_wait returns pid 0, OsHandler sleeps and does not call findTerminated."); + + // given + StrictMock callback; + process_map_.insertIfNotTerminated(3000, &callback); + + EXPECT_CALL(*sys_wait_mock_, wait(_)).WillRepeatedly(Return(score::cpp::expected{0})); + + // when + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + sut_.reset(); + + // then — StrictMock ensures terminated() was never called +} + +TEST_F(OsHandlerTest, WaitReturnsProcessIdBeforeRegistration_LaterRegistrationReceivesStoredTermination) +{ + RecordProperty("Description", + "Covers the race where a child terminates before the process has been registered in " + "SafeProcessMap. OsHandler sees the pid first, stores the terminated state, and a later " + "insertIfNotTerminated call must immediately deliver the stored exit status to the callback."); + + // given + // Simulate the OS reporting that pid 4000 has already exited. + // At this point nothing has been inserted into SafeProcessMap yet. + std::atomic_bool first_wait_seen{false}; + + EXPECT_CALL(*sys_wait_mock_, wait(_)) + .WillOnce([&first_wait_seen](std::int32_t* stat_loc) -> score::cpp::expected { + *stat_loc = 99; + first_wait_seen.store(true); + return 4000; + }) + .WillRepeatedly(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))); + + // when + // Start OsHandler and wait until its background thread has observed that terminated pid. + // That should cause OsHandler to call SafeProcessMap::findTerminated(4000, 99), which stores + // "pid 4000 already terminated with status 99" because no callback is registered yet. + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + while (!first_wait_seen.load()) + { + std::this_thread::yield(); + } + // Allow OsHandler thread to complete findTerminated() after wait() returned. + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + // then + // Register the process after the termination has already been observed. + // insertIfNotTerminated must detect the previously stored termination, return 1, and invoke the + // callback immediately with the saved exit status instead of creating a new live entry. + StrictMock callback; + EXPECT_CALL(callback, terminated(99)).Times(1); + EXPECT_EQ(process_map_.insertIfNotTerminated(4000, &callback), 1); + + sut_.reset(); +} + +TEST_F(OsHandlerTest, WaitReturnsUnknownPidWhenMapIsFull_OutOfResourcesPathDoesNotNotifyCallbacks) +{ + RecordProperty("Description", + "When sys_wait reports an unknown pid and the map is full, OsHandler takes the out-of-resources " + "path without notifying tracked callbacks."); + + // given + StrictMock callbacks[kCapacity]; + for (uint32_t i = 0; i < kCapacity; ++i) + { + ASSERT_EQ(process_map_.insertIfNotTerminated(static_cast(i + 1U), &callbacks[i]), 0); + } + + EXPECT_CALL(*sys_wait_mock_, wait(_)) + .WillOnce([](std::int32_t* stat_loc) -> score::cpp::expected { + *stat_loc = 17; + return 9999; + }) + .WillRepeatedly(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))); + + // when + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + sut_.reset(); + + // then — StrictMock ensures no tracked callback was invoked +} + +TEST_F(OsHandlerTest, WaitReturnsErrorThenProcessId_HandlerRecoversAndInvokesCallback) +{ + RecordProperty("Description", + "When sys_wait first returns an error and then a valid pid, OsHandler resumes processing and " + "invokes the callback."); + + // given + NiceMock callback; + process_map_.insertIfNotTerminated(5000, &callback); + + EXPECT_CALL(callback, terminated(7)).Times(AtLeast(1)); + + EXPECT_CALL(*sys_wait_mock_, wait(_)) + .WillOnce(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))) + .WillOnce([](std::int32_t* stat_loc) -> score::cpp::expected { + *stat_loc = 7; + return 5000; + }) + .WillRepeatedly(Return(score::cpp::unexpected(score::os::Error::createFromErrno(ECHILD)))); + + // when + sut_ = std::make_unique(process_map_, *sys_wait_mock_); + std::this_thread::sleep_for(OS_HANDLER_LOOP_DELAY + std::chrono::milliseconds(50)); + sut_.reset(); +} + +} // namespace diff --git a/score/launch_manager/daemon/src/process_group_manager/details/process_group_manager.cpp b/score/launch_manager/daemon/src/process_group_manager/details/process_group_manager.cpp index 47dec5dd..013a2d8c 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/process_group_manager.cpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/process_group_manager.cpp @@ -264,7 +264,7 @@ bool ProcessGroupManager::run() // debug messages. << (static_cast(clock()) / (static_cast(CLOCKS_PER_SEC) / 1000.0)) << "ms"; - OsHandler os_handler(*process_map_, process_interface_); + OsHandler os_handler(*process_map_); bool result = startInitialTransition(); diff --git a/score/launch_manager/daemon/src/process_group_manager/details/process_info_node.hpp b/score/launch_manager/daemon/src/process_group_manager/details/process_info_node.hpp index 4a89444b..a04e6417 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/process_info_node.hpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/process_info_node.hpp @@ -16,6 +16,7 @@ #include #include "score/mw/launch_manager/configuration/configuration_manager.hpp" +#include "score/mw/launch_manager/process_group_manager/details/safe_process_map.hpp" #include "score/mw/launch_manager/control/control_client_channel.hpp" namespace score { @@ -41,7 +42,7 @@ using SuccessorList = std::vector>; /// the events kRunning received or expected process termination forming connecting edges. /// Unexpected termination of a process or reception of a timeout result in the failure of this graph. /// The ProcessInfoNode thus represents an adaptive process with specific startup configuration and dependencies -class ProcessInfoNode final { +class ProcessInfoNode final : public ITerminationCallback { public: /// Constructor for Process Infonode ProcessInfoNode() @@ -77,7 +78,7 @@ class ProcessInfoNode final { /// The process status is saved in the ProcessInfoNode /// This method will be called by the OsHander. /// @param process_status - the value returned by the OS for the process termination status - void terminated(int32_t process_status); + void terminated(int32_t process_status) override; /// @brief Called by a worker thread to perform the expected action for this node /// The action will either be to start the process, if graph_->isStarting() returns true, diff --git a/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.cpp b/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.cpp index fa7ca199..5b7b8933 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.cpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.cpp @@ -14,8 +14,8 @@ #include #include #include + #include "score/mw/launch_manager/process_group_manager/details/safe_process_map.hpp" -#include "score/mw/launch_manager/process_group_manager/details/process_info_node.hpp" namespace score { @@ -99,7 +99,7 @@ inline int32_t SafeProcessMap::removeNode(ProcessInfoData& target, ProcessInfoDa // found key. There are 4 situations: // data.pin_ == nullptr, stored pin_ != nullptr: normal findTerminated // data.pin_ != nullptr, stored pin_ == nullptr: normal insertIfNotTerminated - // both data.pin_ and stored pin_ point to a ProcessInfoNode: anomalous + // both data.pin_ and stored pin_ point to an ITerminationCallback: anomalous // both data.pin_ and stored pin_ are null: anomalous // In other words, exactly one of data.pin_ and stored pin_ must be nullptr // or there is an anomaly and we return -2 @@ -239,7 +239,7 @@ int32_t SafeProcessMap::findTerminated(osal::ProcessID key, int32_t status) { return search(key, {status, nullptr}); } -int32_t SafeProcessMap::insertIfNotTerminated(osal::ProcessID key, ProcessInfoNode* object) { +int32_t SafeProcessMap::insertIfNotTerminated(osal::ProcessID key, ITerminationCallback* object) { return search(key, {0, object}); } diff --git a/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.hpp b/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.hpp index 44cb2a1a..78c8ce5c 100644 --- a/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.hpp +++ b/score/launch_manager/daemon/src/process_group_manager/details/safe_process_map.hpp @@ -18,7 +18,6 @@ #include #include #include "score/mw/launch_manager/process_group_manager/iprocess.hpp" -#include "score/mw/launch_manager/process_group_manager/details/process_info_node.hpp" namespace score { @@ -26,10 +25,30 @@ namespace lcm { namespace internal { +/// @brief Callback interface for process termination notification. +/// +/// Decouples SafeProcessMap from concrete node types. Any object that needs to +/// be notified when a tracked process terminates implements this interface. +class ITerminationCallback { + public: + virtual ~ITerminationCallback() = default; + + /// @brief Called when the associated process has terminated. + /// @param process_status The exit status reported by the operating system. + virtual void terminated(int32_t process_status) = 0; + + protected: + ITerminationCallback() = default; + ITerminationCallback(const ITerminationCallback&) = default; + ITerminationCallback& operator=(const ITerminationCallback&) = default; + ITerminationCallback(ITerminationCallback&&) = default; + ITerminationCallback& operator=(ITerminationCallback&&) = default; +}; + /// @brief Struct representing data in a map item struct ProcessInfoData { - int32_t status_ = -1; ///< Exit status for process - ProcessInfoNode* pin_ = nullptr; ///< Pointer to the ProcessInfoNode associated with this item. + int32_t status_ = -1; ///< Exit status for process + ITerminationCallback* pin_ = nullptr; ///< Pointer to the termination callback associated with this item. }; /// @brief Struct representing an item in the map. struct ProcessTreeNode { @@ -39,7 +58,7 @@ struct ProcessTreeNode { ProcessInfoData data_; }; -/// @brief The SafeProcessMap class provides a thread-safe mapping of unique process IDs (ProcessID) to pointers of ProcessInfoNode objects. +/// @brief The SafeProcessMap class provides a thread-safe mapping of unique process IDs (ProcessID) to ITerminationCallback pointers. /// It ensures safe concurrent access and modification of the mapping, using atomic operations. class SafeProcessMap final { public: @@ -53,46 +72,46 @@ class SafeProcessMap final { /// @brief Finds a terminated process in the map. /// This method is called from OsHandler when a process terminates. It looks up the given process ID (key) - /// in the map and returns the associated ProcessInfoNode pointer if found. If the key is not found, - /// it is inserted in the map with the value "already_terminated" and that value is returned. + /// in the map and updates the process state accordingly. If the key is not found, it is inserted in the map + /// as already terminated. /// In the case of a clash due to PID re-use, this method yields until the situation is resolved. /// @param key The process ID to look for in the map. - /// @return 0 if the process was found and updated with the provided `pin_`, - /// 1 if the process was found and updated with the provided `object`, + /// @return 0 if the process ID was found and the registered callback was notified with `status`, + /// 1 if the process ID was not found and an already-terminated entry was inserted, /// -1 if an error occurred during insertion (e.g., out of memory), /// or -2 if the provided process ID (`key`) is not valid (< 0). int32_t findTerminated(osal::ProcessID key, int32_t status); /// @brief Inserts a process into the map if it has not already terminated. /// This method is called by a worker thread after starting a process. It attempts to insert the given process ID (key) - /// and its associated ProcessInfoNode pointer into the map, ensuring that the process is not already marked as terminated. + /// and its associated ITerminationCallback pointer into the map, ensuring that the process is not already marked as terminated. /// In the case of a clash due to PID re-use, this method yields until the situation is resolved. /// @param key The process ID to insert into the map. - /// @param object A pointer to the ProcessInfoNode associated with the process. + /// @param object A pointer to the ITerminationCallback associated with the process. /// @return 0 if the key (Process ID) was not found and a new entry was made, /// 1 if the key was found (indicating the process has terminated), and updated with the provided object, /// -1 if an error occurred during insertion (e.g., out of memory), /// or -2 if the provided process ID (`key`) is not valid ( < 0). - int32_t insertIfNotTerminated(osal::ProcessID key, ProcessInfoNode* object); + int32_t insertIfNotTerminated(osal::ProcessID key, ITerminationCallback* object); private: /// @brief Searches for a process with the given process ID (key) in the map. /// If found, updates or removes the entry based on provided conditions. /// If the provided process ID (key) is valid (> 0): /// - If the process ID (key) is found in the map: - /// - If `pin_` (ProcessInfoNode pointer) is not nullptr, uses it to set the return status in ProcessInfoNode, removes the key, and returns 0. - /// - If `pin_` is nullptr, uses the provided ProcessInfoNode pointer to set the stored status, removes the key, and returns 1. - /// - Behaviour under anamolous conditions (PID re-use where either both data.pin_ and stored pin_ are nullptr or both are not nullptr): + /// - If `pin_` (ITerminationCallback pointer) is not nullptr, uses it to set the return status via the callback, removes the key, and returns 0. + /// - If `pin_` is nullptr, uses the provided ITerminationCallback pointer to set the stored status, removes the key, and returns 1. + /// - Behaviour under anomalous conditions (PID re-use where either both data.pin_ and stored pin_ are nullptr or both are not nullptr): /// yield() and then repeat the operation. /// - If the process ID (key) is not found in the map: /// - Adds the key (`key`), `pin_`, and `status` to the map. /// - Returns -1 on failure to add (e.g., out of memory). /// @param key The process ID to search for or insert into the map. /// @param data The data to associate with the key - /// data.object A pointer to the ProcessInfoNode associated with the process. - /// data.status The status to set for the process if inserted. + /// data.pin_ A pointer to the ITerminationCallback associated with the process. + /// data.status_ The status to set for the process if inserted. /// @return 0 if the process was found and updated with the provided `pin_`, - /// 1 if the process was found and updated with the provided `object`, + /// 1 if the process was found and updated with the provided callback pointer in `data.pin_`, /// -1 if an error occurred during insertion (e.g., out of memory), /// or -2 if the provided process ID (`key`) is not valid ( < 0). int32_t search(osal::ProcessID key, ProcessInfoData data); diff --git a/score/launch_manager/daemon/src/process_group_manager/details/safeprocessmap_UT.cpp b/score/launch_manager/daemon/src/process_group_manager/details/safeprocessmap_UT.cpp new file mode 100644 index 00000000..68abf349 --- /dev/null +++ b/score/launch_manager/daemon/src/process_group_manager/details/safeprocessmap_UT.cpp @@ -0,0 +1,389 @@ +/******************************************************************************** + * Copyright (c) 2025 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 + +#include "score/mw/launch_manager/process_group_manager/details/safe_process_map.hpp" +#include "score/mw/launch_manager/common/constants.hpp" + +using namespace testing; +using namespace score::lcm::internal; + +namespace +{ + +constexpr uint32_t kCapacity = static_cast(ProcessLimits::kMaxProcesses); + +class MockTerminationCallback : public ITerminationCallback +{ + public: + MOCK_METHOD(void, terminated, (int32_t process_status), (override)); +}; + +class SafeProcessMapTest : public ::testing::Test +{ + protected: + void SetUp() override + { + RecordProperty("TestType", "interface-test"); + RecordProperty("DerivationTechnique", "explorative-testing"); + } + + SafeProcessMap sut_{kCapacity}; + MockTerminationCallback callback_; +}; + +// --- Construction --- + +TEST_F(SafeProcessMapTest, ConstructWithZeroCapacity) +{ + RecordProperty("Description", "SafeProcessMap can be constructed with zero capacity."); + + // when + SafeProcessMap map(0); +} + +// --- findTerminated --- + +TEST_F(SafeProcessMapTest, FindTerminatedWithNegativePidReturnsInvalid) +{ + RecordProperty("Description", "findTerminated returns -2 for a negative process ID."); + + // when + int32_t result = sut_.findTerminated(-1, 1000); + + // then + EXPECT_EQ(result, -2); +} + +TEST_F(SafeProcessMapTest, FindTerminatedInsertsEntryWhenPidNotPresent) +{ + RecordProperty("Description", "findTerminated inserts an entry and returns 1 when the PID is not in the map."); + + // when + int32_t result = sut_.findTerminated(1000, 0); + + // then + EXPECT_EQ(result, 1); +} + +TEST_F(SafeProcessMapTest, FindTerminatedMatchesExistingInsertAndCallsCallback) +{ + RecordProperty("Description", + "findTerminated matches an existing insert entry and invokes the termination callback."); + + // given + sut_.insertIfNotTerminated(1000, &callback_); + + // then + EXPECT_CALL(callback_, terminated(42)); + + // when + int32_t result = sut_.findTerminated(1000, 42); + EXPECT_EQ(result, 0); +} + +// --- insertIfNotTerminated --- + +TEST_F(SafeProcessMapTest, InsertIntoEmptyTreeReturnsZero) +{ + RecordProperty("Description", "insertIfNotTerminated returns 0 when inserting into an empty tree."); + + // when + int32_t result = sut_.insertIfNotTerminated(2000, &callback_); + + // then + EXPECT_EQ(result, 0); +} + +TEST_F(SafeProcessMapTest, InsertMatchesExistingFindTerminatedEntry) +{ + RecordProperty("Description", + "insertIfNotTerminated returns 1 when matching an entry previously added by findTerminated."); + + // given + sut_.findTerminated(1000, 0); + + EXPECT_CALL(callback_, terminated(0)); + + // when + int32_t result = sut_.insertIfNotTerminated(1000, &callback_); + + // then + EXPECT_EQ(result, 1); +} + +TEST_F(SafeProcessMapTest, InsertMultipleNodesThenFindTerminatedRemovesAll) +{ + RecordProperty("Description", + "Inserting kMaxProcesses nodes and then calling findTerminated for each returns 0."); + + // given + NiceMock callbacks[kCapacity]; + for (uint32_t i = 1; i <= kCapacity; ++i) + { + sut_.insertIfNotTerminated(static_cast(i), &callbacks[i - 1]); + } + + // when / then + for (uint32_t j = 1; j <= kCapacity; ++j) + { + EXPECT_EQ(sut_.findTerminated(static_cast(j), 0), 0); + } +} + +TEST_F(SafeProcessMapTest, InsertBeyondCapacityReturnsOutOfMemory) +{ + RecordProperty("Description", + "insertIfNotTerminated returns -1 when the map is full and a new entry is attempted."); + + // given + NiceMock callbacks[kCapacity]; + for (uint32_t i = 0; i < kCapacity; ++i) + { + EXPECT_EQ(sut_.insertIfNotTerminated(static_cast(i), &callbacks[i]), 0); + } + + // when + int32_t result = sut_.insertIfNotTerminated(static_cast(kCapacity + 1), &callback_); + + // then + EXPECT_EQ(result, -1); +} + +// --- Anomalous (PID reuse) cases --- + +TEST_F(SafeProcessMapTest, InsertSamePidTwiceYieldsUntilFindTerminatedResolves) +{ + RecordProperty("Description", + "Inserting the same PID twice causes the second insert to yield until findTerminated resolves it."); + + // given + std::atomic_bool first_done{false}; + int32_t ret1 = 2; + int32_t ret2 = 2; + + NiceMock cb; + + std::thread inserter([&]() { + ret1 = sut_.insertIfNotTerminated(42, &cb); + first_done.store(true); + ret2 = sut_.insertIfNotTerminated(42, &cb); + }); + + // when — wait for first insert to complete + while (!first_done) + std::this_thread::yield(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + // then — first succeeded, second is still blocked + EXPECT_EQ(ret1, 0); + EXPECT_EQ(ret2, 2); + + // when — resolve the anomaly + EXPECT_EQ(sut_.findTerminated(42, 0), 0); + inserter.join(); + + // then + EXPECT_EQ(ret2, 0); +} + +TEST_F(SafeProcessMapTest, FindTerminatedSamePidTwiceYieldsUntilInsertResolves) +{ + RecordProperty("Description", + "Calling findTerminated twice with the same PID causes the second call to yield " + "until insertIfNotTerminated resolves it."); + + // given + std::atomic_bool first_done{false}; + int32_t ret1 = 2; + int32_t ret2 = 2; + + NiceMock cb; + + std::thread finder([&]() { + ret1 = sut_.findTerminated(42, 0); + first_done.store(true); + ret2 = sut_.findTerminated(42, 0); + }); + + // when — wait for first find to complete + while (!first_done) + std::this_thread::yield(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + // then — first succeeded, second is still blocked + EXPECT_EQ(ret1, 1); + EXPECT_EQ(ret2, 2); + + // when — resolve the anomaly + EXPECT_EQ(sut_.insertIfNotTerminated(42, &cb), 1); + finder.join(); + + // then + EXPECT_EQ(ret2, 1); +} + +// --- Max depth tree --- + +TEST_F(SafeProcessMapTest, FindTerminatedWorksAtMaxTreeDepth) +{ + RecordProperty("Description", "The binary tree handles maximum depth correctly."); + + // given — build a deep tree using bit patterns that always branch one way + EXPECT_EQ(sut_.findTerminated(0x00000000, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00000001, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00000002, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00000003, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00000007, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000000F, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000001F, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000003F, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000007F, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x000000FF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x000001FF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x000003FF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x000007FF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00000FFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00001FFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00003FFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00007FFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000FFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0000FFFE, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0001FFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0003FFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0007FFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x000FFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x001FFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x003FFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x007FFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x00FFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x01FFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x03FFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x07FFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x0FFFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x1FFFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x3FFFFFFF, 0), 1); + EXPECT_EQ(sut_.findTerminated(0x7FFFFFFF, 0), 1); + + // when / then — boundary values + EXPECT_EQ(sut_.findTerminated(static_cast(0xFFFFFFFF), 0), -2); + EXPECT_EQ(sut_.insertIfNotTerminated(static_cast(0xFFFFFFFF), &callback_), -2); + + // when / then — retrieve entries using insertIfNotTerminated + NiceMock cb; + EXPECT_EQ(sut_.insertIfNotTerminated(0x0000FFFE, &cb), 1); + EXPECT_EQ(sut_.insertIfNotTerminated(0x00010000, &cb), 0); + EXPECT_EQ(sut_.insertIfNotTerminated(0x0001FFFF, &cb), 1); + EXPECT_EQ(sut_.insertIfNotTerminated(0x00000002, &cb), 1); +} + +// --- Multi-threaded stress tests --- + +TEST_F(SafeProcessMapTest, ConcurrentInsertAndFindFromMultipleThreads) +{ + RecordProperty("Description", + "Multiple threads concurrently inserting and finding terminated processes completes without error."); + + // given + constexpr int kNumThreads = 4; + constexpr int kIterations = 1000; + constexpr int kPidsPerThread = 256; + NiceMock stubs[kNumThreads]; + int results[kNumThreads] = {}; + + // when + std::vector threads; + threads.reserve(kNumThreads); + for (int t = 0; t < kNumThreads; ++t) + { + threads.emplace_back([&, t]() { + int base = 1000 + t * kPidsPerThread; + for (int j = 0; j < kIterations; ++j) + { + for (int i = 0; i < kPidsPerThread; ++i) + { + results[t] = sut_.insertIfNotTerminated(base + i, &stubs[t]); + } + for (int i = 0; i < kPidsPerThread; ++i) + { + sut_.findTerminated(base + i, 0); + } + } + }); + } + + for (auto& thread : threads) + { + thread.join(); + } + + // then + for (int t = 0; t < kNumThreads; ++t) + { + EXPECT_EQ(results[t], 0); + } +} + +TEST_F(SafeProcessMapTest, ConcurrentFindAndInsertFromMultipleThreads) +{ + RecordProperty("Description", + "Multiple threads concurrently finding and inserting processes completes without error."); + + // given + constexpr int kNumThreads = 4; + constexpr int kIterations = 1000; + constexpr int kPidsPerThread = 256; + NiceMock stubs[kNumThreads]; + int results[kNumThreads] = {}; + + // when + std::vector threads; + threads.reserve(kNumThreads); + for (int t = 0; t < kNumThreads; ++t) + { + threads.emplace_back([&, t]() { + int base = 1000 + t * kPidsPerThread; + for (int j = 0; j < kIterations; ++j) + { + for (int i = 0; i < kPidsPerThread; ++i) + { + results[t] = sut_.findTerminated(base + i, 0); + } + for (int i = 0; i < kPidsPerThread; ++i) + { + sut_.insertIfNotTerminated(base + i, &stubs[t]); + } + } + }); + } + + for (auto& thread : threads) + { + thread.join(); + } + + // then + for (int t = 0; t < kNumThreads; ++t) + { + EXPECT_EQ(results[t], 1); + } +} + +} // namespace