From 41e5ebf246deb012d39fe097d71a927057394026 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 13:08:36 +0300 Subject: [PATCH 1/6] add exit-code lifecycle runner --- CMakeLists.txt | 5 + README-RU.md | 23 + README.md | 22 + docs/mainpage.dox | 14 +- examples/example_exit_code_runner.cpp | 26 ++ external/log-it-cpp | 2 +- include/consolix/config_macros.hpp | 10 + include/consolix/core.hpp | 2 + include/consolix/core/ConsoleApplication.hpp | 353 ++++----------- .../core/ConsoleApplicationRunner.hpp | 426 ++++++++++++++++++ include/consolix/core/application_utils.hpp | 31 +- tests/test_console_application_runner.cpp | 360 +++++++++++++++ 12 files changed, 1000 insertions(+), 274 deletions(-) create mode 100644 examples/example_exit_code_runner.cpp create mode 100644 include/consolix/core/ConsoleApplicationRunner.hpp create mode 100644 tests/test_console_application_runner.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 83f65ef..39f4168 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,6 +151,11 @@ if(CONSOLIX_BUILD_TESTS) consolix_add_test(test_app_component_manager_shutdown_order "tests/test_app_component_manager_shutdown_order.cpp") endif() + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_console_application_runner.cpp") + consolix_add_test(test_console_application_runner "tests/test_console_application_runner.cpp") + set_tests_properties(test_console_application_runner PROPERTIES TIMEOUT 15) + endif() + if(UNIX AND NOT WIN32 AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_posix_signal_shutdown.cpp") consolix_add_test(test_posix_signal_shutdown "tests/test_posix_signal_shutdown.cpp") target_compile_definitions(test_posix_signal_shutdown PRIVATE CONSOLIX_WAIT_ON_ERROR=0) diff --git a/README-RU.md b/README-RU.md index b846c01..7caf5ea 100644 --- a/README-RU.md +++ b/README-RU.md @@ -156,9 +156,32 @@ int main(int argc, char* argv[]) { } ``` +Если `main()` должен вернуть результат lifecycle, а не завершаться через +`std::exit` из `ConsoleApplication::run()`, используйте runner с exit code: + +```cpp +int main() { + consolix::add(); + return consolix::run_for_exit_code(); +} +``` + +Компонент может запросить конкретный код через `consolix::stop(code)`; +`consolix::request_stop(code)` остается доступен как более явная форма. +Если приложение уже владеет своим `AppComponentManager`, можно создать +`consolix::ConsoleApplicationRunner runner(manager);` и вернуть +`runner.run_for_exit_code()`. + +`ConsoleApplication::run()` остается совместимым process-owning facade: +внутри он использует тот же runner и завершает процесс с полученным кодом. +На Windows Ctrl+C/Ctrl+Break запрашивают cooperative shutdown, а close/logoff/ +shutdown events запрашивают shutdown на runner thread и ждут ограниченное окно +для cleanup. + ## Documentation - developer guidelines: `docs/header-implementation-guidelines.md` - agent playbook: `guides/header-implementation-guidelines.md` - lifecycle example: `examples/example_shutdown_and_resources.cpp` +- exit-code runner example: `examples/example_exit_code_runner.cpp` - API docs: https://newyaroslav.github.io/Consolix/ diff --git a/README.md b/README.md index fb51ae9..1cb9d68 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,27 @@ int main(int argc, char* argv[]) { } ``` +When `main()` must return the lifecycle result instead of letting +`ConsoleApplication::run()` call `std::exit`, use the exit-code runner: + +```cpp +int main() { + consolix::add(); + return consolix::run_for_exit_code(); +} +``` + +Components can request a specific code with `consolix::stop(code)`; +`consolix::request_stop(code)` is available as the explicit form. +For applications that already own an `AppComponentManager`, use +`consolix::ConsoleApplicationRunner runner(manager);` and return +`runner.run_for_exit_code()`. + +`ConsoleApplication::run()` remains the process-owning compatibility facade: +internally it uses the same runner and exits with the returned code. On Windows, +Ctrl+C/Ctrl+Break request cooperative shutdown; close/logoff/shutdown events +request shutdown on the runner thread and wait for a bounded cleanup window. + ## Diagnostic Streams Consolix provides two multi-target log macros that route messages through @@ -198,6 +219,7 @@ Additional repository guidance: - developer guidelines: `docs/header-implementation-guidelines.md` - agent playbook: `guides/header-implementation-guidelines.md` - lifecycle example: `examples/example_shutdown_and_resources.cpp` +- exit-code runner example: `examples/example_exit_code_runner.cpp` - diagnostic streams: `examples/example_stderr_diagnostics.cpp` API documentation: https://newyaroslav.github.io/Consolix/ diff --git a/docs/mainpage.dox b/docs/mainpage.dox index 4cc3987..0d8bb8e 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -36,7 +36,7 @@ It also targets the recurring runtime concerns of console tools: startup and shu - **Core**: - `ServiceLocator` for global resource management - - `AppComponentManager` and `ConsoleApplication` for application lifecycle + - `AppComponentManager`, `ConsoleApplication`, and `ConsoleApplicationRunner` for application lifecycle - service and application helpers for common setup flows - **Components**: @@ -97,6 +97,18 @@ int main(int argc, char* argv[]) { } ``` +Use `consolix::run_for_exit_code()` when `main()` should return the lifecycle +result instead of using the legacy `ConsoleApplication::run()` path that exits +the process. Components can request a specific result with +`consolix::stop(exit_code)`; `consolix::request_stop(exit_code)` is available +as the explicit form. Applications that own their own `AppComponentManager` +can run it through `consolix::ConsoleApplicationRunner`. + +`ConsoleApplication::run()` is a compatibility facade over the same runner: it +exits the process with the returned code. On Windows, Ctrl+C/Ctrl+Break request +cooperative shutdown, while close/logoff/shutdown console events request cleanup +on the runner thread and wait for a bounded timeout. + ## Documentation Structure ### Main Sections diff --git a/examples/example_exit_code_runner.cpp b/examples/example_exit_code_runner.cpp new file mode 100644 index 0000000..37fdd63 --- /dev/null +++ b/examples/example_exit_code_runner.cpp @@ -0,0 +1,26 @@ +/// \example example_exit_code_runner.cpp +/// \brief Demonstrates returning an exit code from the Consolix lifecycle runner. + +#include + +class ExitCodeRunnerDemo final : public consolix::BaseLoopComponent { +public: + bool on_once() override { + CONSOLIX_STREAM() << "Exit-code runner demo initialized."; + return true; + } + + void on_loop() override { + CONSOLIX_STREAM() << "Requesting stop with exit code 7."; + consolix::stop(7); + } + + void on_shutdown(int exit_code) override { + CONSOLIX_STREAM() << "Shutdown completed with code: " << exit_code; + } +}; + +int main() { + consolix::add(); + return consolix::run_for_exit_code(); +} diff --git a/external/log-it-cpp b/external/log-it-cpp index c0ff37f..0ebf546 160000 --- a/external/log-it-cpp +++ b/external/log-it-cpp @@ -1 +1 @@ -Subproject commit c0ff37fa3ca5ad3c116dd2b94831f26bf861a121 +Subproject commit 0ebf5463a13159fb72482551f27d2838aae4eed3 diff --git a/include/consolix/config_macros.hpp b/include/consolix/config_macros.hpp index f466a39..3c53910 100644 --- a/include/consolix/config_macros.hpp +++ b/include/consolix/config_macros.hpp @@ -136,4 +136,14 @@ #define CONSOLIX_WAIT_ON_ERROR 0 #endif +/// \def CONSOLIX_FORCED_SHUTDOWN_TIMEOUT_MS +/// \brief Maximum time a Windows close/logoff/shutdown handler waits for runner cleanup. +/// \details Used only for forced console-control events where Windows may terminate +/// the process shortly after handler return. The cleanup itself still runs on the +/// runner thread. +/// \default `4000` +#ifndef CONSOLIX_FORCED_SHUTDOWN_TIMEOUT_MS +#define CONSOLIX_FORCED_SHUTDOWN_TIMEOUT_MS 4000 +#endif + #endif // _CONSOLIX_CONFIG_MACROS_HPP_INCLUDED diff --git a/include/consolix/core.hpp b/include/consolix/core.hpp index aef20fb..6462710 100644 --- a/include/consolix/core.hpp +++ b/include/consolix/core.hpp @@ -31,6 +31,7 @@ /// - `core/ServiceLocator.hpp` /// - `core/service_utils.hpp` /// - `core/AppComponentManager.hpp` +/// - `core/ConsoleApplicationRunner.hpp` /// - `core/ConsoleApplication.hpp` /// - `core/application_utils.hpp` /// @@ -74,6 +75,7 @@ #include "core/ServiceLocator.hpp" ///< Singleton for managing globally accessible services. #include "core/service_utils.hpp" ///< Helper functions for working with services. #include "core/AppComponentManager.hpp" ///< Manager for application components and their lifecycle. +#include "core/ConsoleApplicationRunner.hpp" ///< Runner returning exit codes without std::exit. #include "core/ConsoleApplication.hpp" ///< Singleton managing the console application's lifecycle. #include "core/application_utils.hpp" ///< Helper functions for application setup and execution. diff --git a/include/consolix/core/ConsoleApplication.hpp b/include/consolix/core/ConsoleApplication.hpp index 5f87386..ba870dc 100644 --- a/include/consolix/core/ConsoleApplication.hpp +++ b/include/consolix/core/ConsoleApplication.hpp @@ -3,25 +3,26 @@ #define _CONSOLIX_CONSOLE_APPLICATION_HPP_INCLUDED /// \file ConsoleApplication.hpp -/// \brief Manages the lifecycle of a console application. +/// \brief Singleton facade for the Consolix application lifecycle. /// \ingroup Core -#include -#include -#include +#include "ConsoleApplicationRunner.hpp" -#if !defined(_WIN32) && !defined(_WIN64) -#include -#endif +#include +#include +#include +#include +#include +#include namespace consolix { /// \class ConsoleApplication - /// \brief Singleton class to manage the lifecycle of a console application. + /// \brief Singleton facade over `ConsoleApplicationRunner`. /// - /// Provides functionality to initialize, execute, and gracefully shut down components - /// in a structured lifecycle. On POSIX systems, signal handlers only request shutdown, - /// and the actual component cleanup runs later in the normal execution path. + /// The singleton preserves the compact `consolix::add()` / `consolix::run()` + /// API. The actual lifecycle, signal handling, shutdown, service cleanup, and + /// logger shutdown are owned by `ConsoleApplicationRunner`. class ConsoleApplication { public: @@ -49,16 +50,8 @@ namespace consolix { } /// \brief Initializes the application and its components. - /// This method initializes all components in the manager and ensures readiness void init() { - try { - while (!stop_requested() && !m_manager.initialize()) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - cleanup_if_stopping(); - } catch (const std::exception& e) { - handle_fatal_exception(e); - } + initialize_or_exit(); } /// \brief Initializes the application with a custom action. @@ -66,25 +59,23 @@ namespace consolix { /// \param init_action The custom action to perform during initialization. template void init(InitAction init_action) { + initialize_or_exit(); try { - while (!stop_requested() && !m_manager.initialize()) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - cleanup_if_stopping(); init_action(); - cleanup_if_stopping(); + exit_if_stopping(); } catch (const std::exception& e) { - handle_fatal_exception(e); + exit_after_fatal_exception(e); + } catch (...) { + exit_after_unknown_fatal_exception(); } } /// \brief Runs the application with the registered components. + /// + /// This legacy entry point preserves process-owning behavior by exiting + /// with the code produced by the runner. void run() { - if (m_running) return; - m_running = true; - setup_signal_handlers(); - init(); - lifecycle_loop(); + std::exit(run_for_exit_code()); } /// \brief Runs the application with a custom loop action. @@ -92,279 +83,99 @@ namespace consolix { /// \param iteration_action The custom action to perform in each loop iteration. template void run(IterationAction iteration_action) { - if (m_running) return; - m_running = true; - setup_signal_handlers(); - init(); - lifecycle_loop(iteration_action); + std::exit(run_for_exit_code(iteration_action)); } - /// \brief Stops the application's main loop. - void stop() { - if (m_stopping) return; - m_stopping = true; + /// \brief Runs the application and returns an exit code without calling std::exit. + /// \return The requested exit code, a signal code, or a non-zero fatal error code. + int run_for_exit_code() { + return m_runner.run_for_exit_code(); } - /// \brief - void shutdown(int signal) { - m_stopping = true; - cleanup(signal); + /// \brief Runs the application with a custom loop action and returns an exit code. + /// \tparam IterationAction A callable type executed within the main loop. + /// \param iteration_action The custom action to perform in each loop iteration. + /// \return The requested exit code, a signal code, or a non-zero fatal error code. + template + int run_for_exit_code(IterationAction iteration_action) { + return m_runner.run_for_exit_code(iteration_action); } - private: - AppComponentManager m_manager; - std::atomic m_init{false}; - std::atomic m_running{false}; ///< Flag indicating whether the loop is running. - std::atomic m_stopping{false}; - std::atomic m_cleanup{false}; ///< - - /// \brief Sets up signal handlers for graceful application termination. - void setup_signal_handlers() { -# if defined(_WIN32) || defined(_WIN64) - SetConsoleCtrlHandler(console_handler, TRUE); -# else - reset_signal_state(); - - struct sigaction action = {}; - action.sa_handler = signal_handler; - sigemptyset(&action.sa_mask); - sigaddset(&action.sa_mask, SIGINT); - sigaddset(&action.sa_mask, SIGTERM); - action.sa_flags = 0; - - sigaction(SIGINT, &action, nullptr); - sigaction(SIGTERM, &action, nullptr); -# endif - std::atexit(on_exit_handler); + /// \brief Stops the application's main loop with exit code 0. + void stop() { + request_stop(0); } - /// \brief Cleans up the application and shuts down all components. - /// \param exit_code Exit code indicating the reason for cleanup. - /// \param wait_for_press Flag indicating if the program should wait for a key press before exiting. - void cleanup(int exit_code, bool wait_for_press = false) { - bool expected = false; - if (!m_cleanup.compare_exchange_strong(expected, true)) return; - - m_stopping = true; - -# if !defined(_WIN32) && !defined(_WIN64) - PosixTerminationSignalMaskGuard posix_signal_mask_guard; -# endif - -# if CONSOLIX_USE_LOGIT == 1 - LOGIT_PRINT_INFO("Cleaning up application for exit code: ", exit_code); - try { - m_manager.shutdown(exit_code); - } catch (const std::exception& e) { - LOGIT_FATAL(e); - } - try { - ServiceLocator::get_instance().clear_all(); - } catch (const std::exception& e) { - LOGIT_FATAL(e); - } - LOGIT_SHUTDOWN(); -# else - try { - m_manager.shutdown(exit_code); - } catch (...) {} - try { - ServiceLocator::get_instance().clear_all(); - } catch (...) {} -# endif - if (wait_for_press) { - CONSOLIX_STREAM() << "Press Enter to exit..." << std::endl; - std::cin.get(); - } - std::exit(exit_code); + /// \brief Stops the application's main loop with an explicit exit code. + /// \param exit_code Exit code to pass to shutdown and return from `run_for_exit_code`. + void stop(int exit_code) { + request_stop(exit_code); } - /// \brief Handles fatal exceptions by logging the error and terminating the application. - /// \param e The exception that caused the fatal error. - void handle_fatal_exception(const std::exception& e) { -# if CONSOLIX_USE_LOGIT == 1 - LOGIT_PRINT_FATAL("Unhandled exception: ", e.what()); -# endif // - cleanup(-1, static_cast(CONSOLIX_WAIT_ON_ERROR)); + /// \brief Requests stop with an explicit exit code. + /// \param exit_code Exit code to pass to shutdown and return from `run_for_exit_code`. + void request_stop(int exit_code) { + m_runner.request_stop(exit_code); } - /// \brief The main lifecycle loop with a custom action. - /// \tparam IterationAction A callable executed within the loop. - /// \param iteration_action The action to execute in each iteration. - template - void lifecycle_loop(IterationAction iteration_action) { - try { - while (!stop_requested()) { - m_manager.process(); - iteration_action(); - } - cleanup(resolve_stop_exit_code(0)); - } catch (const std::exception& e) { - handle_fatal_exception(e); + /// \brief Legacy shutdown entry point. + /// + /// If the runner is active, shutdown is deferred to the runner thread. + /// If the runner is inactive, this method performs the legacy process-owning + /// behavior by running cleanup through the runner and exiting. + void shutdown(int exit_code) { + request_stop(exit_code); + if (!m_runner.is_running()) { + std::exit(m_runner.run_for_exit_code()); } } - /// \brief The main lifecycle loop. - void lifecycle_loop() { + private: + AppComponentManager m_manager; + ConsoleApplicationRunner m_runner; + + void initialize_or_exit() { try { - while (!stop_requested()) { - m_manager.process(); + while (!m_runner.is_stop_requested() && !m_manager.initialize()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); } - cleanup(resolve_stop_exit_code(0)); + exit_if_stopping(); } catch (const std::exception& e) { - handle_fatal_exception(e); + exit_after_fatal_exception(e); + } catch (...) { + exit_after_unknown_fatal_exception(); } } - /// \brief Called upon normal program termination. - static void on_exit_handler() { - ConsoleApplication::get_instance().shutdown(0); - } - -# if defined(_WIN32) || defined(_WIN64) - - /// \brief Handles Windows console events and triggers application shutdown. - /// \param win_event Windows console event code. - /// \return FALSE to allow further processing by the system. - static BOOL WINAPI console_handler(DWORD win_event) { - switch (win_event) { - case CTRL_C_EVENT: - log_event("CTRL_C_EVENT"); - break; - case CTRL_CLOSE_EVENT: - log_event("CTRL_CLOSE_EVENT"); - break; - case CTRL_LOGOFF_EVENT: - log_event("CTRL_LOGOFF_EVENT"); - break; - case CTRL_SHUTDOWN_EVENT: - log_event("CTRL_SHUTDOWN_EVENT"); - break; - default: - log_event("UNKNOWN_EVENT"); - break; + void exit_if_stopping() { + if (m_runner.is_stop_requested()) { + std::exit(m_runner.run_for_exit_code()); } - ConsoleApplication::get_instance().shutdown(event_to_exit_code(win_event)); - return FALSE; } - /// \brief Logs a Windows console event. - /// \param event_name Name of the console event. - static void log_event(const char* event_name) { + void exit_after_fatal_exception(const std::exception& e) { # if CONSOLIX_USE_LOGIT == 1 - LOGIT_PRINT_INFO("Console event received: ", event_name); + LOGIT_PRINT_FATAL("Unhandled exception: ", e.what()); +# else + (void)e; # endif + m_runner.request_stop(-1); + std::exit(m_runner.run_for_exit_code()); } - /// \brief Maps a Windows console event to a corresponding POSIX exit code. - /// \param win_event Windows console event code. - /// \return Corresponding POSIX exit code. - static int event_to_exit_code(DWORD win_event) { - switch (win_event) { - case CTRL_C_EVENT: - return SIGINT; - default: - return SIGTERM; - }; - } - -# else - - class PosixTerminationSignalMaskGuard { - public: - PosixTerminationSignalMaskGuard() { - sigemptyset(&m_mask); - sigaddset(&m_mask, SIGINT); - sigaddset(&m_mask, SIGTERM); - - if (sigprocmask(SIG_BLOCK, &m_mask, &m_old_mask) == 0) { - m_active = true; - } - } - - ~PosixTerminationSignalMaskGuard() { - if (m_active) { - sigprocmask(SIG_SETMASK, &m_old_mask, nullptr); - } - } - - private: - sigset_t m_mask{}; - sigset_t m_old_mask{}; - bool m_active{false}; - }; - - /// \brief Handles a POSIX signal by recording a deferred shutdown request. - /// \param exit_code POSIX signal code. - static void signal_handler(int exit_code) { - pending_signal_code() = static_cast(exit_code); - signal_stop_requested() = 1; - } - - /// \brief Resets POSIX signal state before installing handlers. - static void reset_signal_state() { - pending_signal_code() = 0; - signal_stop_requested() = 0; - } - - /// \brief Checks whether a POSIX signal requested shutdown and synchronizes the runtime state. - bool stop_requested() { - if (signal_stop_requested() != 0) { - m_stopping = true; - } - return m_stopping.load(); - } - - /// \brief Resolves the exit code for the active stop request. - int resolve_stop_exit_code(int fallback_exit_code) const { - const int pending_exit_code = static_cast(pending_signal_code()); - if (pending_exit_code != 0) { - return pending_exit_code; - } - return fallback_exit_code; - } - - /// \brief Executes cleanup immediately if a stop request is already pending. - void cleanup_if_stopping() { - if (stop_requested()) { - cleanup(resolve_stop_exit_code(0)); - } - } - - /// \brief Storage for the last requested POSIX shutdown signal. - static volatile std::sig_atomic_t& pending_signal_code() { - static volatile std::sig_atomic_t value = 0; - return value; - } - - /// \brief Storage for the POSIX stop-request flag. - static volatile std::sig_atomic_t& signal_stop_requested() { - static volatile std::sig_atomic_t value = 0; - return value; - } - -# endif - -# if defined(_WIN32) || defined(_WIN64) - bool stop_requested() { - return m_stopping.load(); - } - - int resolve_stop_exit_code(int fallback_exit_code) const { - return fallback_exit_code; + void exit_after_unknown_fatal_exception() { +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_PRINT_FATAL("Unhandled unknown exception"); +# endif + m_runner.request_stop(-1); + std::exit(m_runner.run_for_exit_code()); } - void cleanup_if_stopping() { - if (m_stopping) { - cleanup(0); - } + ConsoleApplication() : + m_runner(m_manager) { } -# endif - - ConsoleApplication() = default; ~ConsoleApplication() = default; - // Deleting copy and move constructors and assignment operators to enforce singleton. ConsoleApplication(const ConsoleApplication&) = delete; ConsoleApplication& operator=(const ConsoleApplication&) = delete; ConsoleApplication(ConsoleApplication&&) = delete; diff --git a/include/consolix/core/ConsoleApplicationRunner.hpp b/include/consolix/core/ConsoleApplicationRunner.hpp new file mode 100644 index 0000000..692b6d3 --- /dev/null +++ b/include/consolix/core/ConsoleApplicationRunner.hpp @@ -0,0 +1,426 @@ +#pragma once +#ifndef _CONSOLIX_CONSOLE_APPLICATION_RUNNER_HPP_INCLUDED +#define _CONSOLIX_CONSOLE_APPLICATION_RUNNER_HPP_INCLUDED + +/// \file ConsoleApplicationRunner.hpp +/// \brief Runs Consolix components and returns an exit code without calling std::exit. +/// \ingroup Core + +#include "../config_macros.hpp" +#include "../interfaces.hpp" +#include "platform_includes.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if CONSOLIX_USE_LOGIT == 1 +#include +#endif + +#include "ServiceLocator.hpp" +#include "AppComponentManager.hpp" + +#if !defined(_WIN32) && !defined(_WIN64) +#include +#endif + +namespace consolix { + + /// \class ConsoleApplicationRunner + /// \brief Component runner that performs the Consolix lifecycle and returns an exit code. + /// + /// The runner initializes components, processes them until a stop request is observed, + /// shuts them down, clears shared services, shuts down LogIt when enabled, and returns + /// the resolved exit code. It never calls `std::exit`. + /// + /// On Windows, Ctrl+C/Ctrl+Break only request a cooperative stop. Close, logoff, + /// and shutdown console events request stop and wait briefly for this runner to + /// complete cleanup on the runner thread. + class ConsoleApplicationRunner { + public: + /// \brief Constructs a runner over an existing component manager. + /// \param manager The component manager to drive. + explicit ConsoleApplicationRunner(AppComponentManager& manager) : + m_manager(manager) { + } + + /// \brief Runs components until a stop request and returns the exit code. + /// \return The requested exit code, a signal code, or a non-zero fatal error code. + int run_for_exit_code() { + return run_for_exit_code(NoIterationAction()); + } + + /// \brief Runs components with a custom action after every process iteration. + /// \tparam IterationAction Callable invoked after each `process()` pass. + /// \param iteration_action The per-iteration action. + /// \return The requested exit code, a signal code, or a non-zero fatal error code. + template + int run_for_exit_code(IterationAction iteration_action) { + if (m_running.exchange(true)) { + return requested_exit_code(); + } + + m_shutdown_complete.store(false); + ActiveRunnerGuard active_runner_guard(*this); + setup_signal_handlers(); + + int exit_code = 0; + try { + initialize_components(); + while (!stop_requested()) { + m_manager.process(); + iteration_action(); + } + exit_code = requested_exit_code(); + } catch (const std::exception& e) { + exit_code = handle_fatal_exception(e); + } catch (...) { + exit_code = handle_unknown_fatal_exception(); + } + + exit_code = cleanup(exit_code); + mark_shutdown_complete(); + m_running.store(false); + return exit_code; + } + + /// \brief Requests the runner to stop and return the provided exit code. + /// \param exit_code Exit code to return and pass to component shutdown. + void request_stop(int exit_code = 0) { + bool expected = false; + if (m_stopping.compare_exchange_strong(expected, true)) { + m_requested_exit_code.store(exit_code); + } + m_stop_requested_cv.notify_all(); + } + + /// \brief Requests stop and waits for runner-thread cleanup to finish. + /// \param exit_code Exit code to pass to shutdown and return from the runner. + /// \param timeout Maximum time to wait for cleanup completion. + /// \return `true` if cleanup completed before the timeout. + bool request_forced_stop( + int exit_code, + std::chrono::milliseconds timeout) { + request_stop(exit_code); + if (is_shutdown_complete()) { + return true; + } + if (!is_running()) { + return false; + } + return wait_for_shutdown(timeout); + } + + /// \brief Requests a normal zero-code stop. + void stop() { + request_stop(0); + } + + /// \brief Checks whether this runner has received a stop request. + /// \return `true` when the runner should leave the process loop. + bool is_stop_requested() { + return stop_requested(); + } + + /// \brief Checks whether this runner is currently executing. + /// \return `true` while `run_for_exit_code()` is active. + bool is_running() const { + return m_running.load(); + } + + /// \brief Checks whether cleanup has completed for the active run. + /// \return `true` after shutdown, service cleanup, and logger shutdown finish. + bool is_shutdown_complete() const { + return m_shutdown_complete.load(); + } + + /// \brief Requests stop on the currently active runner, if one exists. + /// \param exit_code Exit code to request. + /// \return `true` if an active runner accepted the stop request. + static bool request_current_stop(int exit_code = 0) { + ConsoleApplicationRunner* runner = current_runner().load(); + if (!runner) { + return false; + } + runner->request_stop(exit_code); + return true; + } + + /// \brief Requests forced stop on the currently active runner, if one exists. + /// \param exit_code Exit code to request. + /// \param timeout Maximum time to wait for runner-thread cleanup. + /// \return `true` if an active runner completed cleanup before timeout. + static bool request_current_forced_stop( + int exit_code, + std::chrono::milliseconds timeout) { + ConsoleApplicationRunner* runner = current_runner().load(); + if (!runner) { + return false; + } + return runner->request_forced_stop(exit_code, timeout); + } + + private: + struct NoIterationAction { + void operator()() const {} + }; + + class ActiveRunnerGuard { + public: + explicit ActiveRunnerGuard(ConsoleApplicationRunner& runner) : + m_previous(current_runner().exchange(&runner)) { + } + + ~ActiveRunnerGuard() { + current_runner().store(m_previous); + } + + private: + ConsoleApplicationRunner* m_previous; + }; + + AppComponentManager& m_manager; + std::atomic m_running{false}; + std::atomic m_stopping{false}; + std::atomic m_cleanup{false}; + std::atomic m_shutdown_complete{false}; + std::atomic m_requested_exit_code{0}; + std::mutex m_shutdown_mutex; + std::condition_variable m_shutdown_complete_cv; + std::condition_variable m_stop_requested_cv; + + void setup_signal_handlers() { + reset_signal_state(); +# if defined(_WIN32) || defined(_WIN64) + SetConsoleCtrlHandler(console_handler, TRUE); + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); +# else + struct sigaction action = {}; + action.sa_handler = signal_handler; + sigemptyset(&action.sa_mask); + sigaddset(&action.sa_mask, SIGINT); + sigaddset(&action.sa_mask, SIGTERM); + action.sa_flags = 0; + + sigaction(SIGINT, &action, nullptr); + sigaction(SIGTERM, &action, nullptr); +# endif + } + + void initialize_components() { + while (!stop_requested() && !m_manager.initialize()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + bool stop_requested() { + synchronize_signal_stop_request(); + return m_stopping.load(); + } + + int requested_exit_code() { + synchronize_signal_stop_request(); + return m_requested_exit_code.load(); + } + + void synchronize_signal_stop_request() { + if (signal_stop_requested() != 0) { + const int exit_code = static_cast(pending_signal_code()); + request_stop(exit_code); + } + } + + bool wait_for_shutdown(std::chrono::milliseconds timeout) { + std::unique_lock lock(m_shutdown_mutex); + return m_shutdown_complete_cv.wait_for( + lock, + timeout, + [this]() { + return m_shutdown_complete.load(); + }); + } + + void mark_shutdown_complete() { + m_shutdown_complete.store(true); + m_shutdown_complete_cv.notify_all(); + } + + int cleanup(int exit_code) { + bool expected = false; + if (!m_cleanup.compare_exchange_strong(expected, true)) { + return exit_code; + } + + m_stopping.store(true); + bool cleanup_failed = false; + +# if !defined(_WIN32) && !defined(_WIN64) + PosixTerminationSignalMaskGuard posix_signal_mask_guard; +# endif + +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_PRINT_INFO("Cleaning up application for exit code: ", exit_code); +# endif + + try { + m_manager.shutdown(exit_code); + } catch (const std::exception& e) { + cleanup_failed = true; + log_fatal_exception("Shutdown error: ", e); + } catch (...) { + cleanup_failed = true; + log_fatal_message("Shutdown error: unknown exception"); + } + + try { + ServiceLocator::get_instance().clear_all(); + } catch (const std::exception& e) { + cleanup_failed = true; + log_fatal_exception("Service cleanup error: ", e); + } catch (...) { + cleanup_failed = true; + log_fatal_message("Service cleanup error: unknown exception"); + } + +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_SHUTDOWN(); +# endif + + if (cleanup_failed && exit_code == 0) { + return fatal_exit_code(); + } + return exit_code; + } + + int handle_fatal_exception(const std::exception& e) { + log_fatal_exception("Unhandled exception: ", e); + force_stop(fatal_exit_code()); + return fatal_exit_code(); + } + + int handle_unknown_fatal_exception() { + log_fatal_message("Unhandled unknown exception"); + force_stop(fatal_exit_code()); + return fatal_exit_code(); + } + + void force_stop(int exit_code) { + m_requested_exit_code.store(exit_code); + m_stopping.store(true); + } + + static int fatal_exit_code() { + return -1; + } + + static void log_fatal_exception(const char* prefix, const std::exception& e) { +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_PRINT_FATAL(prefix, e.what()); +# else + (void)prefix; + (void)e; +# endif + } + + static void log_fatal_message(const char* message) { +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_PRINT_FATAL(message); +# else + (void)message; +# endif + } + + static std::atomic& current_runner() { + static std::atomic value{nullptr}; + return value; + } + + static void signal_handler(int exit_code) { + pending_signal_code() = static_cast(exit_code); + signal_stop_requested() = 1; + } + + static void reset_signal_state() { + pending_signal_code() = 0; + signal_stop_requested() = 0; + } + + static volatile std::sig_atomic_t& pending_signal_code() { + static volatile std::sig_atomic_t value = 0; + return value; + } + + static volatile std::sig_atomic_t& signal_stop_requested() { + static volatile std::sig_atomic_t value = 0; + return value; + } + +# if defined(_WIN32) || defined(_WIN64) + static BOOL WINAPI console_handler(DWORD win_event) { + switch (win_event) { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + request_current_stop(SIGINT); + return TRUE; + + case CTRL_CLOSE_EVENT: + case CTRL_LOGOFF_EVENT: + case CTRL_SHUTDOWN_EVENT: + request_current_forced_stop( + SIGTERM, + forced_shutdown_timeout()); + return TRUE; + + default: + request_current_forced_stop( + SIGTERM, + forced_shutdown_timeout()); + return TRUE; + } + } + + static std::chrono::milliseconds forced_shutdown_timeout() { + return std::chrono::milliseconds(CONSOLIX_FORCED_SHUTDOWN_TIMEOUT_MS); + } + +# else + class PosixTerminationSignalMaskGuard { + public: + PosixTerminationSignalMaskGuard() { + sigemptyset(&m_mask); + sigaddset(&m_mask, SIGINT); + sigaddset(&m_mask, SIGTERM); + + if (sigprocmask(SIG_BLOCK, &m_mask, &m_old_mask) == 0) { + m_active = true; + } + } + + ~PosixTerminationSignalMaskGuard() { + if (m_active) { + sigprocmask(SIG_SETMASK, &m_old_mask, nullptr); + } + } + + private: + sigset_t m_mask{}; + sigset_t m_old_mask{}; + bool m_active{false}; + }; +# endif + }; // ConsoleApplicationRunner + +} // namespace consolix + +#endif // _CONSOLIX_CONSOLE_APPLICATION_RUNNER_HPP_INCLUDED diff --git a/include/consolix/core/application_utils.hpp b/include/consolix/core/application_utils.hpp index abc63c1..778ba0f 100644 --- a/include/consolix/core/application_utils.hpp +++ b/include/consolix/core/application_utils.hpp @@ -74,10 +74,39 @@ namespace consolix { ConsoleApplication::get_instance().run(iteration_action); } + /// \brief Runs the application and returns an exit code without calling std::exit. + /// \return Exit code requested by the application lifecycle. + inline int run_for_exit_code() { + return ConsoleApplication::get_instance().run_for_exit_code(); + } + + /// \brief Runs the application with a custom loop action and returns an exit code. + /// \tparam IterationAction A callable type for the custom loop action. + /// \param iteration_action The action to execute in the main loop. + /// \return Exit code requested by the application lifecycle. + template + inline int run_for_exit_code(IterationAction iteration_action) { + return ConsoleApplication::get_instance().run_for_exit_code(iteration_action); + } + + /// \brief Requests application shutdown with an explicit exit code. + /// \param exit_code Exit code to pass to shutdown and return from exit-code runners. + inline void request_stop(int exit_code) { + if (!ConsoleApplicationRunner::request_current_stop(exit_code)) { + ConsoleApplication::get_instance().request_stop(exit_code); + } + } + /// \brief Stops the application. /// Stops the application's main loop and begins the shutdown process. inline void stop() { - ConsoleApplication::get_instance().stop(); + request_stop(0); + } + + /// \brief Stops the application with an explicit exit code. + /// \param exit_code Exit code to pass to shutdown and return from exit-code runners. + inline void stop(int exit_code) { + request_stop(exit_code); } }; // namespace consolix diff --git a/tests/test_console_application_runner.cpp b/tests/test_console_application_runner.cpp new file mode 100644 index 0000000..e5218f2 --- /dev/null +++ b/tests/test_console_application_runner.cpp @@ -0,0 +1,360 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +struct ScenarioState { + int initialize_calls = 0; + int process_calls = 0; + int shutdown_calls = 0; + int shutdown_code = 0; +}; + +struct CountingService { + explicit CountingService(int& destroyed) : + destroyed_count(&destroyed) { + } + + ~CountingService() { + ++(*destroyed_count); + } + + int* destroyed_count; +}; + +void expect(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +class NormalStopComponent final : public consolix::BaseLoopComponent { +public: + explicit NormalStopComponent(ScenarioState& state) : + m_state(state) { + } + + bool on_once() override { + ++m_state.initialize_calls; + return true; + } + + void on_loop() override { + ++m_state.process_calls; + consolix::stop(); + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + } + +private: + ScenarioState& m_state; +}; + +class ExitCodeStopComponent final : public consolix::BaseLoopComponent { +public: + ExitCodeStopComponent(ScenarioState& state, int exit_code) : + m_state(state), + m_exit_code(exit_code) { + } + + bool on_once() override { + ++m_state.initialize_calls; + return true; + } + + void on_loop() override { + ++m_state.process_calls; + consolix::stop(m_exit_code); + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + } + +private: + ScenarioState& m_state; + int m_exit_code; +}; + +class SignalStopComponent final : public consolix::BaseLoopComponent { +public: + explicit SignalStopComponent(ScenarioState& state) : + m_state(state) { + } + + bool on_once() override { + ++m_state.initialize_calls; + return true; + } + + void on_loop() override { + ++m_state.process_calls; + if (std::raise(SIGTERM) != 0) { + throw std::runtime_error("std::raise(SIGTERM) failed"); + } + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + } + +private: + ScenarioState& m_state; +}; + +class ThrowingComponent final : public consolix::BaseLoopComponent { +public: + explicit ThrowingComponent(ScenarioState& state) : + m_state(state) { + } + + bool on_once() override { + ++m_state.initialize_calls; + return true; + } + + void on_loop() override { + ++m_state.process_calls; + throw std::runtime_error("intentional process failure"); + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + } + +private: + ScenarioState& m_state; +}; + +class ForcedStopComponent final : public consolix::BaseLoopComponent { +public: + ForcedStopComponent( + ScenarioState& state, + std::atomic& ready, + std::thread::id& shutdown_thread_id) : + m_state(state), + m_ready(ready), + m_shutdown_thread_id(shutdown_thread_id) { + } + + bool on_once() override { + ++m_state.initialize_calls; + m_ready.store(true); + return true; + } + + void on_loop() override { + ++m_state.process_calls; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + m_shutdown_thread_id = std::this_thread::get_id(); + } + +private: + ScenarioState& m_state; + std::atomic& m_ready; + std::thread::id& m_shutdown_thread_id; +}; + +class BlockingProcessComponent final : public consolix::BaseLoopComponent { +public: + BlockingProcessComponent( + ScenarioState& state, + std::atomic& process_entered, + std::atomic& release_process) : + m_state(state), + m_process_entered(process_entered), + m_release_process(release_process) { + } + + bool on_once() override { + ++m_state.initialize_calls; + return true; + } + + void on_loop() override { + ++m_state.process_calls; + m_process_entered.store(true); + while (!m_release_process.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + } + +private: + ScenarioState& m_state; + std::atomic& m_process_entered; + std::atomic& m_release_process; +}; + +void run_normal_stop_scenario() { + ScenarioState state; + int service_destroyed = 0; + + consolix::ServiceLocator::get_instance().register_service( + [&service_destroyed]() { + return std::make_shared(service_destroyed); + }); + + consolix::AppComponentManager manager; + manager.add(state); + + consolix::ConsoleApplicationRunner runner(manager); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code == 0, "normal stop must return 0"); + expect(state.initialize_calls == 1, "normal stop must initialize once"); + expect(state.process_calls == 1, "normal stop must process once"); + expect(state.shutdown_calls == 1, "normal stop must shutdown once"); + expect(state.shutdown_code == 0, "normal stop must shutdown with code 0"); + expect(!consolix::ServiceLocator::get_instance().has_service(), + "runner must clear services"); + expect(service_destroyed == 1, "runner must clear services exactly once"); +} + +void run_component_exit_code_scenario() { + ScenarioState state; + consolix::AppComponentManager manager; + manager.add(state, 42); + + consolix::ConsoleApplicationRunner runner(manager); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code == 42, "stop(int) must return requested code"); + expect(state.shutdown_calls == 1, "stop(int) must shutdown once"); + expect(state.shutdown_code == 42, "stop(int) must pass code to shutdown"); +} + +void run_signal_stop_scenario() { + ScenarioState state; + consolix::AppComponentManager manager; + manager.add(state); + + consolix::ConsoleApplicationRunner runner(manager); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code == SIGTERM, "signal stop must return signal code"); + expect(state.shutdown_calls == 1, "signal stop must shutdown once"); + expect(state.shutdown_code == SIGTERM, "signal stop must pass signal to shutdown"); +} + +void run_exception_scenario() { + ScenarioState state; + consolix::AppComponentManager manager; + manager.add(state); + + consolix::ConsoleApplicationRunner runner(manager); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code != 0, "exception path must return a non-zero exit code"); + expect(state.shutdown_calls == 1, "exception path must shutdown once"); + expect(state.shutdown_code != 0, "exception path must pass non-zero code to shutdown"); +} + +void run_forced_stop_scenario() { + ScenarioState state; + std::atomic ready(false); + std::thread::id runner_thread_id; + std::thread::id shutdown_thread_id; + int runner_exit_code = -1; + + consolix::AppComponentManager manager; + manager.add(state, ready, shutdown_thread_id); + + consolix::ConsoleApplicationRunner runner(manager); + std::thread runner_thread([&]() { + runner_thread_id = std::this_thread::get_id(); + runner_exit_code = runner.run_for_exit_code(); + }); + + while (!ready.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + const bool completed = runner.request_forced_stop( + 24, + std::chrono::milliseconds(2000)); + + runner_thread.join(); + + expect(completed, "forced stop must wait for runner cleanup"); + expect(runner_exit_code == 24, "forced stop must return requested code"); + expect(state.shutdown_calls == 1, "forced stop must shutdown once"); + expect(state.shutdown_code == 24, "forced stop must pass code to shutdown"); + expect(shutdown_thread_id == runner_thread_id, + "forced stop cleanup must run on runner thread"); + expect(shutdown_thread_id != std::this_thread::get_id(), + "forced stop cleanup must not run on requester thread"); +} + +void run_forced_stop_timeout_scenario() { + ScenarioState state; + std::atomic process_entered(false); + std::atomic release_process(false); + int runner_exit_code = -1; + + consolix::AppComponentManager manager; + manager.add(state, process_entered, release_process); + + consolix::ConsoleApplicationRunner runner(manager); + std::thread runner_thread([&]() { + runner_exit_code = runner.run_for_exit_code(); + }); + + while (!process_entered.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + const bool completed = runner.request_forced_stop( + 33, + std::chrono::milliseconds(20)); + + expect(!completed, "forced stop must report timeout while process is blocked"); + + release_process.store(true); + runner_thread.join(); + + expect(runner_exit_code == 33, "timed-out forced stop must still request code"); + expect(state.shutdown_calls == 1, "timed-out forced stop must later shutdown once"); + expect(state.shutdown_code == 33, "timed-out forced stop must pass code to shutdown"); +} + +} // namespace + +int main() { + try { + run_normal_stop_scenario(); + run_component_exit_code_scenario(); + run_signal_stop_scenario(); + run_exception_scenario(); + run_forced_stop_scenario(); + run_forced_stop_timeout_scenario(); + + std::cout << "ConsoleApplicationRunner checks passed." << std::endl; + return 0; + } catch (const std::exception& e) { + std::cerr << "ConsoleApplicationRunner test failed: " << e.what() << std::endl; + return 1; + } +} From 4aeb40be27db10699ac74d4d2c9b13972934702c Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 13:57:48 +0300 Subject: [PATCH 2/6] add wakeable loop throttle component --- CMakeLists.txt | 5 + README-RU.md | 22 ++++ README.md | 22 ++++ docs/mainpage.dox | 23 ++++ include/consolix/components.hpp | 3 + .../components/LoopThrottleComponent.hpp | 112 ++++++++++++++++++ tests/test_loop_throttle_component.cpp | 87 ++++++++++++++ 7 files changed, 274 insertions(+) create mode 100644 include/consolix/components/LoopThrottleComponent.hpp create mode 100644 tests/test_loop_throttle_component.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 39f4168..222e099 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,11 @@ if(CONSOLIX_BUILD_TESTS) set_tests_properties(test_console_application_runner PROPERTIES TIMEOUT 15) endif() + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_loop_throttle_component.cpp") + consolix_add_test(test_loop_throttle_component "tests/test_loop_throttle_component.cpp") + set_tests_properties(test_loop_throttle_component PROPERTIES TIMEOUT 15) + endif() + if(UNIX AND NOT WIN32 AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_posix_signal_shutdown.cpp") consolix_add_test(test_posix_signal_shutdown "tests/test_posix_signal_shutdown.cpp") target_compile_definitions(test_posix_signal_shutdown PRIVATE CONSOLIX_WAIT_ON_ERROR=0) diff --git a/README-RU.md b/README-RU.md index 7caf5ea..7b003ef 100644 --- a/README-RU.md +++ b/README-RU.md @@ -178,6 +178,28 @@ int main() { shutdown events запрашивают shutdown на runner thread и ждут ограниченное окно для cleanup. +### Main loop and CPU usage + +Consolix выполняет компоненты в polling loop и по умолчанию не делает sleep +между итерациями. Если все компоненты сразу возвращаются из `process()`, процесс +может тратить слишком много CPU. Для latency-sensitive приложений это +намеренно: framework не прячет implicit delay внутри runner. + +Для обычных сервисов и утилит добавьте throttle component ближе к концу списка +компонентов: + +```cpp +auto throttle = consolix::add( + std::chrono::milliseconds(1)); + +// Другой поток может завершить ожидание раньше, когда добавил новую работу. +throttle->wake(); +``` + +`LoopThrottleComponent` ждет не дольше настроенного delay на каждом проходе +loop. `wake()` прерывает текущее ожидание, а wake request, сделанный до начала +ожидания, будет обработан на следующем проходе. + ## Documentation - developer guidelines: `docs/header-implementation-guidelines.md` diff --git a/README.md b/README.md index 1cb9d68..a954373 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,28 @@ internally it uses the same runner and exits with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown; close/logoff/shutdown events request shutdown on the runner thread and wait for a bounded cleanup window. +### Main Loop and CPU Usage + +Consolix runs components in a polling loop and does not sleep between +iterations by default. If all components return immediately, the process may +use excessive CPU. For latency-sensitive applications this is intentional: +the framework does not hide an implicit delay inside the runner. + +For ordinary services and tools, add a throttle component near the end of the +component list: + +```cpp +auto throttle = consolix::add( + std::chrono::milliseconds(1)); + +// Another thread can end the wait early when it posts new work. +throttle->wake(); +``` + +`LoopThrottleComponent` waits at most for the configured delay on every loop +pass. `wake()` interrupts the current wait, and wake requests made before the +wait begins are consumed by the next pass. + ## Diagnostic Streams Consolix provides two multi-target log macros that route messages through diff --git a/docs/mainpage.dox b/docs/mainpage.dox index 0d8bb8e..520e3da 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -46,6 +46,7 @@ It also targets the recurring runtime concerns of console tools: startup and shu - `ConfigComponent`: JSON-based configuration with comment support - `LogoComponent`: displays a custom ASCII logo in the console - `LoopComponent`: customizable application loops + - `LoopThrottleComponent`: optional wakeable wait step for polling loops - **Utilities**: - `path_utils.hpp`: path and executable-location helpers @@ -109,6 +110,28 @@ exits the process with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown, while close/logoff/shutdown console events request cleanup on the runner thread and wait for a bounded timeout. +### Main Loop and CPU Usage + +Consolix runs components in a polling loop and does not sleep between iterations +by default. If every component returns immediately, the process may use +excessive CPU. This keeps the runner explicit and avoids adding hidden latency +for low-latency applications. + +For ordinary services and tools, add `consolix::LoopThrottleComponent` near the +end of the component list: + +```cpp +auto throttle = consolix::add( + std::chrono::milliseconds(1)); + +// Producer threads can end the wait early when new work arrives. +throttle->wake(); +``` + +The component waits at most for the configured delay on each pass. `wake()` +interrupts an active wait and preserves wake requests made just before the wait +begins. + ## Documentation Structure ### Main Sections diff --git a/include/consolix/components.hpp b/include/consolix/components.hpp index def8e93..47d54c7 100644 --- a/include/consolix/components.hpp +++ b/include/consolix/components.hpp @@ -31,6 +31,7 @@ /// - **ConfigComponent**: Loads and manages configuration from JSON files. /// - **BaseLoopComponent**: A base class for loop-based components. /// - **LoopComponent**: A component with customizable execution loops. +/// - **LoopThrottleComponent**: A wakeable wait step for throttling polling loops. /// - **EventHubComponent**: Optional bridge to event-hub-cpp EventBus and TaskManager. /// - **ModuleHubComponent**: Optional bridge to event-hub-cpp ModuleHub. /// @@ -57,6 +58,7 @@ /// - `components/ConfigComponent.hpp` /// - `components/BaseLoopComponent.hpp` /// - `components/LoopComponent.hpp` +/// - `components/LoopThrottleComponent.hpp` /// - `components/EventHubComponent.hpp` when `CONSOLIX_USE_EVENT_HUB=1` /// - `components/ModuleHubComponent.hpp` when `CONSOLIX_USE_EVENT_HUB=1` /// @@ -104,6 +106,7 @@ #include "components/LogoComponent.hpp" ///< Component for rendering logos in the console. #include "components/BaseLoopComponent.hpp" ///< Base class for implementing loop-based components. #include "components/LoopComponent.hpp" ///< Component with configurable initialization, loop, and shutdown callbacks. +#include "components/LoopThrottleComponent.hpp" ///< Wakeable throttle component for polling loops. #if CONSOLIX_USE_EVENT_HUB == 1 #include "components/EventHubComponent.hpp" ///< Optional event-hub-cpp EventBus/TaskManager integration component. diff --git a/include/consolix/components/LoopThrottleComponent.hpp b/include/consolix/components/LoopThrottleComponent.hpp new file mode 100644 index 0000000..658a229 --- /dev/null +++ b/include/consolix/components/LoopThrottleComponent.hpp @@ -0,0 +1,112 @@ +#pragma once +#ifndef _CONSOLIX_LOOP_THROTTLE_COMPONENT_HPP_INCLUDED +#define _CONSOLIX_LOOP_THROTTLE_COMPONENT_HPP_INCLUDED + +/// \file LoopThrottleComponent.hpp +/// \brief Defines a wakeable component for throttling polling application loops. +/// \ingroup Components + +#include +#include +#include +#include + +namespace consolix { + + /// \class LoopThrottleComponent + /// \brief Adds an explicit wakeable wait step to the Consolix polling loop. + /// + /// Consolix processes registered components in a loop. If every component returns + /// immediately, the application can become a busy loop. Register this component, + /// preferably after active work components, to pause each pass for a bounded time. + /// + /// Another thread can call `wake()` to end the wait early when new work arrives. + class LoopThrottleComponent : public IAppComponent { + public: + /// \brief Constructs a throttle component with a short default delay. + LoopThrottleComponent() = default; + + /// \brief Constructs a throttle component with a custom delay. + /// \param delay Maximum time to wait during each process pass. + explicit LoopThrottleComponent(std::chrono::milliseconds delay) : + m_delay(delay) { + } + + /// \brief Virtual destructor. + virtual ~LoopThrottleComponent() override { + wake(); + } + + /// \brief Wakes the component if it is currently waiting. + /// + /// A wake request made before `process()` starts is remembered and consumed by + /// the next process pass, so producer threads can signal work without racing + /// the exact wait window. + void wake() { + { + std::lock_guard lock(m_mutex); + m_wake_requested = true; + } + m_condition.notify_all(); + } + + /// \brief Changes the throttle delay and wakes any active wait. + /// \param delay New maximum wait duration per process pass. + void set_delay(std::chrono::milliseconds delay) { + { + std::lock_guard lock(m_mutex); + m_delay = delay; + m_wake_requested = true; + } + m_condition.notify_all(); + } + + /// \brief Returns the currently configured throttle delay. + /// \return Maximum wait duration per process pass. + std::chrono::milliseconds delay() const { + std::lock_guard lock(m_mutex); + return m_delay; + } + + protected: + bool initialize() override { + m_is_initialized.store(true); + return true; + } + + bool is_initialized() const override { + return m_is_initialized.load(); + } + + void process() override { + std::unique_lock lock(m_mutex); + if (m_wake_requested) { + m_wake_requested = false; + return; + } + + const std::chrono::milliseconds current_delay = m_delay; + if (current_delay <= std::chrono::milliseconds(0)) { + return; + } + + m_condition.wait_for( + lock, + current_delay, + [this]() { + return m_wake_requested; + }); + m_wake_requested = false; + } + + private: + mutable std::mutex m_mutex; + std::condition_variable m_condition; + std::chrono::milliseconds m_delay{std::chrono::milliseconds(1)}; + bool m_wake_requested{false}; + std::atomic m_is_initialized{false}; + }; // LoopThrottleComponent + +} // namespace consolix + +#endif // _CONSOLIX_LOOP_THROTTLE_COMPONENT_HPP_INCLUDED diff --git a/tests/test_loop_throttle_component.cpp b/tests/test_loop_throttle_component.cpp new file mode 100644 index 0000000..8654b81 --- /dev/null +++ b/tests/test_loop_throttle_component.cpp @@ -0,0 +1,87 @@ +#include +#include +#include +#include + +#include + +namespace { + +void expect(bool condition, const char* message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +long long elapsed_ms(std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); +} + +void run_pre_wake_scenario() { + consolix::AppComponentManager manager; + auto throttle = manager.add( + std::chrono::milliseconds(1000)); + + expect(manager.initialize(), "LoopThrottleComponent did not initialize"); + + throttle->wake(); + + const auto start = std::chrono::steady_clock::now(); + manager.process(); + + expect(elapsed_ms(start) < 500, + "LoopThrottleComponent did not consume a pre-wake request"); +} + +void run_active_wake_scenario() { + consolix::AppComponentManager manager; + auto throttle = manager.add( + std::chrono::milliseconds(1000)); + + expect(manager.initialize(), "LoopThrottleComponent did not initialize"); + + const auto start = std::chrono::steady_clock::now(); + std::thread worker([&manager]() { + manager.process(); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + throttle->wake(); + worker.join(); + + expect(elapsed_ms(start) < 500, + "LoopThrottleComponent wake did not end the active wait"); +} + +void run_set_delay_scenario() { + consolix::AppComponentManager manager; + auto throttle = manager.add( + std::chrono::milliseconds(1000)); + + expect(manager.initialize(), "LoopThrottleComponent did not initialize"); + + throttle->set_delay(std::chrono::milliseconds(0)); + + const auto start = std::chrono::steady_clock::now(); + manager.process(); + + expect(elapsed_ms(start) < 500, + "LoopThrottleComponent zero delay should not wait"); +} + +} // namespace + +int main() { + try { + run_pre_wake_scenario(); + run_active_wake_scenario(); + run_set_delay_scenario(); + + std::cout << "LoopThrottleComponent checks passed." << std::endl; + return 0; + } catch (const std::exception& e) { + std::cerr << "LoopThrottleComponent test failed: " << e.what() << std::endl; + return 1; + } +} From 3259c1e45aad46cc5582a0ce1c429fea343505eb Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 20:35:54 +0300 Subject: [PATCH 3/6] docs: add loop throttle example --- README-RU.md | 28 +++++ README.md | 27 +++++ docs/mainpage.dox | 26 +++++ examples/example_loop_throttle_component.cpp | 114 +++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 examples/example_loop_throttle_component.cpp diff --git a/README-RU.md b/README-RU.md index 7b003ef..a545cb9 100644 --- a/README-RU.md +++ b/README-RU.md @@ -178,6 +178,33 @@ int main() { shutdown events запрашивают shutdown на runner thread и ждут ограниченное окно для cleanup. +### Порядок shutdown и финальные hooks + +`AppComponentManager` завершает компоненты в обратном порядке регистрации. После +всех component shutdown callbacks runner очищает `ServiceLocator`, а затем +закрывает LogIt. Это значит, что services и logging еще доступны, пока +выполняются component `shutdown()` callbacks. + +Если приложению нужен финальный cleanup hook после остановки обычных +компонентов, но до `ServiceLocator::clear_all()`, зарегистрируйте этот hook +раньше компонентов, которые им пользуются: + +```cpp +consolix::add( + []() { return true; }, + []() {}, + [](int exit_code) { + // Финальный cleanup, пока services и LogIt еще живы. + }); + +consolix::add(); +``` + +Так как shutdown идет в LIFO-порядке, `RealComponent` завершится первым, а hook +выполнится после него. Такой component-level паттерн лучше runner-level +shutdown hooks: cleanup остается в той же lifecycle-модели, что и остальное +приложение. + ### Main loop and CPU usage Consolix выполняет компоненты в polling loop и по умолчанию не делает sleep @@ -206,4 +233,5 @@ loop. `wake()` прерывает текущее ожидание, а wake reque - agent playbook: `guides/header-implementation-guidelines.md` - lifecycle example: `examples/example_shutdown_and_resources.cpp` - exit-code runner example: `examples/example_exit_code_runner.cpp` +- loop throttle example: `examples/example_loop_throttle_component.cpp` - API docs: https://newyaroslav.github.io/Consolix/ diff --git a/README.md b/README.md index a954373..2ea5b15 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,32 @@ internally it uses the same runner and exits with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown; close/logoff/shutdown events request shutdown on the runner thread and wait for a bounded cleanup window. +### Shutdown Ordering and Final Hooks + +`AppComponentManager` shuts down components in reverse registration order. After +all component shutdown callbacks finish, the runner clears `ServiceLocator` and +then shuts down LogIt. This means services and logging are still available while +component `shutdown()` callbacks run. + +If an application needs a final cleanup hook after ordinary components stop, but +before `ServiceLocator::clear_all()`, register that hook before the components +that use it: + +```cpp +consolix::add( + []() { return true; }, + []() {}, + [](int exit_code) { + // Final cleanup while services and LogIt are still alive. + }); + +consolix::add(); +``` + +Because shutdown is LIFO, `RealComponent` shuts down first and the hook runs +after it. Prefer this component-level pattern over runner-level shutdown hooks +so cleanup stays in the same lifecycle model as the rest of the application. + ### Main Loop and CPU Usage Consolix runs components in a polling loop and does not sleep between @@ -242,6 +268,7 @@ Additional repository guidance: - agent playbook: `guides/header-implementation-guidelines.md` - lifecycle example: `examples/example_shutdown_and_resources.cpp` - exit-code runner example: `examples/example_exit_code_runner.cpp` +- loop throttle example: `examples/example_loop_throttle_component.cpp` - diagnostic streams: `examples/example_stderr_diagnostics.cpp` API documentation: https://newyaroslav.github.io/Consolix/ diff --git a/docs/mainpage.dox b/docs/mainpage.dox index 520e3da..34953d9 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -110,6 +110,32 @@ exits the process with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown, while close/logoff/shutdown console events request cleanup on the runner thread and wait for a bounded timeout. +### Shutdown Ordering and Final Hooks + +`AppComponentManager` shuts down components in reverse registration order. After +all component shutdown callbacks finish, the runner clears `ServiceLocator` and +then shuts down LogIt. Services and logging are therefore still available while +component `shutdown()` callbacks run. + +If an application needs a final cleanup hook after ordinary components stop, but +before `ServiceLocator::clear_all()`, register that hook before the components +that use it: + +```cpp +consolix::add( + []() { return true; }, + []() {}, + [](int exit_code) { + // Final cleanup while services and LogIt are still alive. + }); + +consolix::add(); +``` + +Because shutdown is LIFO, `RealComponent` shuts down first and the hook runs +after it. Prefer this component-level pattern over runner-level shutdown hooks +so cleanup stays in the same lifecycle model as the rest of the application. + ### Main Loop and CPU Usage Consolix runs components in a polling loop and does not sleep between iterations diff --git a/examples/example_loop_throttle_component.cpp b/examples/example_loop_throttle_component.cpp new file mode 100644 index 0000000..a143158 --- /dev/null +++ b/examples/example_loop_throttle_component.cpp @@ -0,0 +1,114 @@ +/// \example example_loop_throttle_component.cpp +/// \brief Demonstrates throttling the polling loop and waking it when work arrives. + +#include +#include +#include +#include +#include +#include + +#include + +class WakeableQueueDemo final : public consolix::BaseLoopComponent { +public: + explicit WakeableQueueDemo( + std::shared_ptr throttle) : + m_throttle(throttle) { + } + + virtual ~WakeableQueueDemo() override { + stop_producer(); + } + + bool on_once() override { + CONSOLIX_STREAM() << "Starting producer thread."; + m_producer = std::thread(&WakeableQueueDemo::producer_loop, this); + return true; + } + + void on_loop() override { + int item = 0; + while (try_pop(item)) { + CONSOLIX_STREAM() << "Processed queued item: " << item; + + if (++m_processed >= 5) { + CONSOLIX_STREAM() << "All queued items processed; stopping."; + if (m_throttle) { + m_throttle->wake(); + } + consolix::stop(); + return; + } + } + } + + void on_shutdown(int exit_code) override { + CONSOLIX_STREAM() << "Shutdown requested with code: " << exit_code; + stop_producer(); + } + +private: + void producer_loop() { + for (int item = 1; item <= 5 && !m_stop_producer.load(); ++item) { + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + if (m_stop_producer.load()) { + return; + } + + push(item); + + if (m_throttle) { + m_throttle->wake(); + } + } + } + + void push(int item) { + std::lock_guard lock(m_queue_mutex); + m_queue.push(item); + } + + bool try_pop(int& item) { + std::lock_guard lock(m_queue_mutex); + if (m_queue.empty()) { + return false; + } + + item = m_queue.front(); + m_queue.pop(); + return true; + } + + void stop_producer() { + m_stop_producer.store(true); + if (m_throttle) { + m_throttle->wake(); + } + + if (m_producer.joinable()) { + m_producer.join(); + } + } + + std::shared_ptr m_throttle; + std::thread m_producer; + std::mutex m_queue_mutex; + std::queue m_queue; + std::atomic m_stop_producer{false}; + int m_processed{0}; +}; + +int main() { + consolix::add(); + + auto throttle = std::make_shared( + std::chrono::milliseconds(500)); + + consolix::add(throttle); + + // Put the throttle near the end so active components run before the wait. + consolix::add(throttle); + + return consolix::run_for_exit_code(); +} From f16a5976114ec3d3c62f447ffad2c24d2e0cd79d Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 20:36:03 +0300 Subject: [PATCH 4/6] refactor: avoid parent-directory includes --- AGENTS.md | 5 +++++ docs/header-implementation-guidelines.md | 6 ++++++ guides/header-implementation-guidelines.md | 6 ++++++ include/consolix/core/ConsoleApplicationRunner.hpp | 2 -- include/consolix/utils/enums.hpp | 2 +- include/consolix/utils/path_utils.hpp | 4 ++-- 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3de3a51..1c68c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,11 @@ directly by users. Do not include internal leaf headers directly from user code unless the header explicitly documents itself as standalone. +Do not use parent-directory paths such as `../` in `#include` directives. +Aggregate-owned headers should receive their shared dependencies from the +owning umbrella header. Standalone public leaf headers should use forward +project include paths instead of walking upward through directories. + When adding a new internal header, prefer one of two clear modes: 1. **Standalone header**: include all direct dependencies explicitly; diff --git a/docs/header-implementation-guidelines.md b/docs/header-implementation-guidelines.md index b51c8bb..9dcba2e 100644 --- a/docs/header-implementation-guidelines.md +++ b/docs/header-implementation-guidelines.md @@ -129,6 +129,12 @@ That approach is appropriate when: Do not centralize dependencies merely to save a few include lines if the subsystem is designed around standalone leaf headers. +Avoid parent-directory include paths such as `../` in `#include` directives. +For aggregate-first subsystems, place cross-module prerequisites in the owning +aggregate header and include leaf headers only after that context is prepared. +For standalone public leaf headers, use forward project include paths instead of +walking upward through directories. + ### Implementation-only helpers Build-mode helpers, implementation macros, and similar infrastructure should stay at the implementation boundary. diff --git a/guides/header-implementation-guidelines.md b/guides/header-implementation-guidelines.md index fd69cfd..96276ae 100644 --- a/guides/header-implementation-guidelines.md +++ b/guides/header-implementation-guidelines.md @@ -79,6 +79,12 @@ Otherwise: - keep dependencies with the leaf header that owns the public contract +Do not use parent-directory paths such as `../` in `#include` directives. +For aggregate-first subsystems, prepare cross-module dependencies in the owning +aggregate header before including leaf headers. For standalone public leaf +headers, use forward project include paths instead of walking upward through +directories. + Implementation-only helpers such as build-mode macros should stay at the narrowest valid boundary: - preferably in the `.ipp` that uses them diff --git a/include/consolix/core/ConsoleApplicationRunner.hpp b/include/consolix/core/ConsoleApplicationRunner.hpp index 692b6d3..be9eed2 100644 --- a/include/consolix/core/ConsoleApplicationRunner.hpp +++ b/include/consolix/core/ConsoleApplicationRunner.hpp @@ -6,8 +6,6 @@ /// \brief Runs Consolix components and returns an exit code without calling std::exit. /// \ingroup Core -#include "../config_macros.hpp" -#include "../interfaces.hpp" #include "platform_includes.hpp" #include diff --git a/include/consolix/utils/enums.hpp b/include/consolix/utils/enums.hpp index 1867fb8..4f2e580 100644 --- a/include/consolix/utils/enums.hpp +++ b/include/consolix/utils/enums.hpp @@ -5,7 +5,7 @@ /// \file enums.hpp /// \brief Contains enumerations and utility functions for text colors. -#include "../core/platform_includes.hpp" +#include "consolix/core/platform_includes.hpp" #include diff --git a/include/consolix/utils/path_utils.hpp b/include/consolix/utils/path_utils.hpp index 959372e..4dbe3b3 100644 --- a/include/consolix/utils/path_utils.hpp +++ b/include/consolix/utils/path_utils.hpp @@ -5,8 +5,8 @@ /// \file path_utils.hpp /// \brief Utilities for working with file and directory paths, including resolving paths relative to the executable. -#include "../core/platform_includes.hpp" -#include "../core/std_compat.hpp" +#include "consolix/core/platform_includes.hpp" +#include "consolix/core/std_compat.hpp" #include "encoding_utils.hpp" #include From f3b6856633857a13c0953089833298b7a85b2e34 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 21:39:40 +0300 Subject: [PATCH 5/6] feat: wake throttled loops on stop --- README-RU.md | 7 +- README.md | 7 +- docs/mainpage.dox | 8 +- examples/example_loop_throttle_component.cpp | 3 - include/consolix/components.hpp | 1 + .../components/LoopThrottleComponent.hpp | 56 +++++++++++-- include/consolix/core.hpp | 3 + .../core/ConsoleApplicationRunner.hpp | 21 ++++- include/consolix/core/LoopWakeService.hpp | 80 +++++++++++++++++++ include/consolix/core/ServiceLocator.hpp | 32 ++++++++ include/consolix/core/service_utils.hpp | 16 ++++ tests/test_console_application_runner.cpp | 65 +++++++++++++++ 12 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 include/consolix/core/LoopWakeService.hpp diff --git a/README-RU.md b/README-RU.md index a545cb9..a124f9d 100644 --- a/README-RU.md +++ b/README-RU.md @@ -225,7 +225,12 @@ throttle->wake(); `LoopThrottleComponent` ждет не дольше настроенного delay на каждом проходе loop. `wake()` прерывает текущее ожидание, а wake request, сделанный до начала -ожидания, будет обработан на следующем проходе. +ожидания, будет обработан на следующем проходе. Когда приложение запущено через +`ConsoleApplicationRunner`, `consolix::stop()` и `consolix::request_stop()` +тоже будят throttle wait через общий `LoopWakeService`, поэтому shutdown не +ждет длинный throttle delay. Если вы запускаете `AppComponentManager` напрямую +без runner-а, держите delay коротким или вызывайте `wake()` из того же control +path, который запрашивает stop. ## Documentation diff --git a/README.md b/README.md index 2ea5b15..2e6c052 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,12 @@ throttle->wake(); `LoopThrottleComponent` waits at most for the configured delay on every loop pass. `wake()` interrupts the current wait, and wake requests made before the -wait begins are consumed by the next pass. +wait begins are consumed by the next pass. When the application is run through +`ConsoleApplicationRunner`, `consolix::stop()` and `consolix::request_stop()` +also wake throttle waits through the shared `LoopWakeService`, so shutdown does +not have to wait for a long throttle delay. If you drive `AppComponentManager` +directly without the runner, keep the delay short or call `wake()` from the +same control path that requests stop. ## Diagnostic Streams diff --git a/docs/mainpage.dox b/docs/mainpage.dox index 34953d9..ed722b8 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -37,6 +37,7 @@ It also targets the recurring runtime concerns of console tools: startup and shu - **Core**: - `ServiceLocator` for global resource management - `AppComponentManager`, `ConsoleApplication`, and `ConsoleApplicationRunner` for application lifecycle + - `LoopWakeService` for waking polling-loop wait components - service and application helpers for common setup flows - **Components**: @@ -156,7 +157,12 @@ throttle->wake(); The component waits at most for the configured delay on each pass. `wake()` interrupts an active wait and preserves wake requests made just before the wait -begins. +begins. When the application is run through `ConsoleApplicationRunner`, +`consolix::stop()` and `consolix::request_stop()` also wake throttle waits +through the shared `LoopWakeService`, so shutdown does not have to wait for a +long throttle delay. If you drive `AppComponentManager` directly without the +runner, keep the delay short or call `wake()` from the same control path that +requests stop. ## Documentation Structure diff --git a/examples/example_loop_throttle_component.cpp b/examples/example_loop_throttle_component.cpp index a143158..cc29c4c 100644 --- a/examples/example_loop_throttle_component.cpp +++ b/examples/example_loop_throttle_component.cpp @@ -34,9 +34,6 @@ class WakeableQueueDemo final : public consolix::BaseLoopComponent { if (++m_processed >= 5) { CONSOLIX_STREAM() << "All queued items processed; stopping."; - if (m_throttle) { - m_throttle->wake(); - } consolix::stop(); return; } diff --git a/include/consolix/components.hpp b/include/consolix/components.hpp index 47d54c7..f84fca4 100644 --- a/include/consolix/components.hpp +++ b/include/consolix/components.hpp @@ -99,6 +99,7 @@ // Service management utilities required by LoggerComponent, CliComponent, ConfigComponent #include "core/ServiceLocator.hpp" ///< Service locator for managing shared resources. #include "core/service_utils.hpp" ///< Utility functions for working with services. +#include "core/LoopWakeService.hpp" ///< Shared wake channel for polling-loop waits. // Core components of the Consolix framework #include "components/TitleComponent.hpp" ///< Component for managing the console window title across platforms. diff --git a/include/consolix/components/LoopThrottleComponent.hpp b/include/consolix/components/LoopThrottleComponent.hpp index 658a229..62b1753 100644 --- a/include/consolix/components/LoopThrottleComponent.hpp +++ b/include/consolix/components/LoopThrottleComponent.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace consolix { @@ -21,6 +22,8 @@ namespace consolix { /// preferably after active work components, to pause each pass for a bounded time. /// /// Another thread can call `wake()` to end the wait early when new work arrives. + /// When a `ConsoleApplicationRunner` is active, stop requests also wake this + /// component through `LoopWakeService`. class LoopThrottleComponent : public IAppComponent { public: /// \brief Constructs a throttle component with a short default delay. @@ -43,6 +46,10 @@ namespace consolix { /// the next process pass, so producer threads can signal work without racing /// the exact wait window. void wake() { + if (auto service = loop_wake_service()) { + service->wake_all(); + } + { std::lock_guard lock(m_mutex); m_wake_requested = true; @@ -53,11 +60,16 @@ namespace consolix { /// \brief Changes the throttle delay and wakes any active wait. /// \param delay New maximum wait duration per process pass. void set_delay(std::chrono::milliseconds delay) { + auto service = loop_wake_service(); + { std::lock_guard lock(m_mutex); m_delay = delay; m_wake_requested = true; } + if (service) { + service->wake_all(); + } m_condition.notify_all(); } @@ -70,6 +82,9 @@ namespace consolix { protected: bool initialize() override { + if (auto service = loop_wake_service()) { + m_observed_generation = service->generation(); + } m_is_initialized.store(true); return true; } @@ -79,17 +94,29 @@ namespace consolix { } void process() override { - std::unique_lock lock(m_mutex); - if (m_wake_requested) { - m_wake_requested = false; - return; + std::chrono::milliseconds current_delay; + { + std::unique_lock lock(m_mutex); + if (m_wake_requested) { + m_wake_requested = false; + return; + } + + current_delay = m_delay; } - const std::chrono::milliseconds current_delay = m_delay; if (current_delay <= std::chrono::milliseconds(0)) { return; } + if (auto service = loop_wake_service()) { + service->wait_for_change(m_observed_generation, current_delay); + std::lock_guard lock(m_mutex); + m_wake_requested = false; + return; + } + + std::unique_lock lock(m_mutex); m_condition.wait_for( lock, current_delay, @@ -100,11 +127,30 @@ namespace consolix { } private: + std::shared_ptr loop_wake_service() { + { + std::lock_guard lock(m_mutex); + auto service = m_loop_wake_service.lock(); + if (service) { + return service; + } + } + + auto service = ServiceLocator::get_instance().find_service(); + if (service) { + std::lock_guard lock(m_mutex); + m_loop_wake_service = service; + } + return service; + } + mutable std::mutex m_mutex; std::condition_variable m_condition; std::chrono::milliseconds m_delay{std::chrono::milliseconds(1)}; bool m_wake_requested{false}; std::atomic m_is_initialized{false}; + std::weak_ptr m_loop_wake_service; + LoopWakeService::Generation m_observed_generation{0}; }; // LoopThrottleComponent } // namespace consolix diff --git a/include/consolix/core.hpp b/include/consolix/core.hpp index 6462710..8a9419b 100644 --- a/include/consolix/core.hpp +++ b/include/consolix/core.hpp @@ -20,6 +20,7 @@ /// - **ServiceLocator**: A mechanism for registering and accessing global services. /// - **AppComponentManager**: A manager for handling application components. /// - **ConsoleApplication**: A singleton for managing the console application. It includes `AppComponentManager`. +/// - **LoopWakeService**: A shared wake channel for polling-loop wait components. /// - **Utilities**: Functions to simplify working with applications and services. /// /// ### Key Features: @@ -31,6 +32,7 @@ /// - `core/ServiceLocator.hpp` /// - `core/service_utils.hpp` /// - `core/AppComponentManager.hpp` +/// - `core/LoopWakeService.hpp` /// - `core/ConsoleApplicationRunner.hpp` /// - `core/ConsoleApplication.hpp` /// - `core/application_utils.hpp` @@ -75,6 +77,7 @@ #include "core/ServiceLocator.hpp" ///< Singleton for managing globally accessible services. #include "core/service_utils.hpp" ///< Helper functions for working with services. #include "core/AppComponentManager.hpp" ///< Manager for application components and their lifecycle. +#include "core/LoopWakeService.hpp" ///< Shared wake channel for polling-loop waits. #include "core/ConsoleApplicationRunner.hpp" ///< Runner returning exit codes without std::exit. #include "core/ConsoleApplication.hpp" ///< Singleton managing the console application's lifecycle. #include "core/application_utils.hpp" ///< Helper functions for application setup and execution. diff --git a/include/consolix/core/ConsoleApplicationRunner.hpp b/include/consolix/core/ConsoleApplicationRunner.hpp index be9eed2..237bf8c 100644 --- a/include/consolix/core/ConsoleApplicationRunner.hpp +++ b/include/consolix/core/ConsoleApplicationRunner.hpp @@ -27,6 +27,7 @@ #include "ServiceLocator.hpp" #include "AppComponentManager.hpp" +#include "LoopWakeService.hpp" #if !defined(_WIN32) && !defined(_WIN64) #include @@ -70,6 +71,7 @@ namespace consolix { m_shutdown_complete.store(false); ActiveRunnerGuard active_runner_guard(*this); + setup_loop_wake_service(); setup_signal_handlers(); int exit_code = 0; @@ -99,7 +101,7 @@ namespace consolix { if (m_stopping.compare_exchange_strong(expected, true)) { m_requested_exit_code.store(exit_code); } - m_stop_requested_cv.notify_all(); + wake_loop_waiters(); } /// \brief Requests stop and waits for runner-thread cleanup to finish. @@ -195,7 +197,7 @@ namespace consolix { std::atomic m_requested_exit_code{0}; std::mutex m_shutdown_mutex; std::condition_variable m_shutdown_complete_cv; - std::condition_variable m_stop_requested_cv; + std::weak_ptr m_loop_wake_service; void setup_signal_handlers() { reset_signal_state(); @@ -222,6 +224,21 @@ namespace consolix { } } + void setup_loop_wake_service() { + auto service = ServiceLocator::get_instance().find_service(); + if (!service) { + ServiceLocator::get_instance().register_service(); + service = ServiceLocator::get_instance().find_service(); + } + m_loop_wake_service = service; + } + + void wake_loop_waiters() { + if (auto service = m_loop_wake_service.lock()) { + service->wake_all(); + } + } + bool stop_requested() { synchronize_signal_stop_request(); return m_stopping.load(); diff --git a/include/consolix/core/LoopWakeService.hpp b/include/consolix/core/LoopWakeService.hpp new file mode 100644 index 0000000..43024fb --- /dev/null +++ b/include/consolix/core/LoopWakeService.hpp @@ -0,0 +1,80 @@ +#pragma once +#ifndef _CONSOLIX_LOOP_WAKE_SERVICE_HPP_INCLUDED +#define _CONSOLIX_LOOP_WAKE_SERVICE_HPP_INCLUDED + +/// \file LoopWakeService.hpp +/// \brief Shared wake channel for Consolix polling-loop wait components. +/// \ingroup Core + +#include +#include +#include +#include + +namespace consolix { + + /// \class LoopWakeService + /// \brief Broadcasts wake requests to loop components that wait between passes. + /// + /// The service uses a generation counter so a wake request can be observed by + /// multiple waiters and by waiters that start just after the request was made. + class LoopWakeService { + public: + /// \brief Monotonic wake generation type. + typedef std::uint64_t Generation; + + /// \brief Returns the current wake generation. + /// \return Current generation value. + Generation generation() const { + std::lock_guard lock(m_mutex); + return m_generation; + } + + /// \brief Wakes all current and next-pass waiters. + void wake_all() { + { + std::lock_guard lock(m_mutex); + ++m_generation; + } + m_condition.notify_all(); + } + + /// \brief Waits until the wake generation changes or timeout expires. + /// \param observed_generation Last generation observed by the caller. + /// \param timeout Maximum wait duration. + /// \return `true` if a wake generation change was observed. + bool wait_for_change( + Generation& observed_generation, + std::chrono::milliseconds timeout) { + std::unique_lock lock(m_mutex); + if (m_generation != observed_generation) { + observed_generation = m_generation; + return true; + } + + if (timeout <= std::chrono::milliseconds(0)) { + return false; + } + + const bool changed = m_condition.wait_for( + lock, + timeout, + [this, observed_generation]() { + return m_generation != observed_generation; + }); + + if (changed) { + observed_generation = m_generation; + } + return changed; + } + + private: + mutable std::mutex m_mutex; + std::condition_variable m_condition; + Generation m_generation{0}; + }; // LoopWakeService + +} // namespace consolix + +#endif // _CONSOLIX_LOOP_WAKE_SERVICE_HPP_INCLUDED diff --git a/include/consolix/core/ServiceLocator.hpp b/include/consolix/core/ServiceLocator.hpp index 4b24d38..98d62d1 100644 --- a/include/consolix/core/ServiceLocator.hpp +++ b/include/consolix/core/ServiceLocator.hpp @@ -89,6 +89,38 @@ namespace consolix { return *std::static_pointer_cast(it->second); } + /// \brief Retrieves a shared resource pointer from the locator. + /// \tparam T The type of the resource. + /// \return Shared pointer to the resource. + /// \throws `std::runtime_error` if the resource is not registered. + template + std::shared_ptr get_service_ptr() { + compat::shared_lock lock(m_mutex); + const auto type = std::type_index(typeid(T)); + auto it = m_services.find(type); + if (it == m_services.end()) { +# if CONSOLIX_USE_LOGIT == 1 + LOGIT_PRINT_ERROR("Service not registered: ", std::string(typeid(T).name())); +# endif + throw std::runtime_error("Service not registered: " + std::string(typeid(T).name())); + } + return std::static_pointer_cast(it->second); + } + + /// \brief Attempts to find a shared resource pointer. + /// \tparam T The type of the resource. + /// \return Shared pointer to the resource, or `nullptr` when it is not registered. + template + std::shared_ptr find_service() { + compat::shared_lock lock(m_mutex); + const auto type = std::type_index(typeid(T)); + auto it = m_services.find(type); + if (it == m_services.end()) { + return std::shared_ptr(); + } + return std::static_pointer_cast(it->second); + } + /// \brief Checks if a resource is registered. /// \tparam T The type of the resource. /// \return `true` if the resource is registered, `false` otherwise. diff --git a/include/consolix/core/service_utils.hpp b/include/consolix/core/service_utils.hpp index 3af9a3e..7eec8c7 100644 --- a/include/consolix/core/service_utils.hpp +++ b/include/consolix/core/service_utils.hpp @@ -52,6 +52,22 @@ namespace consolix { return ServiceLocator::get_instance().get_service(); } + /// \brief Retrieves a shared pointer to a globally registered resource. + /// \tparam T The type of the resource. + /// \return Shared pointer to the resource. + template + inline std::shared_ptr get_service_ptr() { + return ServiceLocator::get_instance().get_service_ptr(); + } + + /// \brief Attempts to find a globally registered resource. + /// \tparam T The type of the resource. + /// \return Shared pointer to the resource, or `nullptr` when it is not registered. + template + inline std::shared_ptr find_service() { + return ServiceLocator::get_instance().find_service(); + } + /// \brief Checks if a resource is registered globally. /// \tparam T The type of the resource. /// \return `true` if the resource is registered, `false` otherwise. diff --git a/tests/test_console_application_runner.cpp b/tests/test_console_application_runner.cpp index e5218f2..4109686 100644 --- a/tests/test_console_application_runner.cpp +++ b/tests/test_console_application_runner.cpp @@ -35,6 +35,11 @@ void expect(bool condition, const char* message) { } } +long long elapsed_ms(std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); +} + class NormalStopComponent final : public consolix::BaseLoopComponent { public: explicit NormalStopComponent(ScenarioState& state) : @@ -208,6 +213,48 @@ class BlockingProcessComponent final : public consolix::BaseLoopComponent { std::atomic& m_release_process; }; +class ThreadStopComponent final : public consolix::BaseLoopComponent { +public: + ThreadStopComponent(ScenarioState& state, int exit_code) : + m_state(state), + m_exit_code(exit_code) { + } + + virtual ~ThreadStopComponent() override { + join_stop_thread(); + } + + bool on_once() override { + ++m_state.initialize_calls; + m_stop_thread = std::thread([this]() { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + consolix::stop(m_exit_code); + }); + return true; + } + + void on_loop() override { + ++m_state.process_calls; + } + + void on_shutdown(int signal) override { + ++m_state.shutdown_calls; + m_state.shutdown_code = signal; + join_stop_thread(); + } + +private: + void join_stop_thread() { + if (m_stop_thread.joinable()) { + m_stop_thread.join(); + } + } + + ScenarioState& m_state; + int m_exit_code; + std::thread m_stop_thread; +}; + void run_normal_stop_scenario() { ScenarioState state; int service_destroyed = 0; @@ -340,6 +387,23 @@ void run_forced_stop_timeout_scenario() { expect(state.shutdown_code == 33, "timed-out forced stop must pass code to shutdown"); } +void run_stop_wakes_throttle_scenario() { + ScenarioState state; + consolix::AppComponentManager manager; + manager.add(state, 77); + manager.add(std::chrono::milliseconds(1000)); + + consolix::ConsoleApplicationRunner runner(manager); + const auto start = std::chrono::steady_clock::now(); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code == 77, "thread stop must return requested code"); + expect(elapsed_ms(start) < 500, + "stop request must wake LoopThrottleComponent instead of waiting for delay"); + expect(state.shutdown_calls == 1, "thread stop must shutdown once"); + expect(state.shutdown_code == 77, "thread stop must pass code to shutdown"); +} + } // namespace int main() { @@ -350,6 +414,7 @@ int main() { run_exception_scenario(); run_forced_stop_scenario(); run_forced_stop_timeout_scenario(); + run_stop_wakes_throttle_scenario(); std::cout << "ConsoleApplicationRunner checks passed." << std::endl; return 0; From 81607f6e7445ac1a573c190270a3624392fd8b71 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 8 Jun 2026 22:11:34 +0300 Subject: [PATCH 6/6] fix runner lifecycle review notes --- README-RU.md | 10 +++++++++ README.md | 10 +++++++++ docs/mainpage.dox | 10 +++++++++ .../components/LoopThrottleComponent.hpp | 11 +++++----- .../core/ConsoleApplicationRunner.hpp | 10 ++++++++- include/consolix/core/application_utils.hpp | 4 ++++ tests/test_console_application_runner.cpp | 21 +++++++++++++++++++ 7 files changed, 70 insertions(+), 6 deletions(-) diff --git a/README-RU.md b/README-RU.md index a124f9d..a474910 100644 --- a/README-RU.md +++ b/README-RU.md @@ -172,6 +172,11 @@ int main() { `consolix::ConsoleApplicationRunner runner(manager);` и вернуть `runner.run_for_exit_code()`. +Экземпляр `ConsoleApplicationRunner` одноразовый. Для нового lifecycle создайте +новый manager и новый runner. Пока `run_for_exit_code()` активен, глобальные +`consolix::stop()` и `consolix::request_stop()` направляются в активный runner; +иначе они работают с singleton facade `ConsoleApplication`. + `ConsoleApplication::run()` остается совместимым process-owning facade: внутри он использует тот же runner и завершает процесс с полученным кодом. На Windows Ctrl+C/Ctrl+Break запрашивают cooperative shutdown, а close/logoff/ @@ -232,6 +237,11 @@ loop. `wake()` прерывает текущее ожидание, а wake reque без runner-а, держите delay коротким или вызывайте `wake()` из того же control path, который запрашивает stop. +На POSIX signal handlers остаются async-signal-safe: они только сохраняют флаг +ожидающего сигнала. Они не будят C++ condition variables напрямую, поэтому +долгий блокирующий `process()` может отложить обработку SIGINT/SIGTERM до +возврата управления в runner loop. + ## Documentation - developer guidelines: `docs/header-implementation-guidelines.md` diff --git a/README.md b/README.md index 2e6c052..6f38d1d 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ For applications that already own an `AppComponentManager`, use `consolix::ConsoleApplicationRunner runner(manager);` and return `runner.run_for_exit_code()`. +A `ConsoleApplicationRunner` instance is single-use. Create a new manager and a +new runner for another lifecycle. While `run_for_exit_code()` is active, global +`consolix::stop()` and `consolix::request_stop()` target the active runner; +otherwise they target the singleton `ConsoleApplication` facade. + `ConsoleApplication::run()` remains the process-owning compatibility facade: internally it uses the same runner and exits with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown; close/logoff/shutdown events @@ -230,6 +235,11 @@ not have to wait for a long throttle delay. If you drive `AppComponentManager` directly without the runner, keep the delay short or call `wake()` from the same control path that requests stop. +On POSIX platforms, signal handlers remain async-signal-safe: they only store a +pending signal flag. They do not wake C++ condition variables directly, so a +long blocking `process()` call can delay SIGINT/SIGTERM handling until control +returns to the runner loop. + ## Diagnostic Streams Consolix provides two multi-target log macros that route messages through diff --git a/docs/mainpage.dox b/docs/mainpage.dox index ed722b8..8ee3a38 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -106,6 +106,11 @@ the process. Components can request a specific result with as the explicit form. Applications that own their own `AppComponentManager` can run it through `consolix::ConsoleApplicationRunner`. +`ConsoleApplicationRunner` instances are single-use. Create a new manager and a +new runner for another lifecycle. While `run_for_exit_code()` is active, global +`consolix::stop()` and `consolix::request_stop()` target the active runner; +otherwise they target the singleton `ConsoleApplication` facade. + `ConsoleApplication::run()` is a compatibility facade over the same runner: it exits the process with the returned code. On Windows, Ctrl+C/Ctrl+Break request cooperative shutdown, while close/logoff/shutdown console events request cleanup @@ -164,6 +169,11 @@ long throttle delay. If you drive `AppComponentManager` directly without the runner, keep the delay short or call `wake()` from the same control path that requests stop. +On POSIX platforms, signal handlers remain async-signal-safe: they only store a +pending signal flag. They do not wake C++ condition variables directly, so a +long blocking `process()` call can delay SIGINT/SIGTERM handling until control +returns to the runner loop. + ## Documentation Structure ### Main Sections diff --git a/include/consolix/components/LoopThrottleComponent.hpp b/include/consolix/components/LoopThrottleComponent.hpp index 62b1753..de267a0 100644 --- a/include/consolix/components/LoopThrottleComponent.hpp +++ b/include/consolix/components/LoopThrottleComponent.hpp @@ -22,8 +22,8 @@ namespace consolix { /// preferably after active work components, to pause each pass for a bounded time. /// /// Another thread can call `wake()` to end the wait early when new work arrives. - /// When a `ConsoleApplicationRunner` is active, stop requests also wake this - /// component through `LoopWakeService`. + /// When a `ConsoleApplicationRunner` is active, ordinary stop requests also + /// wake this component through `LoopWakeService`. class LoopThrottleComponent : public IAppComponent { public: /// \brief Constructs a throttle component with a short default delay. @@ -46,14 +46,15 @@ namespace consolix { /// the next process pass, so producer threads can signal work without racing /// the exact wait window. void wake() { - if (auto service = loop_wake_service()) { - service->wake_all(); - } + auto service = loop_wake_service(); { std::lock_guard lock(m_mutex); m_wake_requested = true; } + if (service) { + service->wake_all(); + } m_condition.notify_all(); } diff --git a/include/consolix/core/ConsoleApplicationRunner.hpp b/include/consolix/core/ConsoleApplicationRunner.hpp index 237bf8c..fed4ba5 100644 --- a/include/consolix/core/ConsoleApplicationRunner.hpp +++ b/include/consolix/core/ConsoleApplicationRunner.hpp @@ -45,6 +45,9 @@ namespace consolix { /// On Windows, Ctrl+C/Ctrl+Break only request a cooperative stop. Close, logoff, /// and shutdown console events request stop and wait briefly for this runner to /// complete cleanup on the runner thread. + /// + /// A runner instance is single-use. Create a new `AppComponentManager` and + /// `ConsoleApplicationRunner` for a new application lifecycle. class ConsoleApplicationRunner { public: /// \brief Constructs a runner over an existing component manager. @@ -65,8 +68,12 @@ namespace consolix { /// \return The requested exit code, a signal code, or a non-zero fatal error code. template int run_for_exit_code(IterationAction iteration_action) { + if (m_has_run.exchange(true)) { + throw std::logic_error("ConsoleApplicationRunner is single-use"); + } + if (m_running.exchange(true)) { - return requested_exit_code(); + throw std::logic_error("ConsoleApplicationRunner is already running"); } m_shutdown_complete.store(false); @@ -190,6 +197,7 @@ namespace consolix { }; AppComponentManager& m_manager; + std::atomic m_has_run{false}; std::atomic m_running{false}; std::atomic m_stopping{false}; std::atomic m_cleanup{false}; diff --git a/include/consolix/core/application_utils.hpp b/include/consolix/core/application_utils.hpp index 778ba0f..0388bd0 100644 --- a/include/consolix/core/application_utils.hpp +++ b/include/consolix/core/application_utils.hpp @@ -90,6 +90,10 @@ namespace consolix { } /// \brief Requests application shutdown with an explicit exit code. + /// + /// If a `ConsoleApplicationRunner` is active on the current lifecycle, the + /// request is routed to that runner. Otherwise it targets the singleton + /// `ConsoleApplication` compatibility facade. /// \param exit_code Exit code to pass to shutdown and return from exit-code runners. inline void request_stop(int exit_code) { if (!ConsoleApplicationRunner::request_current_stop(exit_code)) { diff --git a/tests/test_console_application_runner.cpp b/tests/test_console_application_runner.cpp index 4109686..fb6fd06 100644 --- a/tests/test_console_application_runner.cpp +++ b/tests/test_console_application_runner.cpp @@ -280,6 +280,26 @@ void run_normal_stop_scenario() { expect(service_destroyed == 1, "runner must clear services exactly once"); } +void run_runner_single_use_scenario() { + ScenarioState state; + consolix::AppComponentManager manager; + manager.add(state); + + consolix::ConsoleApplicationRunner runner(manager); + const int exit_code = runner.run_for_exit_code(); + + expect(exit_code == 0, "single-use runner first run must return 0"); + + bool threw = false; + try { + (void)runner.run_for_exit_code(); + } catch (const std::logic_error&) { + threw = true; + } + + expect(threw, "runner must reject a second run"); +} + void run_component_exit_code_scenario() { ScenarioState state; consolix::AppComponentManager manager; @@ -409,6 +429,7 @@ void run_stop_wakes_throttle_scenario() { int main() { try { run_normal_stop_scenario(); + run_runner_single_use_scenario(); run_component_exit_code_scenario(); run_signal_stop_scenario(); run_exception_scenario();