From 81f15cab17057f7f5341338c250f48a1bcf1bbdf Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Tue, 9 Jun 2026 09:50:53 +0300 Subject: [PATCH] add posix signal wake service --- README-RU.md | 15 + README.md | 15 + docs/mainpage.dox | 17 + include/consolix/components.hpp | 4 + .../components/PosixSignalWakeComponent.hpp | 81 +++++ include/consolix/core.hpp | 3 + .../core/ConsoleApplicationRunner.hpp | 2 + .../consolix/core/PosixSignalWakeService.hpp | 291 ++++++++++++++++++ tests/test_posix_signal_shutdown.cpp | 24 +- 9 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 include/consolix/components/PosixSignalWakeComponent.hpp create mode 100644 include/consolix/core/PosixSignalWakeService.hpp diff --git a/README-RU.md b/README-RU.md index a474910..d560515 100644 --- a/README-RU.md +++ b/README-RU.md @@ -242,6 +242,21 @@ path, который запрашивает stop. долгий блокирующий `process()` может отложить обработку SIGINT/SIGTERM до возврата управления в runner loop. +Для POSIX-приложений с длинными throttle delays добавьте opt-in self-pipe +component до компонентов, которые могут блокировать loop: + +```cpp +consolix::add(); +consolix::add(); +consolix::add( + std::chrono::seconds(10)); +``` + +POSIX handler по-прежнему только сохраняет сигнал и пишет один байт в pipe. +Watcher thread будит `LoopWakeService` уже из обычного C++ кода, поэтому +SIGINT/SIGTERM может прервать ожидание `LoopThrottleComponent` без mutex/CV +работы внутри signal handler. На Windows этот component является no-op. + ## Documentation - developer guidelines: `docs/header-implementation-guidelines.md` diff --git a/README.md b/README.md index 6f38d1d..b7552f5 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,21 @@ 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. +For POSIX applications that use long throttle delays, add the opt-in self-pipe +wake component before components that may block the loop: + +```cpp +consolix::add(); +consolix::add(); +consolix::add( + std::chrono::seconds(10)); +``` + +The POSIX handler still only records the signal and writes one byte to a pipe. +A watcher thread wakes `LoopWakeService` from normal C++ code, so +SIGINT/SIGTERM can interrupt `LoopThrottleComponent` waits without doing +mutex/CV work inside the signal handler. On Windows this component is a no-op. + ## Diagnostic Streams Consolix provides two multi-target log macros that route messages through diff --git a/docs/mainpage.dox b/docs/mainpage.dox index 8ee3a38..bf88d55 100644 --- a/docs/mainpage.dox +++ b/docs/mainpage.dox @@ -38,6 +38,7 @@ It also targets the recurring runtime concerns of console tools: startup and shu - `ServiceLocator` for global resource management - `AppComponentManager`, `ConsoleApplication`, and `ConsoleApplicationRunner` for application lifecycle - `LoopWakeService` for waking polling-loop wait components + - `PosixSignalWakeService` for opt-in POSIX self-pipe wake-ups - service and application helpers for common setup flows - **Components**: @@ -48,6 +49,7 @@ It also targets the recurring runtime concerns of console tools: startup and shu - `LogoComponent`: displays a custom ASCII logo in the console - `LoopComponent`: customizable application loops - `LoopThrottleComponent`: optional wakeable wait step for polling loops + - `PosixSignalWakeComponent`: lifecycle wrapper for POSIX signal wake-ups - **Utilities**: - `path_utils.hpp`: path and executable-location helpers @@ -174,6 +176,21 @@ 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. +For POSIX applications that use long throttle delays, add the opt-in self-pipe +wake component before components that may block the loop: + +```cpp +consolix::add(); +consolix::add(); +consolix::add( + std::chrono::seconds(10)); +``` + +The POSIX handler still only records the signal and writes one byte to a pipe. +A watcher thread wakes `LoopWakeService` from normal C++ code, so +SIGINT/SIGTERM can interrupt `LoopThrottleComponent` waits without doing +mutex/CV work inside the signal handler. On Windows this component is a no-op. + ## Documentation Structure ### Main Sections diff --git a/include/consolix/components.hpp b/include/consolix/components.hpp index f84fca4..6d50d06 100644 --- a/include/consolix/components.hpp +++ b/include/consolix/components.hpp @@ -32,6 +32,7 @@ /// - **BaseLoopComponent**: A base class for loop-based components. /// - **LoopComponent**: A component with customizable execution loops. /// - **LoopThrottleComponent**: A wakeable wait step for throttling polling loops. +/// - **PosixSignalWakeComponent**: Optional POSIX signal wake bridge for throttled loops. /// - **EventHubComponent**: Optional bridge to event-hub-cpp EventBus and TaskManager. /// - **ModuleHubComponent**: Optional bridge to event-hub-cpp ModuleHub. /// @@ -59,6 +60,7 @@ /// - `components/BaseLoopComponent.hpp` /// - `components/LoopComponent.hpp` /// - `components/LoopThrottleComponent.hpp` +/// - `components/PosixSignalWakeComponent.hpp` /// - `components/EventHubComponent.hpp` when `CONSOLIX_USE_EVENT_HUB=1` /// - `components/ModuleHubComponent.hpp` when `CONSOLIX_USE_EVENT_HUB=1` /// @@ -100,6 +102,7 @@ #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. +#include "core/PosixSignalWakeService.hpp" ///< Optional self-pipe bridge for POSIX signal wake-ups. // Core components of the Consolix framework #include "components/TitleComponent.hpp" ///< Component for managing the console window title across platforms. @@ -108,6 +111,7 @@ #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. +#include "components/PosixSignalWakeComponent.hpp" ///< POSIX signal wake component for throttled 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/PosixSignalWakeComponent.hpp b/include/consolix/components/PosixSignalWakeComponent.hpp new file mode 100644 index 0000000..406affc --- /dev/null +++ b/include/consolix/components/PosixSignalWakeComponent.hpp @@ -0,0 +1,81 @@ +#pragma once +#ifndef _CONSOLIX_POSIX_SIGNAL_WAKE_COMPONENT_HPP_INCLUDED +#define _CONSOLIX_POSIX_SIGNAL_WAKE_COMPONENT_HPP_INCLUDED + +/// \file PosixSignalWakeComponent.hpp +/// \brief Opt-in component that wakes polling waits for POSIX termination signals. +/// \ingroup Components + +#include +#include + +namespace consolix { + + /// \class PosixSignalWakeComponent + /// \brief Starts `PosixSignalWakeService` for the application lifecycle. + /// + /// Register this component when POSIX SIGINT/SIGTERM should wake + /// `LoopThrottleComponent` waits immediately. The component is a no-op on + /// Windows, where console-control handling already uses the runner stop path. + class PosixSignalWakeComponent final : public IAppComponent, public IShutdownable { + public: + /// \brief Stops the wake service if it is still active. + ~PosixSignalWakeComponent() override { + stop_service(); + } + + protected: + /// \brief Starts or reuses the shared POSIX signal wake service. + /// \return Always `true` once setup succeeds or when unsupported. + bool initialize() override { + if (m_initialized.load()) { + return true; + } + + auto service = ServiceLocator::get_instance().find_service(); + if (!service) { + ServiceLocator::get_instance().register_service(); + service = ServiceLocator::get_instance().find_service(); + } + + if (service) { + service->start(); + m_service = service; + } + + m_initialized.store(true); + return true; + } + + /// \brief Reports whether the service has been initialized. + /// \return `true` after successful initialization. + bool is_initialized() const override { + return m_initialized.load(); + } + + /// \brief Does no per-loop work. + void process() override { + } + + /// \brief Stops the POSIX wake service during application shutdown. + /// \param signal Exit code or signal that initiated shutdown. + void shutdown(int signal) override { + (void)signal; + stop_service(); + } + + private: + void stop_service() { + if (m_service) { + m_service->stop(); + } + m_initialized.store(false); + } + + std::atomic m_initialized{false}; + std::shared_ptr m_service; + }; // PosixSignalWakeComponent + +} // namespace consolix + +#endif // _CONSOLIX_POSIX_SIGNAL_WAKE_COMPONENT_HPP_INCLUDED diff --git a/include/consolix/core.hpp b/include/consolix/core.hpp index 8a9419b..3caac57 100644 --- a/include/consolix/core.hpp +++ b/include/consolix/core.hpp @@ -21,6 +21,7 @@ /// - **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. +/// - **PosixSignalWakeService**: An opt-in self-pipe bridge for POSIX signal wake-ups. /// - **Utilities**: Functions to simplify working with applications and services. /// /// ### Key Features: @@ -33,6 +34,7 @@ /// - `core/service_utils.hpp` /// - `core/AppComponentManager.hpp` /// - `core/LoopWakeService.hpp` +/// - `core/PosixSignalWakeService.hpp` /// - `core/ConsoleApplicationRunner.hpp` /// - `core/ConsoleApplication.hpp` /// - `core/application_utils.hpp` @@ -78,6 +80,7 @@ #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/PosixSignalWakeService.hpp" ///< Optional self-pipe bridge for POSIX signal wake-ups. #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 fed4ba5..9eda311 100644 --- a/include/consolix/core/ConsoleApplicationRunner.hpp +++ b/include/consolix/core/ConsoleApplicationRunner.hpp @@ -28,6 +28,7 @@ #include "ServiceLocator.hpp" #include "AppComponentManager.hpp" #include "LoopWakeService.hpp" +#include "PosixSignalWakeService.hpp" #if !defined(_WIN32) && !defined(_WIN64) #include @@ -372,6 +373,7 @@ namespace consolix { static void signal_handler(int exit_code) { pending_signal_code() = static_cast(exit_code); signal_stop_requested() = 1; + PosixSignalWakeService::notify_signal_handler(); } static void reset_signal_state() { diff --git a/include/consolix/core/PosixSignalWakeService.hpp b/include/consolix/core/PosixSignalWakeService.hpp new file mode 100644 index 0000000..cf04e2e --- /dev/null +++ b/include/consolix/core/PosixSignalWakeService.hpp @@ -0,0 +1,291 @@ +#pragma once +#ifndef _CONSOLIX_POSIX_SIGNAL_WAKE_SERVICE_HPP_INCLUDED +#define _CONSOLIX_POSIX_SIGNAL_WAKE_SERVICE_HPP_INCLUDED + +/// \file PosixSignalWakeService.hpp +/// \brief Optional self-pipe wake bridge for POSIX termination signals. +/// \ingroup Core + +#include "LoopWakeService.hpp" +#include "ServiceLocator.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) && !defined(_WIN64) +#include +#include +#include +#endif + +namespace consolix { + + /// \class PosixSignalWakeService + /// \brief Wakes polling-loop waiters when the runner observes POSIX signals. + /// + /// The regular POSIX signal handler remains async-signal-safe: it records the + /// signal in the runner and asks this service to write one byte to a self-pipe. + /// A watcher thread then wakes `LoopWakeService` from ordinary C++ code. + /// + /// This service is opt-in and POSIX-only. On Windows it compiles as a no-op. + class PosixSignalWakeService { + public: + /// \brief Constructs an inactive wake service. + PosixSignalWakeService() = default; + + /// \brief Stops the watcher thread and closes the self-pipe if active. + ~PosixSignalWakeService() { + stop(); + } + + PosixSignalWakeService(const PosixSignalWakeService&) = delete; + PosixSignalWakeService& operator=(const PosixSignalWakeService&) = delete; + + /// \brief Returns whether this platform supports the POSIX wake bridge. + /// \return `true` on POSIX platforms, `false` on Windows. + bool is_supported() const { +#if defined(_WIN32) || defined(_WIN64) + return false; +#else + return true; +#endif + } + + /// \brief Starts the self-pipe watcher. + /// \return `true` when the service is active, `false` on unsupported platforms. + /// \throws std::runtime_error if POSIX pipe/thread setup fails. + /// \throws std::logic_error if another service instance is already active. + bool start() { +#if defined(_WIN32) || defined(_WIN64) + return false; +#else + std::lock_guard lock(m_mutex); + if (m_running) { + return true; + } + + acquire_active_service(); + try { + setup_loop_wake_service(); + create_pipe(); + set_close_on_exec(m_pipe_fds[0]); + set_close_on_exec(m_pipe_fds[1]); + set_nonblocking(m_pipe_fds[1]); + + m_stop_requested.store(false); + signal_wake_fd() = static_cast(m_pipe_fds[1]); + m_watcher = std::thread(&PosixSignalWakeService::watch_loop, this); + m_running = true; + } catch (...) { + signal_wake_fd() = static_cast(-1); + close_pipe(); + release_active_service(); + throw; + } + return true; +#endif + } + + /// \brief Stops the watcher thread if it is active. + void stop() { +#if !defined(_WIN32) && !defined(_WIN64) + std::thread watcher; + + { + std::lock_guard lock(m_mutex); + if (!m_running) { + return; + } + + m_stop_requested.store(true); + clear_signal_wake_fd_if_active(); + write_wake_token(m_pipe_fds[1]); + + if (m_watcher.joinable()) { + watcher.swap(m_watcher); + } + m_running = false; + } + + if (watcher.joinable()) { + watcher.join(); + } + + { + std::lock_guard lock(m_mutex); + close_pipe(); + m_loop_wake_service.reset(); + m_stop_requested.store(false); + release_active_service(); + } +#endif + } + + /// \brief Checks whether the service is currently active. + /// \return `true` if the self-pipe watcher is running. + bool is_running() const { +#if defined(_WIN32) || defined(_WIN64) + return false; +#else + std::lock_guard lock(m_mutex); + return m_running; +#endif + } + + /// \brief Async-signal-safe hook called by the runner signal handler. + /// + /// This method must not use C++ runtime services, locks, allocation, or + /// logging. It only writes a byte to the active self-pipe, if one exists. + static void notify_signal_handler() { +#if !defined(_WIN32) && !defined(_WIN64) + const int fd = static_cast(signal_wake_fd()); + write_wake_token(fd); +#endif + } + + private: +#if !defined(_WIN32) && !defined(_WIN64) + mutable std::mutex m_mutex; + std::thread m_watcher; + std::weak_ptr m_loop_wake_service; + std::atomic m_stop_requested{false}; + int m_pipe_fds[2]{-1, -1}; + bool m_running{false}; + + 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 create_pipe() { + if (::pipe(m_pipe_fds) != 0) { + throw std::runtime_error("PosixSignalWakeService pipe() failed"); + } + } + + void watch_loop() { + while (true) { + unsigned char buffer[64]; + const ssize_t bytes = ::read(m_pipe_fds[0], buffer, sizeof(buffer)); + + if (bytes > 0) { + wake_loop_waiters(); + if (m_stop_requested.load()) { + return; + } + continue; + } + + if (bytes == 0) { + return; + } + + if (errno == EINTR) { + continue; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + return; + } + } + + void wake_loop_waiters() { + std::shared_ptr service; + { + std::lock_guard lock(m_mutex); + service = m_loop_wake_service.lock(); + } + if (service) { + service->wake_all(); + } + } + + void close_pipe() { + close_fd(m_pipe_fds[0]); + close_fd(m_pipe_fds[1]); + } + + void acquire_active_service() { + std::lock_guard lock(active_service_mutex()); + if (active_service() != nullptr && active_service() != this) { + throw std::logic_error("PosixSignalWakeService is already active"); + } + active_service() = this; + } + + void release_active_service() { + std::lock_guard lock(active_service_mutex()); + if (active_service() == this) { + active_service() = nullptr; + } + } + + void clear_signal_wake_fd_if_active() { + std::lock_guard lock(active_service_mutex()); + if (active_service() == this) { + signal_wake_fd() = static_cast(-1); + } + } + + static void close_fd(int& fd) { + if (fd >= 0) { + ::close(fd); + fd = -1; + } + } + + static void set_close_on_exec(int fd) { + const int flags = ::fcntl(fd, F_GETFD, 0); + if (flags < 0 || ::fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != 0) { + throw std::runtime_error("PosixSignalWakeService fcntl(FD_CLOEXEC) failed"); + } + } + + static void set_nonblocking(int fd) { + const int flags = ::fcntl(fd, F_GETFL, 0); + if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { + throw std::runtime_error("PosixSignalWakeService fcntl(O_NONBLOCK) failed"); + } + } + + static void write_wake_token(int fd) { + if (fd < 0) { + return; + } + + const unsigned char token = 1; + const ssize_t result = ::write(fd, &token, sizeof(token)); + (void)result; + } + + static volatile std::sig_atomic_t& signal_wake_fd() { + static volatile std::sig_atomic_t fd = -1; + return fd; + } + + static std::mutex& active_service_mutex() { + static std::mutex mutex; + return mutex; + } + + static PosixSignalWakeService*& active_service() { + static PosixSignalWakeService* service = nullptr; + return service; + } +#endif + }; // PosixSignalWakeService + +} // namespace consolix + +#endif // _CONSOLIX_POSIX_SIGNAL_WAKE_SERVICE_HPP_INCLUDED diff --git a/tests/test_posix_signal_shutdown.cpp b/tests/test_posix_signal_shutdown.cpp index 12775c2..d85caf2 100644 --- a/tests/test_posix_signal_shutdown.cpp +++ b/tests/test_posix_signal_shutdown.cpp @@ -110,6 +110,11 @@ bool wait_for_exit(pid_t pid, int& status_code, int timeout_ms) { return false; } +long long elapsed_ms(std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); +} + class SignalAwareComponent : public consolix::BaseLoopComponent { public: SignalAwareComponent(int ready_fd, int status_fd) : @@ -126,14 +131,16 @@ class SignalAwareComponent : public consolix::BaseLoopComponent { bool on_once() override { m_resource.reset(new ResourceState(m_resource_destroyed)); m_worker = std::thread(&SignalAwareComponent::worker_loop, this); - - const char ready = 'R'; - write_all(m_ready_fd, &ready, sizeof(ready)); - close_fd(m_ready_fd); return true; } void on_loop() override { + int expected = 0; + if (m_ready_sent.compare_exchange_strong(expected, 1)) { + const char ready = 'R'; + write_all(m_ready_fd, &ready, sizeof(ready)); + close_fd(m_ready_fd); + } std::this_thread::sleep_for(std::chrono::milliseconds(1)); } @@ -186,11 +193,14 @@ class SignalAwareComponent : public consolix::BaseLoopComponent { std::atomic m_resource_destroyed{0}; std::atomic m_worker_started{0}; std::atomic m_worker_joined{0}; + std::atomic m_ready_sent{0}; }; int run_child(int ready_fd, int status_fd) { try { + consolix::add(); consolix::add(ready_fd, status_fd); + consolix::add(std::chrono::milliseconds(5000)); consolix::run(); return 0; } catch (const std::exception& e) { @@ -277,6 +287,7 @@ void run_signal_scenario(int signal_value) { fail_and_cleanup_child(pid, "Child did not report readiness"); } + const auto signal_start = std::chrono::steady_clock::now(); if (::kill(pid, signal_value) != 0) { fail_and_cleanup_child(pid, "kill() failed"); } @@ -284,7 +295,10 @@ void run_signal_scenario(int signal_value) { ChildStatus status; std::memset(&status, 0, sizeof(status)); if (!read_all_with_timeout(status_pipe[0], &status, sizeof(status), 3000)) { - fail_and_cleanup_child(pid, "Did not receive shutdown status from child"); + fail_and_cleanup_child(pid, "Did not receive shutdown status from child before throttle timeout"); + } + if (elapsed_ms(signal_start) > 2000) { + fail_and_cleanup_child(pid, "POSIX signal wake did not interrupt throttle wait quickly"); } int wait_status = 0;