From a12793ff2abd7564487b612bdf55dc43331fd94b Mon Sep 17 00:00:00 2001 From: rstein Date: Wed, 31 Jan 2024 17:51:47 +0100 Subject: [PATCH] added lifecycle::StateMachine --- core/include/gnuradio-4.0/Block.hpp | 89 ++--------- core/include/gnuradio-4.0/LifeCycle.hpp | 167 +++++++++++++++++++++ core/test/CMakeLists.txt | 1 + core/test/qa_LifeCycle.cpp | 187 ++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 77 deletions(-) create mode 100644 core/include/gnuradio-4.0/LifeCycle.hpp create mode 100644 core/test/qa_LifeCycle.cpp diff --git a/core/include/gnuradio-4.0/Block.hpp b/core/include/gnuradio-4.0/Block.hpp index 30007dcd..8d749847 100644 --- a/core/include/gnuradio-4.0/Block.hpp +++ b/core/include/gnuradio-4.0/Block.hpp @@ -22,89 +22,23 @@ #include #include -#include "BlockTraits.hpp" -#include "Port.hpp" -#include "Sequence.hpp" -#include "Tag.hpp" -#include "thread/thread_pool.hpp" +#include +#include +#include +#include +#include -#include "annotated.hpp" // This needs to be included after fmt/format.h, as it defines formatters only if FMT_FORMAT_H_ is defined -#include "reflection.hpp" -#include "Settings.hpp" +#include // This needs to be included after fmt/format.h, as it defines formatters only if FMT_FORMAT_H_ is defined +#include +#include + +#include namespace gr { namespace stdx = vir::stdx; using gr::meta::fixed_string; -namespace lifecycle { -/** - * @enum lifecycle::State enumerates the possible states of a `Scheduler` lifecycle. - * - * Transition between the following states is triggered by specific actions or events: - * - `IDLE`: The initial state before the scheduler has been initialized. - * - `INITIALISED`: The scheduler has been initialized and is ready to start running. - * - `RUNNING`: The scheduler is actively running. - * - `REQUESTED_PAUSE`: A pause has been requested, and the scheduler is in the process of pausing. - * - `PAUSED`: The scheduler is paused and can be resumed or stopped. - * - `REQUESTED_STOP`: A stop has been requested, and the scheduler is in the process of stopping. - * - `STOPPED`: The scheduler has been stopped and can be reset or re-initialized. - * - `ERROR`: An error state that can be reached from any state at any time, requiring a reset. - * - * @note All `Block`-derived classes can optionally implement any subset of the lifecycle methods - * (`start()`, `stop()`, `reset()`, `pause()`, `resume()`) to handle state changes of the `Scheduler`. - * - * State diagram: - * - * Block() can be reached from - * │ anywhere and anytime. - * ┌─────┴────┐ ┌────┴────┐ - * │ IDLE │ │ ERROR │ - * └────┬─────┘ └────┬────┘ - * │ init() │ reset() - * v │ - * ┌───────┴───────┐ │ - * │ INITIALISED ├<─────────────────────┤ - * └───────┬───────┘ │ - * │ start() │ - * v │ - * stop() ┌──────┴──────┐ │ ╓ - * ┌──────┤ RUNNING ├<──────────┐ │ ║ - * │ └─────┬───────┘ │ │ ║ - * │ │ pause() │ │ ║ isActive(lifecycle::State) ─> true - * │ v │ resume() │ ║ - * │ ┌─────────┴─────────┐ ┌─────┴─────┐ │ ║ - * │ │ REQUESTED_PAUSE ├──>┤ PAUSED │ │ ║ - * │ └──────────┬────────┘ └─────┬─────┘ │ ╙ - * │ │ stop() │ stop() │ - * │ v │ │ - * │ ┌─────────┴────────┐ │ │ ╓ - * └──>┤ REQUESTED_STOP ├<────────┘ │ ║ - * └────────┬─────────┘ │ ║ - * │ │ ║ isShuttingDown(lifecycle::State) ─> true - * v │ ║ - * ┌─────┴─────┐ reset() │ ║ - * │ STOPPED ├─────────────────────────┘ ║ - * └─────┬─────┘ ╙ - * │ - * v - * ~Block() - */ -enum class State : char { IDLE, INITIALISED, RUNNING, REQUESTED_PAUSE, PAUSED, REQUESTED_STOP, STOPPED, ERROR }; -using enum State; - -inline constexpr bool -isActive(lifecycle::State state) noexcept { - return state == RUNNING || state == REQUESTED_PAUSE || state == PAUSED; -} - -inline constexpr bool -isShuttingDown(lifecycle::State state) noexcept { - return state == REQUESTED_STOP || state == STOPPED; -} - -} // namespace lifecycle - template constexpr void simd_epilogue(auto width, F &&fun) { @@ -606,7 +540,8 @@ class Block : protected std::tuple { // we can not have a move-assignment that is equivalent to // the move constructor Block & - operator=(Block &&other) = delete; + operator=(Block &&other) + = delete; ~Block() { // NOSONAR -- need to request the (potentially) running ioThread to stop if (lifecycle::isActive(std::atomic_load_explicit(&state, std::memory_order_acquire))) { diff --git a/core/include/gnuradio-4.0/LifeCycle.hpp b/core/include/gnuradio-4.0/LifeCycle.hpp new file mode 100644 index 00000000..880ff5f7 --- /dev/null +++ b/core/include/gnuradio-4.0/LifeCycle.hpp @@ -0,0 +1,167 @@ +#ifndef GNURADIO_LIFECYCLE_HPP +#define GNURADIO_LIFECYCLE_HPP + +#include + +namespace gr ::lifecycle { +/** + * @enum lifecycle::State enumerates the possible states of a `Scheduler` lifecycle. + * + * Transition between the following states is triggered by specific actions or events: + * - `IDLE`: The initial state before the scheduler has been initialized. + * - `INITIALISED`: The scheduler has been initialized and is ready to start running. + * - `RUNNING`: The scheduler is actively running. + * - `REQUESTED_PAUSE`: A pause has been requested, and the scheduler is in the process of pausing. + * - `PAUSED`: The scheduler is paused and can be resumed or stopped. + * - `REQUESTED_STOP`: A stop has been requested, and the scheduler is in the process of stopping. + * - `STOPPED`: The scheduler has been stopped and can be reset or re-initialized. + * - `ERROR`: An error state that can be reached from any state at any time, requiring a reset. + * + * @note All `Block`-derived classes can optionally implement any subset of the lifecycle methods + * (`start()`, `stop()`, `reset()`, `pause()`, `resume()`) to handle state changes of the `Scheduler`. + * + * State diagram: + * + * Block() can be reached from + * │ anywhere and anytime. + * ┌─────┴────┐ ┌────┴────┐ + * │ IDLE │ │ ERROR │ + * └────┬─────┘ └────┬────┘ + * │ init() │ reset() + * v │ + * ┌───────┴───────┐ │ + * │ INITIALISED ├<─────────────────────┤ + * └───────┬───────┘ │ + * │ start() │ + * v │ + * stop() ┌──────┴──────┐ │ ╓ + * ┌──────┤ RUNNING ├<──────────┐ │ ║ + * │ └─────┬───────┘ │ │ ║ + * │ │ pause() │ │ ║ isActive(lifecycle::State) ─> true + * │ v │ resume() │ ║ + * │ ┌─────────┴─────────┐ ┌─────┴─────┐ │ ║ + * │ │ REQUESTED_PAUSE ├──>┤ PAUSED │ │ ║ + * │ └──────────┬────────┘ └─────┬─────┘ │ ╙ + * │ │ stop() │ stop() │ + * │ v │ │ + * │ ┌─────────┴────────┐ │ │ ╓ + * └──>┤ REQUESTED_STOP ├<────────┘ │ ║ + * └────────┬─────────┘ │ ║ + * │ │ ║ isShuttingDown(lifecycle::State) ─> true + * v │ ║ + * ┌─────┴─────┐ reset() │ ║ + * │ STOPPED ├─────────────────────────┘ ║ + * └─────┬─────┘ ╙ + * │ + * v + * ~Block() + */ +enum class State : char { IDLE, INITIALISED, RUNNING, REQUESTED_PAUSE, PAUSED, REQUESTED_STOP, STOPPED, ERROR }; +using enum State; + +inline constexpr bool +isActive(lifecycle::State state) noexcept { + return state == RUNNING || state == REQUESTED_PAUSE || state == PAUSED; +} + +inline constexpr bool +isShuttingDown(lifecycle::State state) noexcept { + return state == REQUESTED_STOP || state == STOPPED; +} + +constexpr bool +isValidTransition(const State from, const State to) noexcept { + if (to == State::ERROR) { + // can transit to ERROR from any state + return true; + } + switch (from) { + case State::IDLE: return to == State::INITIALISED; + case State::INITIALISED: return to == State::RUNNING; + case State::RUNNING: return to == State::REQUESTED_PAUSE || to == State::REQUESTED_STOP; + case State::REQUESTED_PAUSE: return to == State::PAUSED; + case State::PAUSED: return to == State::RUNNING || to == State::REQUESTED_STOP; + case State::REQUESTED_STOP: return to == State::STOPPED; + case State::STOPPED: return to == State::INITIALISED; + case State::ERROR: return to == State::INITIALISED; + default: return false; + } +} + +enum class StorageType { ATOMIC, NON_ATOMIC }; + +template +class StateMachine { +protected: + using StateStorage = std::conditional_t, State>; + StateStorage _state = lifecycle::State::IDLE; + +public: + [[nodiscard]] constexpr bool + transitionTo(State newState) { + if (!isValidTransition(_state, newState)) { + return false; + } + + State oldState = _state; + + if constexpr (storageType == StorageType::ATOMIC) { + _state.store(newState); + } else { + _state = newState; + } + + if constexpr (storageType == StorageType::ATOMIC) { + _state.notify_all(); + } + + // Call specific methods in TDerived based on the state + if constexpr (requires(TDerived &d) { d.start(); }) { + if (oldState == State::INITIALISED && newState == State::RUNNING) { + static_cast(this)->start(); + } + } + if constexpr (requires(TDerived &d) { d.stop(); }) { + if (newState == State::REQUESTED_STOP) { + static_cast(this)->stop(); + } + } + if constexpr (requires(TDerived &d) { d.pause(); }) { + if (newState == State::REQUESTED_PAUSE) { + static_cast(this)->pause(); + } + } + if constexpr (requires(TDerived &d) { d.resume(); }) { + if (oldState == State::PAUSED && newState == State::RUNNING) { + static_cast(this)->resume(); + } + } + if constexpr (requires(TDerived &d) { d.reset(); }) { + if (oldState == State::STOPPED && newState == State::INITIALISED) { + static_cast(this)->reset(); + } + } + + return true; + } + + [[nodiscard]] State + state() const noexcept { + if constexpr (storageType == StorageType::ATOMIC) { + return _state.load(); + } else { + return _state; + } + } + + void + waitOnState(State oldState) + requires(storageType == StorageType::ATOMIC) + { + _state.wait(oldState); + } +}; + +} // namespace gr::lifecycle + +#endif // GNURADIO_LIFECYCLE_HPP diff --git a/core/test/CMakeLists.txt b/core/test/CMakeLists.txt index 41f72cff..eca74fde 100644 --- a/core/test/CMakeLists.txt +++ b/core/test/CMakeLists.txt @@ -34,6 +34,7 @@ add_ut_test(qa_DynamicBlock) add_ut_test(qa_DynamicPort) add_ut_test(qa_HierBlock) add_ut_test(qa_Block) +add_ut_test(qa_LifeCycle) add_ut_test(qa_Scheduler) add_ut_test(qa_reader_writer_lock) add_ut_test(qa_Settings) diff --git a/core/test/qa_LifeCycle.cpp b/core/test/qa_LifeCycle.cpp new file mode 100644 index 00000000..833f03eb --- /dev/null +++ b/core/test/qa_LifeCycle.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include +#include +#include + +#ifdef __GNUC__ +#pragma GCC diagnostic push // ignore warning of external libraries that from this lib-context we do not have any control over +#pragma GCC diagnostic ignored "-Wuseless-cast" +#pragma GCC diagnostic ignored "-Wsign-conversion" +#endif +#include +#include +#ifdef __GNUC__ +#pragma GCC diagnostic pop +#endif + +#include + +namespace gr::test { + +template +struct MockStateMachine : public lifecycle::StateMachine, storageType> { + int startCalled{}; + int stopCalled{}; + int pauseCalled{}; + int resumeCalled{}; + int resetCalled{}; + + void + start() { + startCalled++; + } + + void + stop() { + stopCalled++; + } + + void + pause() { + pauseCalled++; + } + + void + resume() { + resumeCalled++; + } + + void + reset() { + resetCalled++; + } +}; + +} // namespace gr::test + +const boost::ut::suite StateMachineTest = [] { + using namespace std::string_literals; + using namespace boost::ut; + using namespace gr::lifecycle; + + auto nominalTest = []() { + MockStateMachine machine; + + expect(machine.state() == State::IDLE); + + expect(machine.transitionTo(State::INITIALISED)); + expect(machine.state() == State::INITIALISED); + + expect(machine.transitionTo(State::RUNNING)); + expect(machine.state() == State::RUNNING); + expect(eq(machine.startCalled, 1)) << "start() called once"; + + expect(machine.transitionTo(State::REQUESTED_PAUSE)); + expect(machine.state() == State::REQUESTED_PAUSE); + expect(eq(machine.pauseCalled, 1)) << "pause() called once"; + + expect(machine.transitionTo(State::PAUSED)); + expect(machine.state() == State::PAUSED); + expect(eq(machine.pauseCalled, 1)) << "pause() called once"; + + expect(machine.transitionTo(State::RUNNING)); + expect(machine.state() == State::RUNNING); + expect(eq(machine.resumeCalled, 1)) << "resume() called once"; + + expect(machine.transitionTo(State::REQUESTED_STOP)); + expect(machine.state() == State::REQUESTED_STOP); + expect(eq(machine.stopCalled, 1)) << "stop() called once"; + + expect(machine.transitionTo(State::STOPPED)); + expect(machine.state() == State::STOPPED); + expect(eq(machine.stopCalled, 1)) << "stop() called once"; + + expect(machine.transitionTo(State::INITIALISED)); + expect(machine.state() == State::INITIALISED); + expect(eq(machine.resetCalled, 1)) << "reset() called once"; + + expect(machine.transitionTo(State::ERROR)); + expect(machine.state() == State::ERROR); + expect(eq(machine.resetCalled, 1)) << "reset() called once"; + + // ensure again that the path have been executed only once + expect(eq(machine.startCalled, 1)) << "end-of-test: start() called once"; + expect(eq(machine.stopCalled, 1)) << "end-of-test: stop() called once"; + expect(eq(machine.pauseCalled, 1)) << "end-of-test: pause() called once"; + expect(eq(machine.resumeCalled, 1)) << "end-of-test: resume() called once"; + expect(eq(machine.resetCalled, 1)) << "end-of-test: reset() called once"; + }; + + "StateMachine nominal State transitions -- non-atomic"_test = [&] { nominalTest.template operator()>(); }; + "StateMachine nominal State transitions -- atomic"_test = [&] { // N.B. this workaround is needed because atomic are not copyable + nominalTest.template operator()>(); + }; + + "StateMachine all State transitions"_test = [] { + std::map> allowedTransitions = { + { State::IDLE, { State::INITIALISED } }, + { State::INITIALISED, { State::RUNNING } }, + { State::RUNNING, { State::REQUESTED_PAUSE, State::REQUESTED_STOP } }, + { State::REQUESTED_PAUSE, { State::PAUSED } }, + { State::PAUSED, { State::RUNNING, State::REQUESTED_STOP } }, + { State::REQUESTED_STOP, { State::STOPPED } }, + { State::STOPPED, { State::INITIALISED } }, + { State::ERROR, { State::INITIALISED } }, + }; + + magic_enum::enum_for_each([&allowedTransitions](State fromState) { + magic_enum::enum_for_each([&fromState, &allowedTransitions](State toState) { + bool isAllowed = std::find(allowedTransitions[fromState].begin(), allowedTransitions[fromState].end(), toState) != allowedTransitions[fromState].end(); + + // special case: Any state can transition to ERROR + if (toState == State::ERROR) { + isAllowed = true; + } + + bool isValid = isValidTransition(fromState, toState); + + // Assert that the function's validity matches the expected validity + expect(isValid == isAllowed) << "Transition from " << static_cast(fromState) << " to " << static_cast(toState) << " should be " << (isAllowed ? "allowed" : "disallowed"); + }); + }); + }; + + "StateMachine misc"_test = [] { + magic_enum::enum_for_each([&](State state) { + std::vector allowedState{ RUNNING, REQUESTED_PAUSE, PAUSED }; + + if (std::ranges::find(allowedState, state) != allowedState.end()) { + expect(isActive(state)); + } else { + expect(!isActive(state)); + } + }); + + magic_enum::enum_for_each([&](State state) { + std::vector allowedState{ REQUESTED_STOP, STOPPED }; + + if (std::ranges::find(allowedState, state) != allowedState.end()) { + expect(isShuttingDown(state)); + } else { + expect(!isShuttingDown(state)); + } + }); + + gr::test::MockStateMachine machine; + expect(machine.state() == State::IDLE); + + std::thread notifyThread([&machine]() { + using namespace std::literals; + std::this_thread::sleep_for(100ms); + expect(machine.transitionTo(State::INITIALISED)); + expect(machine.state() == State::INITIALISED); + }); + machine.waitOnState(State::IDLE); // blocks here + expect(machine.state() == State::INITIALISED); + if (notifyThread.joinable()) { + notifyThread.join(); + } + // finished successful + }; +}; + +int +main() { /* tests are statically executed */ +}