From 78effa8172bdd6e8200ee781b0827bedc28a841f Mon Sep 17 00:00:00 2001 From: madanagopalt Date: Tue, 11 Aug 2026 10:08:34 +0530 Subject: [PATCH] RDKEMW-21185: Add exitcode on container stopped event * RDKEMW-21185: Add exitcode on container stopped event * RDKEMW-21185: Add exitcode on container stopped event * Adding tests for container stopped events * Adding tests for container stopped events --- AppInfrastructure/Public/Dobby/IDobbyProxy.h | 8 +- client/lib/include/DobbyProxy.h | 15 +- client/lib/source/DobbyProxy.cpp | 101 +++- tests/L1_testing/mocks/IIpcService.h | 2 + tests/L1_testing/mocks/IIpcServiceMock.cpp | 7 + tests/L1_testing/mocks/IIpcServiceMock.h | 1 + tests/L1_testing/tests/CMakeLists.txt | 1 + .../tests/DobbyProxyTest/CMakeLists.txt | 72 +++ .../tests/DobbyProxyTest/DobbyProxyTests.cpp | 486 ++++++++++++++++++ .../dobby_specs/exit_with_code.json | 29 ++ .../L2_testing/test_runner/exit_code_tests.py | 341 ++++++++++++ tests/L2_testing/test_runner/runner.py | 4 +- 12 files changed, 1050 insertions(+), 17 deletions(-) create mode 100644 tests/L1_testing/tests/DobbyProxyTest/CMakeLists.txt create mode 100644 tests/L1_testing/tests/DobbyProxyTest/DobbyProxyTests.cpp create mode 100644 tests/L2_testing/dobby_specs/exit_with_code.json create mode 100644 tests/L2_testing/test_runner/exit_code_tests.py diff --git a/AppInfrastructure/Public/Dobby/IDobbyProxy.h b/AppInfrastructure/Public/Dobby/IDobbyProxy.h index 13b3c851..6369711c 100644 --- a/AppInfrastructure/Public/Dobby/IDobbyProxy.h +++ b/AppInfrastructure/Public/Dobby/IDobbyProxy.h @@ -192,12 +192,18 @@ class IDobbyProxy : public AICommon::Notifier } public: + // Listener for STOPPED events — no exit code, backward-compatible. typedef std::function StateChangeListener; - virtual int registerListener(const StateChangeListener &listener, const void* cbParams) = 0; + // Listener for STOPPED_WITH_STATUS events — includes exit code. + typedef std::function StateChangeWithStatusListener; + virtual int registerListener(const StateChangeListener &listener, const void* cbParams) = 0; virtual void unregisterListener(int tag) = 0; + virtual int registerListenerWithStatus(const StateChangeWithStatusListener &listener, const void* cbParams) = 0; + virtual void unregisterListenerWithStatus(int tag) = 0; + #if (AI_BUILD_TYPE == AI_DEBUG) diff --git a/client/lib/include/DobbyProxy.h b/client/lib/include/DobbyProxy.h index 72fa0a24..6885750b 100644 --- a/client/lib/include/DobbyProxy.h +++ b/client/lib/include/DobbyProxy.h @@ -125,8 +125,10 @@ class DobbyProxy : public IDobbyProxy int getContainerState(int32_t cd) const override; int registerListener(const StateChangeListener &listener, const void* cbParams) override; + void unregisterListener(int id) override; - void unregisterListener(int tag) override; + int registerListenerWithStatus(const StateChangeWithStatusListener &listener, const void* cbParams) override; + void unregisterListenerWithStatus(int id) override; std::string getContainerInfo(int32_t descriptor) const override; @@ -155,6 +157,7 @@ class DobbyProxy : public IDobbyProxy private: void onContainerStartedEvent(const AI_IPC::VariantList& args); void onContainerStoppedEvent(const AI_IPC::VariantList& args); + void onContainerStoppedWithStatusEvent(const AI_IPC::VariantList& args); void onContainerHibernatedEvent(const AI_IPC::VariantList& args); void onContainerAwokenEvent(const AI_IPC::VariantList& args); @@ -173,6 +176,7 @@ class DobbyProxy : public IDobbyProxy private: std::string mContainerStartedSignal; std::string mContainerStoppedSignal; + std::string mContainerStoppedWithStatusSignal; private: std::thread mStateChangeThread; @@ -181,19 +185,20 @@ class DobbyProxy : public IDobbyProxy struct StateChangeEvent { - enum Type { Terminate, ContainerStarted, ContainerStopped, ContainerHibernated, ContainerAwoken }; + enum Type { Terminate, ContainerStarted, ContainerStopped, ContainerStoppedWithStatus, ContainerHibernated, ContainerAwoken }; explicit StateChangeEvent(Type type_) : type(type_), descriptor(-1) { } StateChangeEvent(Type type_, int32_t descriptor_, const std::string& name_) - : type(type_), descriptor(descriptor_), name(name_) + : type(type_), descriptor(descriptor_), name(name_), exitCode(-1) { } Type type; int32_t descriptor; std::string name; + int32_t exitCode; }; std::deque mStateChangeQueue; @@ -202,6 +207,10 @@ class DobbyProxy : public IDobbyProxy AICommon::IDGenerator<8> mListenerIdGen; std::map> mListeners; + std::mutex mStatusListenersLock; + AICommon::IDGenerator<8> mStatusListenerIdGen; + std::map> mStatusListeners; + }; diff --git a/client/lib/source/DobbyProxy.cpp b/client/lib/source/DobbyProxy.cpp index a98ce860..b5619280 100644 --- a/client/lib/source/DobbyProxy.cpp +++ b/client/lib/source/DobbyProxy.cpp @@ -27,6 +27,7 @@ #include +#include #include @@ -89,10 +90,16 @@ DobbyProxy::DobbyProxy(const std::shared_ptr& ipcService, const AI_IPC::SignalHandler startedHandler(std::bind(&DobbyProxy::onContainerStartedEvent, this, std::placeholders::_1)); mContainerStartedSignal = mIpcService->registerSignalHandler(startedSignal, startedHandler); + // Subscribe to plain STOPPED to serve StateChangeListener clients (no exit code). const AI_IPC::Signal stoppedSignal(objectName, DOBBY_CTRL_INTERFACE, DOBBY_CTRL_EVENT_STOPPED); const AI_IPC::SignalHandler stoppedHandler(std::bind(&DobbyProxy::onContainerStoppedEvent, this, std::placeholders::_1)); mContainerStoppedSignal = mIpcService->registerSignalHandler(stoppedSignal, stoppedHandler); + // Subscribe to STOPPED_WITH_STATUS to serve StateChangeWithStatusListener clients (with exit code). + const AI_IPC::Signal stoppedWithStatusSignal(objectName, DOBBY_CTRL_INTERFACE, DOBBY_CTRL_EVENT_STOPPED_WITH_STATUS); + const AI_IPC::SignalHandler stoppedWithStatusHandler(std::bind(&DobbyProxy::onContainerStoppedWithStatusEvent, this, std::placeholders::_1)); + mContainerStoppedWithStatusSignal = mIpcService->registerSignalHandler(stoppedWithStatusSignal, stoppedWithStatusHandler); + const AI_IPC::Signal hibernatedSignal(objectName, DOBBY_CTRL_INTERFACE, DOBBY_CTRL_EVENT_HIBERNATED); const AI_IPC::SignalHandler hibernatedHandler(std::bind(&DobbyProxy::onContainerHibernatedEvent, this, std::placeholders::_1)); mContainerStartedSignal = mIpcService->registerSignalHandler(hibernatedSignal, hibernatedHandler); @@ -101,7 +108,7 @@ DobbyProxy::DobbyProxy(const std::shared_ptr& ipcService, const AI_IPC::SignalHandler awokenHandler(std::bind(&DobbyProxy::onContainerAwokenEvent, this, std::placeholders::_1)); mContainerStartedSignal = mIpcService->registerSignalHandler(awokenSignal, awokenHandler); - if (mContainerStartedSignal.empty() || mContainerStoppedSignal.empty()) + if (mContainerStartedSignal.empty() || mContainerStoppedSignal.empty() || mContainerStoppedWithStatusSignal.empty()) { AI_LOG_ERROR("failed to register dbus signal listeners"); } @@ -127,6 +134,9 @@ DobbyProxy::~DobbyProxy() if (!mContainerStoppedSignal.empty()) mIpcService->unregisterHandler(mContainerStoppedSignal); + if (!mContainerStoppedWithStatusSignal.empty()) + mIpcService->unregisterHandler(mContainerStoppedWithStatusSignal); + // flush the ipc service to guarantee the signal handlers aren't going to // be called after we're done mIpcService->flush(); @@ -196,6 +206,36 @@ void DobbyProxy::unregisterListener(int id) mListenerIdGen.put(id); } +int DobbyProxy::registerListenerWithStatus(const StateChangeWithStatusListener &listener, const void* cbParams) +{ + std::lock_guard locker(mStatusListenersLock); + + int id = mStatusListenerIdGen.get(); + if (id < 0) + { + AI_LOG_ERROR("too many status listeners installed"); + return -1; + } + + mStatusListeners.emplace(id, std::make_pair(listener, cbParams)); + return id; +} + +void DobbyProxy::unregisterListenerWithStatus(int id) +{ + std::lock_guard locker(mStatusListenersLock); + + auto it = mStatusListeners.find(id); + if (it == mStatusListeners.end()) + { + AI_LOG_ERROR("no status listener installed with id %d", id); + return; + } + + mStatusListeners.erase(it); + mStatusListenerIdGen.put(id); +} + // ----------------------------------------------------------------------------- /** * @brief Called when a org.rdk.dobby.ctrl1.Started event is received from @@ -244,7 +284,7 @@ void DobbyProxy::onContainerStoppedEvent(const AI_IPC::VariantList& args) { AI_LOG_FN_ENTRY(); - // the event should container two args; container descriptor and id + // STOPPED carries two args: container descriptor and id (no exit code). int32_t descriptor; std::string id; @@ -255,7 +295,6 @@ void DobbyProxy::onContainerStoppedEvent(const AI_IPC::VariantList& args) } else { - // ping off an event std::lock_guard locker(mStateChangeLock); mStateChangeQueue.emplace_back(StateChangeEvent::ContainerStopped, descriptor, id); mStateChangeCond.notify_all(); @@ -264,6 +303,33 @@ void DobbyProxy::onContainerStoppedEvent(const AI_IPC::VariantList& args) AI_LOG_FN_EXIT(); } +void DobbyProxy::onContainerStoppedWithStatusEvent(const AI_IPC::VariantList& args) +{ + AI_LOG_FN_ENTRY(); + + // STOPPED_WITH_STATUS carries three args: descriptor, id, raw waitpid status. + int32_t descriptor; + std::string id; + int32_t rawStatus; + + if (!AI_IPC::parseVariantList(args, &descriptor, &id, &rawStatus)) + { + AI_LOG_ERROR("failed to read all args from %s.%s signal", + DOBBY_CTRL_INTERFACE, DOBBY_CTRL_EVENT_STOPPED_WITH_STATUS); + } + else + { + StateChangeEvent ev(StateChangeEvent::ContainerStoppedWithStatus, descriptor, id); + ev.exitCode = WIFEXITED(rawStatus) ? WEXITSTATUS(rawStatus) : -1; + + std::lock_guard locker(mStateChangeLock); + mStateChangeQueue.push_back(ev); + mStateChangeCond.notify_all(); + } + + AI_LOG_FN_EXIT(); +} + // ----------------------------------------------------------------------------- /** * @brief Called when a org.rdk.dobby.ctrl1.Hibernated event is received from @@ -1498,16 +1564,27 @@ void DobbyProxy::containerStateChangeThread() notify(&IDobbyProxyEvents::containerStateChanged, event.descriptor, event.name, state); - // need to hold the lock before searching - std::lock_guard listenerLocker(mListenersLock); - - // check if we have any listener interested in this service - for (const std::pair>& handler : mListeners) + if (event.type == StateChangeEvent::ContainerStoppedWithStatus) + { + // Notify status listeners (include exit code) + std::lock_guard listenerLocker(mStatusListenersLock); + for (const auto& handler : mStatusListeners) + { + const StateChangeWithStatusListener& cb = handler.second.first; + if (cb) + cb(event.descriptor, event.name, state, event.exitCode, handler.second.second); + } + } + else { - const StateChangeListener& callback = handler.second.first; - const void* cbParams = handler.second.second; - if (callback) - callback(event.descriptor, event.name, state, cbParams); + // Notify standard listeners (no exit code) + std::lock_guard listenerLocker(mListenersLock); + for (const auto& handler : mListeners) + { + const StateChangeListener& cb = handler.second.first; + if (cb) + cb(event.descriptor, event.name, state, handler.second.second); + } } } diff --git a/tests/L1_testing/mocks/IIpcService.h b/tests/L1_testing/mocks/IIpcService.h index 73a1e450..3786c883 100644 --- a/tests/L1_testing/mocks/IIpcService.h +++ b/tests/L1_testing/mocks/IIpcService.h @@ -39,6 +39,7 @@ class IIpcServiceImpl { virtual std::string registerSignalHandler(const Signal& signal, const SignalHandler& handler) = 0; virtual bool unregisterHandler(const std::string& regId) = 0; virtual bool enableMonitor(const std::set& matchRules, const MonitorHandler& handler) = 0; + virtual bool isServiceAvailable(const std::string& serviceName) const = 0; virtual void flush() = 0; }; class IIpcService { @@ -57,6 +58,7 @@ static IIpcServiceImpl* impl; std::string registerSignalHandler(const Signal& signal, const SignalHandler& handler); bool unregisterHandler(const std::string& regId); bool enableMonitor(const std::set& matchRules, const MonitorHandler& handler); + bool isServiceAvailable(const std::string& serviceName) const; void flush(); }; diff --git a/tests/L1_testing/mocks/IIpcServiceMock.cpp b/tests/L1_testing/mocks/IIpcServiceMock.cpp index 6177d249..f7fa552d 100755 --- a/tests/L1_testing/mocks/IIpcServiceMock.cpp +++ b/tests/L1_testing/mocks/IIpcServiceMock.cpp @@ -81,6 +81,13 @@ bool AI_IPC::IIpcService::enableMonitor(const std::set& matchRules, return impl->enableMonitor(matchRules, handler); } +bool AI_IPC::IIpcService::isServiceAvailable(const std::string& serviceName) const +{ + EXPECT_NE(impl, nullptr); + + return impl->isServiceAvailable(serviceName); +} + void AI_IPC::IIpcService::flush() { EXPECT_NE(impl, nullptr); diff --git a/tests/L1_testing/mocks/IIpcServiceMock.h b/tests/L1_testing/mocks/IIpcServiceMock.h index c0676957..007a7515 100644 --- a/tests/L1_testing/mocks/IIpcServiceMock.h +++ b/tests/L1_testing/mocks/IIpcServiceMock.h @@ -37,6 +37,7 @@ class IpcServiceMock : public IIpcServiceImpl { MOCK_METHOD(std::string, registerSignalHandler, (const Signal& signal, const SignalHandler& handler), (override)); MOCK_METHOD(bool, unregisterHandler, (const std::string& regId), (override)); MOCK_METHOD(bool, enableMonitor, (const std::set& matchRules, const MonitorHandler& handler), (override)); + MOCK_METHOD(bool, isServiceAvailable, (const std::string& serviceName), (const, override)); MOCK_METHOD(void, flush, (), (override)); }; diff --git a/tests/L1_testing/tests/CMakeLists.txt b/tests/L1_testing/tests/CMakeLists.txt index b30693b9..a9b7c3ce 100644 --- a/tests/L1_testing/tests/CMakeLists.txt +++ b/tests/L1_testing/tests/CMakeLists.txt @@ -19,4 +19,5 @@ add_subdirectory(DobbyUtilsTest) add_subdirectory(DobbyTest) add_subdirectory(DobbyManagerTest) add_subdirectory(DobbySpecConfigTest) +add_subdirectory(DobbyProxyTest) diff --git a/tests/L1_testing/tests/DobbyProxyTest/CMakeLists.txt b/tests/L1_testing/tests/DobbyProxyTest/CMakeLists.txt new file mode 100644 index 00000000..ccf26cce --- /dev/null +++ b/tests/L1_testing/tests/DobbyProxyTest/CMakeLists.txt @@ -0,0 +1,72 @@ +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2024 Sky UK +# +# 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. + +cmake_minimum_required(VERSION 3.7) +project(DobbyProxyL1Test) + +set(CMAKE_CXX_STANDARD 14) + +find_package(GTest REQUIRED) + +include_directories(${GTEST_INCLUDE_DIRS}) + +add_library(DobbyProxyTestLib STATIC + ../../../../client/lib/source/DobbyProxy.cpp + ../../../../AppInfrastructure/Logging/source/Logging.cpp + ../../mocks/IIpcServiceMock.cpp + ../../mocks/IpcVariantList.cpp + ../../mocks/IpcFileDescriptorMock.cpp +) + +target_include_directories(DobbyProxyTestLib + PUBLIC + ../../mocks + ../../../../client/lib/include + ../../../../AppInfrastructure/Public + ../../../../AppInfrastructure/Common/include + ../../../../AppInfrastructure/Logging/include + ../../../../AppInfrastructure/IpcService/include + ../../../../protocol/include +) + +file(GLOB TESTS *.cpp + ../../mocks/IpcVariantList.cpp +) + +add_executable(${PROJECT_NAME} ${TESTS}) + +target_include_directories(${PROJECT_NAME} + PRIVATE + ../../mocks + ../../../../client/lib/include + ../../../../AppInfrastructure/Public + ../../../../AppInfrastructure/Common/include + ../../../../AppInfrastructure/Logging/include + ../../../../AppInfrastructure/IpcService/include + ../../../../protocol/include +) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + DobbyProxyTestLib + GTest::gmock + GTest::GTest + GTest::Main + pthread +) + +install(TARGETS ${PROJECT_NAME} DESTINATION bin) diff --git a/tests/L1_testing/tests/DobbyProxyTest/DobbyProxyTests.cpp b/tests/L1_testing/tests/DobbyProxyTest/DobbyProxyTests.cpp new file mode 100644 index 00000000..19469a5b --- /dev/null +++ b/tests/L1_testing/tests/DobbyProxyTest/DobbyProxyTests.cpp @@ -0,0 +1,486 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2024 Sky UK + * + * 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. + */ + +/** + * @file DobbyProxyTests.cpp + * + * L1 Unit tests for RDKEMW-21185: Add exitcode on container stopped event. + * + * Covers: + * - registerListenerWithStatus / unregisterListenerWithStatus lifecycle + * - onContainerStoppedWithStatusEvent: correct exit-code extraction via + * WIFEXITED / WEXITSTATUS, and graceful handling of malformed args + * - containerStateChangeThread dispatch: status listeners receive exit code + * for ContainerStoppedWithStatus events; standard listeners do NOT + * - Standard ContainerStopped events go to standard listeners only + */ + +#include "IIpcServiceMock.h" +// Expose private members so we can call onContainerStoppedWithStatusEvent() +// and inspect internal fields directly — the same technique used in +// DaemonDobbyTests.cpp. +#define private public +#include "DobbyProxy.h" +#undef private + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Static impl pointer definitions required by the IIpcService, IAsyncReplySender +// and IpcFileDescriptor shims used across the mock TUs linked into this test. +// --------------------------------------------------------------------------- +AI_IPC::IIpcServiceImpl* AI_IPC::IIpcService::impl = nullptr; +AI_IPC::IAsyncReplySenderApiImpl* AI_IPC::IAsyncReplySender::impl = nullptr; +AI_IPC::IpcFileDescriptorApiImpl* AI_IPC::IpcFileDescriptor::impl = nullptr; + +// --------------------------------------------------------------------------- +// Helper: build an AI_IPC::VariantList from (int32, string, int32) +// --------------------------------------------------------------------------- +static AI_IPC::VariantList makeArgs(int32_t descriptor, + const std::string& id, + int32_t rawStatus) +{ + return AI_IPC::VariantList{ + AI_IPC::Variant(descriptor), + AI_IPC::Variant(id), + AI_IPC::Variant(rawStatus) + }; +} + +// Build args with only 2 items (missing rawStatus) to trigger parse failure +static AI_IPC::VariantList makeIncompleteArgs(int32_t descriptor, + const std::string& id) +{ + return AI_IPC::VariantList{ + AI_IPC::Variant(descriptor), + AI_IPC::Variant(id) + }; +} + +// --------------------------------------------------------------------------- +// Test fixture +// --------------------------------------------------------------------------- +class DobbyProxyTest : public ::testing::Test +{ +protected: + AI_IPC::IpcServiceMock* mIpcServiceMock = nullptr; + AI_IPC::IIpcService* mIpcService = nullptr; + std::unique_ptr mProxy; + + void SetUp() override + { + mIpcServiceMock = new AI_IPC::IpcServiceMock(); + AI_IPC::IIpcService::setImpl(mIpcServiceMock); + mIpcService = new AI_IPC::IIpcService(); + + // DobbyProxy constructor registers several signal handlers and checks + // that none of the returned registration IDs are empty. Return a + // non-empty token for every call so the error branch is not hit. + ON_CALL(*mIpcServiceMock, registerSignalHandler(::testing::_, ::testing::_)) + .WillByDefault(::testing::Return("reg-token")); + + // flush() is called in the destructor — allow it unconditionally. + ON_CALL(*mIpcServiceMock, flush()) + .WillByDefault(::testing::Return()); + + // unregisterHandler() is called in the destructor for each registered + // signal. Allow them all. + ON_CALL(*mIpcServiceMock, unregisterHandler(::testing::_)) + .WillByDefault(::testing::Return(true)); + + // isServiceAvailable() is called by isAlive() — default to true so + // tests that don't exercise isAlive() don't block. + ON_CALL(*mIpcServiceMock, isServiceAvailable(::testing::_)) + .WillByDefault(::testing::Return(true)); + + mProxy = std::make_unique( + std::shared_ptr(mIpcService, [](auto*){}), + "org.rdk.dobby", + "/org/rdk/dobby/ctrl1"); + } + + void TearDown() override + { + mProxy.reset(); + // IIpcService is owned by the shared_ptr with a no-op deleter above; + // delete it manually. + delete mIpcService; + AI_IPC::IIpcService::setImpl(nullptr); + delete mIpcServiceMock; + } +}; + +// =========================================================================== +// registerListenerWithStatus +// =========================================================================== + +/** + * @test registerListenerWithStatus_ReturnsNonNegativeId + * + * Registering a valid status listener must return an ID >= 0. + */ +TEST_F(DobbyProxyTest, registerListenerWithStatus_ReturnsNonNegativeId) +{ + IDobbyProxy::StateChangeWithStatusListener listener = + [](int32_t, const std::string&, IDobbyProxyEvents::ContainerState, + int32_t, const void*) {}; + + int id = mProxy->registerListenerWithStatus(listener, nullptr); + EXPECT_GE(id, 0); + + mProxy->unregisterListenerWithStatus(id); +} + +/** + * @test registerListenerWithStatus_ReturnsUniqueIds + * + * Two successive registrations must receive different IDs. + */ +TEST_F(DobbyProxyTest, registerListenerWithStatus_ReturnsUniqueIds) +{ + IDobbyProxy::StateChangeWithStatusListener listener = + [](int32_t, const std::string&, IDobbyProxyEvents::ContainerState, + int32_t, const void*) {}; + + int id1 = mProxy->registerListenerWithStatus(listener, nullptr); + int id2 = mProxy->registerListenerWithStatus(listener, nullptr); + + EXPECT_GE(id1, 0); + EXPECT_GE(id2, 0); + EXPECT_NE(id1, id2); + + mProxy->unregisterListenerWithStatus(id1); + mProxy->unregisterListenerWithStatus(id2); +} + +// =========================================================================== +// unregisterListenerWithStatus +// =========================================================================== + +/** + * @test unregisterListenerWithStatus_RemovesListener + * + * After unregistering, the listener must not be called when a + * ContainerStoppedWithStatus event is dispatched. + */ +TEST_F(DobbyProxyTest, unregisterListenerWithStatus_RemovesListener) +{ + std::atomic callCount{0}; + + IDobbyProxy::StateChangeWithStatusListener listener = + [&callCount](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void*) { ++callCount; }; + + int id = mProxy->registerListenerWithStatus(listener, nullptr); + ASSERT_GE(id, 0); + mProxy->unregisterListenerWithStatus(id); + + // Fire a StoppedWithStatus event directly + int rawStatus = W_EXITCODE(42, 0); // WIFEXITED true, WEXITSTATUS 42 + mProxy->onContainerStoppedWithStatusEvent(makeArgs(10, "myapp", rawStatus)); + + // Give the state-change thread a moment to drain the queue + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + EXPECT_EQ(callCount.load(), 0) + << "Listener should not be called after unregister"; +} + +/** + * @test unregisterListenerWithStatus_InvalidId_DoesNotCrash + * + * Calling unregisterListenerWithStatus with an ID that was never registered + * must not crash (the implementation logs an error and returns). + */ +TEST_F(DobbyProxyTest, unregisterListenerWithStatus_InvalidId_DoesNotCrash) +{ + EXPECT_NO_FATAL_FAILURE(mProxy->unregisterListenerWithStatus(9999)); +} + +// =========================================================================== +// onContainerStoppedWithStatusEvent — argument parsing +// =========================================================================== + +/** + * @test onContainerStoppedWithStatusEvent_NormalExit_ExitCodeExtracted + * + * When the raw waitpid status represents a normal exit (WIFEXITED) with + * exit code N, the listener must receive exitCode == N. + */ +TEST_F(DobbyProxyTest, onContainerStoppedWithStatusEvent_NormalExit_ExitCodeExtracted) +{ + constexpr int32_t kDescriptor = 7; + const std::string kName = "youtube"; + constexpr int kExitValue = 42; + int32_t rawStatus = W_EXITCODE(kExitValue, 0); // normal exit + + std::atomic called{false}; + int32_t capturedExitCode = -999; + int32_t capturedDescriptor = -1; + std::string capturedName; + + IDobbyProxy::StateChangeWithStatusListener listener = + [&](int32_t desc, const std::string& name, + IDobbyProxyEvents::ContainerState, + int32_t exitCode, const void*) + { + capturedDescriptor = desc; + capturedName = name; + capturedExitCode = exitCode; + called = true; + }; + + int id = mProxy->registerListenerWithStatus(listener, nullptr); + ASSERT_GE(id, 0); + + mProxy->onContainerStoppedWithStatusEvent( + makeArgs(kDescriptor, kName, rawStatus)); + + // Wait up to 1 s for the background thread to dispatch + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (!called && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + ASSERT_TRUE(called) << "Status listener was not called within timeout"; + EXPECT_EQ(capturedDescriptor, kDescriptor); + EXPECT_EQ(capturedName, kName); + EXPECT_EQ(capturedExitCode, kExitValue); + + mProxy->unregisterListenerWithStatus(id); +} + +/** + * @test onContainerStoppedWithStatusEvent_SignaledProcess_ExitCodeIsMinusOne + * + * When the container was killed by a signal (WIFEXITED is false), the + * listener must receive exitCode == -1. + */ +TEST_F(DobbyProxyTest, onContainerStoppedWithStatusEvent_SignaledProcess_ExitCodeIsMinusOne) +{ + // Simulate a process killed by SIGKILL (signal 9, no core dump) + int32_t rawStatus = W_EXITCODE(0, SIGKILL); // WIFSIGNALED true, WIFEXITED false + + std::atomic called{false}; + int32_t capturedExitCode = -999; + + IDobbyProxy::StateChangeWithStatusListener listener = + [&](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t exitCode, const void*) + { + capturedExitCode = exitCode; + called = true; + }; + + int id = mProxy->registerListenerWithStatus(listener, nullptr); + ASSERT_GE(id, 0); + + mProxy->onContainerStoppedWithStatusEvent(makeArgs(1, "container1", rawStatus)); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (!called && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + ASSERT_TRUE(called); + EXPECT_EQ(capturedExitCode, -1) + << "Signaled container must report exitCode == -1"; + + mProxy->unregisterListenerWithStatus(id); +} + +/** + * @test onContainerStoppedWithStatusEvent_BadArgs_ListenerNotCalled + * + * When the signal arrives with fewer than 3 arguments (malformed), the + * implementation must log an error and not enqueue any event, so status + * listeners must not be called. + */ +TEST_F(DobbyProxyTest, onContainerStoppedWithStatusEvent_BadArgs_ListenerNotCalled) +{ + std::atomic callCount{0}; + + IDobbyProxy::StateChangeWithStatusListener listener = + [&callCount](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void*) { ++callCount; }; + + int id = mProxy->registerListenerWithStatus(listener, nullptr); + ASSERT_GE(id, 0); + + // Only 2 args — parseVariantList should fail + mProxy->onContainerStoppedWithStatusEvent(makeIncompleteArgs(5, "broken")); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + EXPECT_EQ(callCount.load(), 0) + << "Listener must not be called when args are malformed"; + + mProxy->unregisterListenerWithStatus(id); +} + +// =========================================================================== +// Dispatch isolation: ContainerStopped vs ContainerStoppedWithStatus +// =========================================================================== + +/** + * @test standardStopped_DoesNotCallStatusListeners + * + * A plain Stopped event (onContainerStoppedEvent) must dispatch only to + * standard StateChangeListeners, not to StateChangeWithStatusListeners. + */ +TEST_F(DobbyProxyTest, standardStopped_DoesNotCallStatusListeners) +{ + std::atomic statusCallCount{0}; + + IDobbyProxy::StateChangeWithStatusListener statusListener = + [&statusCallCount](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void*) { ++statusCallCount; }; + + int id = mProxy->registerListenerWithStatus(statusListener, nullptr); + ASSERT_GE(id, 0); + + // Fire a plain Stopped event (2-arg signal, no exit code) + AI_IPC::VariantList stoppedArgs{ + AI_IPC::Variant(static_cast(3)), + AI_IPC::Variant(std::string("myapp")) + }; + mProxy->onContainerStoppedEvent(stoppedArgs); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + EXPECT_EQ(statusCallCount.load(), 0) + << "Status listeners must not fire for plain Stopped events"; + + mProxy->unregisterListenerWithStatus(id); +} + +/** + * @test stoppedWithStatus_DoesNotCallStandardListeners + * + * A StoppedWithStatus event must not call standard StateChangeListeners. + */ +TEST_F(DobbyProxyTest, stoppedWithStatus_DoesNotCallStandardListeners) +{ + std::atomic standardCallCount{0}; + + IDobbyProxy::StateChangeListener standardListener = + [&standardCallCount](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, + const void*) { ++standardCallCount; }; + + int id = mProxy->registerListener(standardListener, nullptr); + ASSERT_GE(id, 0); + + int32_t rawStatus = W_EXITCODE(0, 0); // normal exit, code 0 + mProxy->onContainerStoppedWithStatusEvent(makeArgs(5, "target", rawStatus)); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + EXPECT_EQ(standardCallCount.load(), 0) + << "Standard listeners must not fire for StoppedWithStatus events"; + + mProxy->unregisterListener(id); +} + +/** + * @test stoppedWithStatus_MultipleStatusListeners_AllCalled + * + * All registered status listeners must be invoked for a single + * ContainerStoppedWithStatus event. + */ +TEST_F(DobbyProxyTest, stoppedWithStatus_MultipleStatusListeners_AllCalled) +{ + std::atomic count1{0}, count2{0}; + + IDobbyProxy::StateChangeWithStatusListener l1 = + [&count1](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void*) { ++count1; }; + + IDobbyProxy::StateChangeWithStatusListener l2 = + [&count2](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void*) { ++count2; }; + + int id1 = mProxy->registerListenerWithStatus(l1, nullptr); + int id2 = mProxy->registerListenerWithStatus(l2, nullptr); + ASSERT_GE(id1, 0); + ASSERT_GE(id2, 0); + + int32_t rawStatus = W_EXITCODE(1, 0); + mProxy->onContainerStoppedWithStatusEvent(makeArgs(9, "dual", rawStatus)); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while ((count1 == 0 || count2 == 0) && + std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + EXPECT_EQ(count1.load(), 1); + EXPECT_EQ(count2.load(), 1); + + mProxy->unregisterListenerWithStatus(id1); + mProxy->unregisterListenerWithStatus(id2); +} + +/** + * @test stoppedWithStatus_CbParams_ForwardedToListener + * + * The cbParams pointer registered with registerListenerWithStatus must be + * forwarded unchanged to each listener invocation. + */ +TEST_F(DobbyProxyTest, stoppedWithStatus_CbParams_ForwardedToListener) +{ + int sentinel = 0xDEAD; + const void* capturedParams = nullptr; + std::atomic called{false}; + + IDobbyProxy::StateChangeWithStatusListener listener = + [&](int32_t, const std::string&, + IDobbyProxyEvents::ContainerState, int32_t, + const void* params) + { + capturedParams = params; + called = true; + }; + + int id = mProxy->registerListenerWithStatus(listener, &sentinel); + ASSERT_GE(id, 0); + + int32_t rawStatus = W_EXITCODE(0, 0); + mProxy->onContainerStoppedWithStatusEvent(makeArgs(2, "paramtest", rawStatus)); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (!called && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + + ASSERT_TRUE(called); + EXPECT_EQ(capturedParams, static_cast(&sentinel)); + + mProxy->unregisterListenerWithStatus(id); +} diff --git a/tests/L2_testing/dobby_specs/exit_with_code.json b/tests/L2_testing/dobby_specs/exit_with_code.json new file mode 100644 index 00000000..8a6ace42 --- /dev/null +++ b/tests/L2_testing/dobby_specs/exit_with_code.json @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "cwd": "/", + "args": [ + "/bin/sh", + "-c", + "exit 42" + ], + "env": [], + "user": { + "uid": 1000, + "gid": 1000 + }, + "console": { + "limit": 65536, + "path": "/tmp/exit_with_code.log" + }, + "etc": { + "group": [ + "root:x:0:" + ], + "passwd": [ + "root::0:0:root:/:/bin/false" + ] + }, + "memLimit": 41943040, + "network": "nat", + "mounts": [] +} diff --git a/tests/L2_testing/test_runner/exit_code_tests.py b/tests/L2_testing/test_runner/exit_code_tests.py new file mode 100644 index 00000000..edd79077 --- /dev/null +++ b/tests/L2_testing/test_runner/exit_code_tests.py @@ -0,0 +1,341 @@ +# If not stated otherwise in this file or this component's LICENSE file the +# following copyright and licenses apply: +# +# Copyright 2024 Sky UK +# +# 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. + +""" +L2 integration tests for RDKEMW-21185: Add exitcode on container stopped event. + +These tests verify that DobbyDaemon emits the StoppedWithStatus D-Bus signal +when a container exits, and that the raw waitpid status carried by the signal +encodes the correct exit code. + +Tests: + 1. exit_code_normal_exit — container exits with code 42; verify signal + carries a waitpid status where WEXITSTATUS == 42 + 2. exit_code_zero — container exits with code 0; verify exit code == 0 + 3. stopped_signal_also_emitted — both StoppedWithStatus and plain Stopped + signals are emitted when a container stops +""" + +import subprocess +import test_utils +import os +import sys +import threading +import select +import re +from time import sleep, monotonic +from os.path import basename + +# --------------------------------------------------------------------------- +# Containers used by these tests. +# exit_with_code.json: runs "/bin/sh -c 'exit 42'" — exits with code 42. +# echo.json: runs "echo Hello World" — exits with code 0. +# --------------------------------------------------------------------------- +CONTAINER_EXIT_42 = "exit_with_code" +CONTAINER_EXIT_ZERO = "echo" + +# D-Bus interface / member names as emitted by DobbyDaemon +DBUS_INTERFACE = "org.rdk.dobby.ctrl1" +SIGNAL_STOPPED_STATUS = "StoppedWithStatus" +SIGNAL_STOPPED_PLAIN = "Stopped" + +# Timeout (seconds) to wait for a D-Bus signal after starting a container +SIGNAL_WAIT_TIMEOUT = 10 + +tests = ( + test_utils.Test("exit_code_normal_exit", + CONTAINER_EXIT_42, + "", + "Container exiting with code 42 should produce a " + "StoppedWithStatus signal where WEXITSTATUS == 42"), + test_utils.Test("exit_code_zero", + CONTAINER_EXIT_ZERO, + "", + "Container exiting with code 0 should produce a " + "StoppedWithStatus signal where WEXITSTATUS == 0"), + test_utils.Test("stopped_signal_also_emitted", + CONTAINER_EXIT_42, + "", + "Both StoppedWithStatus and plain Stopped signals must be " + "emitted when a container stops"), +) + + +# --------------------------------------------------------------------------- +# D-Bus signal monitoring helpers +# --------------------------------------------------------------------------- + +class DbusSignalCapture: + """Runs dbus-monitor in the background and collects matching signals. + + Usage:: + + with DbusSignalCapture(DBUS_INTERFACE, SIGNAL_STOPPED_STATUS) as cap: + # ... start container ... + signals = cap.wait_for_signals(count=1, timeout=10) + """ + + def __init__(self, interface, member): + self._interface = interface + self._member = member + self._lines = [] + self._lock = threading.Lock() + self._event = threading.Event() + self._proc = None + self._thread = None + + def __enter__(self): + match_rule = ("type='signal',interface='%s',member='%s'" + % (self._interface, self._member)) + self._proc = subprocess.Popen( + ["dbus-monitor", "--system", match_rule], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True + ) + self._thread = threading.Thread(target=self._reader, daemon=True) + self._thread.start() + return self + + def __exit__(self, *_): + if self._proc: + self._proc.terminate() + try: + self._proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + if self._thread: + self._thread.join(timeout=2) + + def _reader(self): + for line in self._proc.stdout: + with self._lock: + self._lines.append(line.rstrip()) + self._event.set() + + def wait_for_signals(self, count=1, timeout=SIGNAL_WAIT_TIMEOUT): + """Block until *count* signal blocks have been captured, or timeout.""" + deadline = monotonic() + timeout + while monotonic() < deadline: + with self._lock: + # dbus-monitor emits one "signal" header line per signal + headers = [l for l in self._lines + if "member=" + self._member in l] + if len(headers) >= count: + return list(self._lines) + remaining = deadline - monotonic() + self._event.wait(timeout=min(0.2, remaining)) + self._event.clear() + with self._lock: + return list(self._lines) + + +def extract_int32_args(monitor_output, signal_member): + """Parse int32 values that follow a matching signal header in dbus-monitor + output. + + dbus-monitor prints signal data like:: + + signal time=... interface=org.rdk.dobby.ctrl1 member=StoppedWithStatus + int32 7 + string "mycontainer" + int32 2621440 + + Returns a list of (descriptor, name, raw_status) tuples for each match. + """ + results = [] + lines = monitor_output if isinstance(monitor_output, list) else \ + monitor_output.splitlines() + + i = 0 + while i < len(lines): + if "member=" + signal_member in lines[i]: + # Collect subsequent value lines + ints = [] + strings = [] + j = i + 1 + while j < len(lines) and not lines[j].strip().startswith("signal "): + stripped = lines[j].strip() + m_int = re.match(r'^int32\s+(-?\d+)$', stripped) + m_str = re.match(r'^string\s+"(.*)"$', stripped) + if m_int: + ints.append(int(m_int.group(1))) + if m_str: + strings.append(m_str.group(1)) + j += 1 + + # StoppedWithStatus carries (int32 cd, string id, int32 rawStatus) + if len(ints) >= 2 and len(strings) >= 1: + results.append({ + "descriptor": ints[0], + "name": strings[0], + "raw_status": ints[1] + }) + i += 1 + + return results + + +def wexitstatus(raw_status): + """Return the exit code from a raw waitpid status (WEXITSTATUS macro).""" + return (raw_status >> 8) & 0xFF + + +def wifexited(raw_status): + """Return True if the process exited normally (WIFEXITED macro).""" + return (raw_status & 0x7F) == 0 + + +# --------------------------------------------------------------------------- +# Individual test functions +# --------------------------------------------------------------------------- + +def test_exit_code_normal_exit(): + """Verify WEXITSTATUS == 42 in StoppedWithStatus for exit_with_code container.""" + test_utils.print_log("Starting test: exit_code_normal_exit", + test_utils.Severity.debug) + + spec_path = test_utils.get_container_spec_path(CONTAINER_EXIT_42) + + with DbusSignalCapture(DBUS_INTERFACE, SIGNAL_STOPPED_STATUS) as cap: + proc = test_utils.run_command_line( + ["DobbyTool", "start", CONTAINER_EXIT_42, spec_path]) + + if "started '%s' container" % CONTAINER_EXIT_42 not in proc.stdout: + return False, "Container did not start: %s" % proc.stdout + + monitor_lines = cap.wait_for_signals(count=1) + + entries = extract_int32_args(monitor_lines, SIGNAL_STOPPED_STATUS) + matching = [e for e in entries if e["name"] == CONTAINER_EXIT_42] + + if not matching: + return False, ("StoppedWithStatus signal not received for '%s'. " + "dbus-monitor output:\n%s" + % (CONTAINER_EXIT_42, "\n".join(monitor_lines))) + + raw_status = matching[0]["raw_status"] + if not wifexited(raw_status): + return False, ("Container '%s' did not exit normally " + "(raw_status=0x%04x)" % (CONTAINER_EXIT_42, raw_status)) + + actual_exit_code = wexitstatus(raw_status) + if actual_exit_code != 42: + return False, ("Expected exit code 42, got %d " + "(raw_status=0x%04x)" % (actual_exit_code, raw_status)) + + return True, "StoppedWithStatus signal received with correct exit code 42" + + +def test_exit_code_zero(): + """Verify WEXITSTATUS == 0 in StoppedWithStatus for a container that exits cleanly.""" + test_utils.print_log("Starting test: exit_code_zero", + test_utils.Severity.debug) + + spec_path = test_utils.get_container_spec_path(CONTAINER_EXIT_ZERO) + + with DbusSignalCapture(DBUS_INTERFACE, SIGNAL_STOPPED_STATUS) as cap: + proc = test_utils.run_command_line( + ["DobbyTool", "start", CONTAINER_EXIT_ZERO, spec_path]) + + if "started '%s' container" % CONTAINER_EXIT_ZERO not in proc.stdout: + return False, "Container did not start: %s" % proc.stdout + + monitor_lines = cap.wait_for_signals(count=1) + + entries = extract_int32_args(monitor_lines, SIGNAL_STOPPED_STATUS) + matching = [e for e in entries if e["name"] == CONTAINER_EXIT_ZERO] + + if not matching: + return False, ("StoppedWithStatus signal not received for '%s'. " + "dbus-monitor output:\n%s" + % (CONTAINER_EXIT_ZERO, "\n".join(monitor_lines))) + + raw_status = matching[0]["raw_status"] + if not wifexited(raw_status): + return False, ("Container '%s' did not exit normally " + "(raw_status=0x%04x)" % (CONTAINER_EXIT_ZERO, raw_status)) + + actual_exit_code = wexitstatus(raw_status) + if actual_exit_code != 0: + return False, ("Expected exit code 0, got %d " + "(raw_status=0x%04x)" % (actual_exit_code, raw_status)) + + return True, "StoppedWithStatus signal received with correct exit code 0" + + +def test_stopped_signal_also_emitted(): + """Verify that plain Stopped AND StoppedWithStatus are both emitted.""" + test_utils.print_log("Starting test: stopped_signal_also_emitted", + test_utils.Severity.debug) + + spec_path = test_utils.get_container_spec_path(CONTAINER_EXIT_42) + + # Monitor both signals concurrently + with DbusSignalCapture(DBUS_INTERFACE, SIGNAL_STOPPED_STATUS) as status_cap, \ + DbusSignalCapture(DBUS_INTERFACE, SIGNAL_STOPPED_PLAIN) as plain_cap: + + proc = test_utils.run_command_line( + ["DobbyTool", "start", CONTAINER_EXIT_42, spec_path]) + + if "started '%s' container" % CONTAINER_EXIT_42 not in proc.stdout: + return False, "Container did not start: %s" % proc.stdout + + status_lines = status_cap.wait_for_signals(count=1) + plain_lines = plain_cap.wait_for_signals(count=1) + + got_status = any("member=" + SIGNAL_STOPPED_STATUS in l + for l in status_lines) + got_plain = any("member=" + SIGNAL_STOPPED_PLAIN in l + for l in plain_lines) + + if not got_status: + return False, "StoppedWithStatus signal was not emitted" + if not got_plain: + return False, "Plain Stopped signal was not emitted" + + return True, ("Both '%s' and '%s' signals were emitted" + % (SIGNAL_STOPPED_STATUS, SIGNAL_STOPPED_PLAIN)) + + +# --------------------------------------------------------------------------- +# Test runner +# --------------------------------------------------------------------------- + +def execute_test(): + output_table = [] + + test_functions = [ + (tests[0], test_exit_code_normal_exit), + (tests[1], test_exit_code_zero), + (tests[2], test_stopped_signal_also_emitted), + ] + + with test_utils.dobby_daemon(): + for test, fn in test_functions: + result, message = fn() + output = test_utils.create_simple_test_output(test, result, message) + output_table.append(output) + test_utils.print_single_result(output) + + return test_utils.count_print_results(output_table) + + +if __name__ == "__main__": + test_utils.parse_arguments(__file__) + execute_test() diff --git a/tests/L2_testing/test_runner/runner.py b/tests/L2_testing/test_runner/runner.py index 4b98ec80..68f387f9 100755 --- a/tests/L2_testing/test_runner/runner.py +++ b/tests/L2_testing/test_runner/runner.py @@ -29,6 +29,7 @@ import memcr_tests import annotation_tests import swap_limit_tests +import exit_code_tests import sys import json @@ -46,7 +47,8 @@ gui_containers, pid_limit_tests, memcr_tests, - swap_limit_tests] + swap_limit_tests, + exit_code_tests] def run_all_tests(): success_count = 0