include/DetourModKit/event_dispatcher.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_EVENT_DISPATCHER_HPP | ||
| 2 | #define DETOURMODKIT_EVENT_DISPATCHER_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file event_dispatcher.hpp | ||
| 6 | * @brief Typed event dispatcher with RAII subscription management. | ||
| 7 | * | ||
| 8 | * @details Provides a per-event-type pub/sub dispatcher. Subscribers receive events by const reference. Subscriptions | ||
| 9 | * are RAII objects that automatically unsubscribe on destruction. | ||
| 10 | * | ||
| 11 | * **Threading model:** | ||
| 12 | * - `emit()` / `emit_safe()` avoid any `shared_mutex` / reader lock. | ||
| 13 | * The zero-subscriber fast path is wait-free: a single `memory_order_acquire` load of an atomic counter. | ||
| 14 | * When subscribers exist, an atomic acquire-load of a `std::shared_ptr<const std::vector<Entry>>` snapshot | ||
| 15 | * is performed, then the contiguous handler vector is iterated. The snapshot load is genuinely lock-free on | ||
| 16 | * toolchains that provide a DWCAS-backed `std::atomic<std::shared_ptr<T>>` (for example libstdc++ on | ||
| 17 | * x86_64), and may use an implementation-internal short-critical-section bit lock on toolchains that do not | ||
| 18 | * (notably MSVC's STL). | ||
| 19 | * - `subscribe()` / manual `unsubscribe()` serialize writers through | ||
| 20 | * a small `std::mutex` and publish a new immutable snapshot via copy-on-write. Mutation paths allocate; see | ||
| 21 | * the `subscribe()`, `unsubscribe()`, and `clear()` method docs for the OOM contract. | ||
| 22 | * - Safe to emit from multiple threads concurrently (e.g., hook callbacks). | ||
| 23 | * - Safe to subscribe/unsubscribe from any thread. | ||
| 24 | * | ||
| 25 | * **Performance characteristics:** | ||
| 26 | * - `emit()`: atomic acquire-load of a `shared_ptr` snapshot, then | ||
| 27 | * linear iteration over the contiguous handler vector. No user-visible mutex acquisition on the hot path. | ||
| 28 | * When there are no subscribers, `emit()` skips the snapshot load entirely via the atomic counter (wait-free | ||
| 29 | * fast path). | ||
| 30 | * - `subscribe()` / `unsubscribe()`: copy-on-write. Each writer | ||
| 31 | * allocates a new handler vector (O(n) in the current subscriber count), appends or removes an entry, and | ||
| 32 | * publishes it atomically. Typical dispatcher usage is 1-10 subscribers and write-rarely, so the O(n) | ||
| 33 | * publish cost is negligible in practice. | ||
| 34 | * - No heap allocation on `emit()` beyond the `shared_ptr` refcount | ||
| 35 | * bump. Handler vector is cache-friendly. | ||
| 36 | * | ||
| 37 | * **Usage:** | ||
| 38 | * @code | ||
| 39 | * struct PlayerStateChanged { float health; }; | ||
| 40 | * | ||
| 41 | * EventDispatcher<PlayerStateChanged> dispatcher; | ||
| 42 | * | ||
| 43 | * // RAII subscription -- auto-unsubscribes when `sub` goes out of scope | ||
| 44 | * auto sub = dispatcher.subscribe([](const PlayerStateChanged& e) { | ||
| 45 | * logger.info("Health: {}", e.health); | ||
| 46 | * }); | ||
| 47 | * | ||
| 48 | * // Emit from a hook callback (no user-visible mutex on the read path, thread-safe) | ||
| 49 | * dispatcher.emit(PlayerStateChanged{.health = 75.0f}); | ||
| 50 | * @endcode | ||
| 51 | */ | ||
| 52 | |||
| 53 | #include "DetourModKit/logger.hpp" | ||
| 54 | |||
| 55 | #include <algorithm> | ||
| 56 | #include <atomic> | ||
| 57 | #include <cstdint> | ||
| 58 | #include <functional> | ||
| 59 | #include <memory> | ||
| 60 | #include <mutex> | ||
| 61 | #include <utility> | ||
| 62 | #include <vector> | ||
| 63 | |||
| 64 | namespace DetourModKit | ||
| 65 | { | ||
| 66 | /** | ||
| 67 | * @brief Opaque subscription identifier returned by EventDispatcher::subscribe(). | ||
| 68 | */ | ||
| 69 | enum class SubscriptionId : uint64_t | ||
| 70 | { | ||
| 71 | }; | ||
| 72 | |||
| 73 | /** | ||
| 74 | * @brief RAII subscription guard that unsubscribes on destruction. | ||
| 75 | * | ||
| 76 | * @details Move-only. When the guard is destroyed or reset, the associated handler is removed from the dispatcher. | ||
| 77 | * If the dispatcher has already been destroyed, the unsubscribe is silently skipped (weak_ptr safety). | ||
| 78 | */ | ||
| 79 | class Subscription | ||
| 80 | { | ||
| 81 | public: | ||
| 82 | 9 | Subscription() noexcept = default; | |
| 83 | |||
| 84 | 10290 | ~Subscription() noexcept { reset(); } | |
| 85 | |||
| 86 | Subscription(const Subscription &) = delete; | ||
| 87 | Subscription &operator=(const Subscription &) = delete; | ||
| 88 | |||
| 89 | 26 | Subscription(Subscription &&other) noexcept | |
| 90 | 78 | : m_alive(std::move(other.m_alive)), m_unsubscribe(std::move(other.m_unsubscribe)) | |
| 91 | { | ||
| 92 | 26 | other.m_unsubscribe = nullptr; | |
| 93 | 26 | } | |
| 94 | |||
| 95 | 7 | Subscription &operator=(Subscription &&other) noexcept | |
| 96 | { | ||
| 97 |
1/2✓ Branch 2 → 3 taken 7 times.
✗ Branch 2 → 11 not taken.
|
7 | if (this != &other) |
| 98 | { | ||
| 99 | 7 | reset(); | |
| 100 | 14 | m_alive = std::move(other.m_alive); | |
| 101 | 14 | m_unsubscribe = std::move(other.m_unsubscribe); | |
| 102 | 7 | other.m_unsubscribe = nullptr; | |
| 103 | } | ||
| 104 | 7 | return *this; | |
| 105 | } | ||
| 106 | |||
| 107 | /** | ||
| 108 | * @brief Manually unsubscribes. Safe to call multiple times. | ||
| 109 | * @details If called from within a handler on the same dispatcher (i.e. emitting_depth > 0 on this thread), the | ||
| 110 | * unsubscribe is silently skipped and the subscription remains active. The m_unsubscribe lambda is | ||
| 111 | * retained so that a subsequent reset() call outside the emit stack -- including the | ||
| 112 | * Subscription destructor -- will complete the removal. If the Subscription is also destroyed inside | ||
| 113 | * the same handler scope, the destructor's reset() is likewise skipped because emitting_depth is still | ||
| 114 | * positive. This keeps the no-mutation-during-emit invariant intact so the in-flight snapshot | ||
| 115 | * iteration remains consistent. | ||
| 116 | */ | ||
| 117 | 10307 | void reset() noexcept | |
| 118 | { | ||
| 119 |
6/6✓ Branch 3 → 4 taken 10257 times.
✓ Branch 3 → 7 taken 50 times.
✓ Branch 5 → 6 taken 10256 times.
✓ Branch 5 → 7 taken 1 time.
✓ Branch 8 → 9 taken 10256 times.
✓ Branch 8 → 12 taken 51 times.
|
10307 | if (m_unsubscribe && !m_alive.expired()) |
| 120 | { | ||
| 121 |
2/2✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 10254 times.
|
10256 | if (!m_unsubscribe()) |
| 122 | { | ||
| 123 | 2 | return; | |
| 124 | } | ||
| 125 | } | ||
| 126 | 10305 | m_unsubscribe = nullptr; | |
| 127 | 10305 | m_alive.reset(); | |
| 128 | } | ||
| 129 | |||
| 130 | /// Returns true if this subscription is still active. | ||
| 131 |
4/4✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 7 taken 6 times.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 1 time.
|
9 | [[nodiscard]] bool active() const noexcept { return m_unsubscribe != nullptr && !m_alive.expired(); } |
| 132 | |||
| 133 | private: | ||
| 134 | template <typename E> friend class EventDispatcher; | ||
| 135 | |||
| 136 | 10255 | Subscription(std::weak_ptr<void> alive, std::function<bool()> unsub) noexcept | |
| 137 | 30765 | : m_alive(std::move(alive)), m_unsubscribe(std::move(unsub)) | |
| 138 | { | ||
| 139 | 10255 | } | |
| 140 | |||
| 141 | std::weak_ptr<void> m_alive; | ||
| 142 | std::function<bool()> m_unsubscribe; | ||
| 143 | }; | ||
| 144 | |||
| 145 | /** | ||
| 146 | * @brief Thread-safe typed event dispatcher with RAII subscription management. | ||
| 147 | * | ||
| 148 | * @tparam Event The event type. Must be copyable or movable. Handlers receive events by const reference. | ||
| 149 | * | ||
| 150 | * @details Each EventDispatcher manages a single event type. For multiple event types, compose multiple | ||
| 151 | * dispatchers: | ||
| 152 | * @code | ||
| 153 | * struct MyEvents { | ||
| 154 | * EventDispatcher<PlayerStateChanged> player_state; | ||
| 155 | * EventDispatcher<CameraUpdated> camera; | ||
| 156 | * EventDispatcher<ConfigReloaded> config; | ||
| 157 | * }; | ||
| 158 | * @endcode | ||
| 159 | * | ||
| 160 | * **Thread safety:** | ||
| 161 | * - `emit()` / `emit_safe()`: the zero-subscriber fast path is wait-free | ||
| 162 | * (single atomic counter load). Otherwise acquires a `shared_ptr` snapshot of the immutable handler list and | ||
| 163 | * iterates it. The snapshot load avoids any reader lock; it is lock-free on toolchains with a | ||
| 164 | * DWCAS-backed `std::atomic<std::shared_ptr<T>>` and may use an implementation-internal bit lock on toolchains | ||
| 165 | * that do not. | ||
| 166 | * - `subscribe()` / `unsubscribe()`: copy-on-write under a small writer | ||
| 167 | * mutex. Each mutation allocates a new handler vector, appends or removes the entry, and publishes the new | ||
| 168 | * snapshot atomically. See the method docs for the OOM contract. | ||
| 169 | * - Handlers are invoked while the snapshot's `shared_ptr` keeps the | ||
| 170 | * vector alive. A thread-local reentrancy guard detects and rejects subscribe/unsubscribe calls from within a | ||
| 171 | * handler; the guard is what guarantees the user's "do not mutate during emit" invariant, not the snapshot | ||
| 172 | * mechanism. | ||
| 173 | * | ||
| 174 | * **Reentrancy guard scope:** The guard is per-template-instantiation, not per-instance. Two dispatchers of the | ||
| 175 | * same Event type share the same thread-local counter. Subscribing to a second dispatcher of the same type from | ||
| 176 | * within a handler on the first will be rejected. Use distinct event types to avoid this (the typical usage | ||
| 177 | * pattern). | ||
| 178 | * | ||
| 179 | * **Subscribe/emit ordering invariant:** A subscribe() performs a release-store on both the snapshot pointer and | ||
| 180 | * the atomic handler count. Any thread that observes the Subscription object returned from subscribe() (or | ||
| 181 | * synchronizes-with the thread that did) will see the subscription in subsequent emits. Without such a | ||
| 182 | * happens-before edge, a concurrent emit may or may not observe a freshly-published handler -- this matches the | ||
| 183 | * user's own ordering. | ||
| 184 | */ | ||
| 185 | template <typename Event> class EventDispatcher | ||
| 186 | { | ||
| 187 | public: | ||
| 188 | /// Handler function signature: receives the event by const reference. | ||
| 189 | using Handler = std::function<void(const Event &)>; | ||
| 190 | |||
| 191 | private: | ||
| 192 | // Private type aliases surfaced here so they are visible to the public API's member declarations and | ||
| 193 | // constructor below. | ||
| 194 | struct Entry | ||
| 195 | { | ||
| 196 | SubscriptionId id; | ||
| 197 | Handler callback; | ||
| 198 | }; | ||
| 199 | |||
| 200 | using HandlerList = std::vector<Entry>; | ||
| 201 | using SharedList = std::shared_ptr<const HandlerList>; | ||
| 202 | |||
| 203 | public: | ||
| 204 |
8/16DetourModKit::EventDispatcher<SimpleEvent>::EventDispatcher():
✓ Branch 2 → 3 taken 27 times.
✗ Branch 2 → 12 not taken.
✓ Branch 8 → 9 taken 27 times.
✗ Branch 8 → 13 not taken.
DetourModKit::EventDispatcher<StringEvent>::EventDispatcher():
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 12 not taken.
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 13 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::EventDispatcher():
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 12 not taken.
✓ Branch 8 → 9 taken 5 times.
✗ Branch 8 → 13 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::EventDispatcher():
✓ Branch 2 → 3 taken 118 times.
✗ Branch 2 → 12 not taken.
✓ Branch 8 → 9 taken 118 times.
✗ Branch 8 → 13 not taken.
|
152 | EventDispatcher() : m_handlers(std::make_shared<const HandlerList>()), m_alive(std::make_shared<char>('\0')) {} |
| 205 | |||
| 206 | 152 | ~EventDispatcher() noexcept = default; | |
| 207 | |||
| 208 | EventDispatcher(const EventDispatcher &) = delete; | ||
| 209 | EventDispatcher &operator=(const EventDispatcher &) = delete; | ||
| 210 | EventDispatcher(EventDispatcher &&) = delete; | ||
| 211 | EventDispatcher &operator=(EventDispatcher &&) = delete; | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @brief Subscribes a handler to this event type. | ||
| 215 | * @param handler Callable invoked on each emit(). Must be safe to call from any thread. | ||
| 216 | * @return RAII Subscription guard. The handler is removed when the guard is destroyed or reset(). | ||
| 217 | * @note Copy-on-write: allocates a new handler list of size N+1. | ||
| 218 | * Acceptable for the expected mutation rate (startup and occasional reconfiguration). Do not call from | ||
| 219 | * within a handler. | ||
| 220 | */ | ||
| 221 | 10258 | [[nodiscard]] Subscription subscribe(Handler handler) | |
| 222 | { | ||
| 223 |
5/8DetourModKit::EventDispatcher<SimpleEvent>::subscribe(std::function<void (SimpleEvent const&)>):
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 6 taken 10244 times.
DetourModKit::EventDispatcher<StringEvent>::subscribe(std::function<void (StringEvent const&)>):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 2 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::ScannerFaultEvent const&)>):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::HookLifecycleEvent const&)>):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 6 times.
|
10258 | if (emitting_depth() > 0) |
| 224 | { | ||
| 225 | // The reentrancy guard is per-template-instantiation, so a handler mutating a second dispatcher of the | ||
| 226 | // same Event type is rejected here invisibly to the caller. Surface it best-effort (never throw, never | ||
| 227 | // block) so the silent rejection is observable during development. assert fires the same condition in | ||
| 228 | // debug builds. | ||
| 229 | 3 | report_reentrant_rejection("subscribe"); | |
| 230 | 3 | return {}; | |
| 231 | } | ||
| 232 | |||
| 233 | 10255 | const auto id = static_cast<SubscriptionId>(this->m_next_id.fetch_add(1, std::memory_order_relaxed)); | |
| 234 | |||
| 235 | { | ||
| 236 |
4/8DetourModKit::EventDispatcher<SimpleEvent>::subscribe(std::function<void (SimpleEvent const&)>):
✓ Branch 8 → 9 taken 10244 times.
✗ Branch 8 → 56 not taken.
DetourModKit::EventDispatcher<StringEvent>::subscribe(std::function<void (StringEvent const&)>):
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 56 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::ScannerFaultEvent const&)>):
✓ Branch 8 → 9 taken 3 times.
✗ Branch 8 → 56 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::HookLifecycleEvent const&)>):
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 56 not taken.
|
10255 | std::scoped_lock lock{this->m_writer_mutex}; |
| 237 | 10255 | auto current = this->m_handlers.load(std::memory_order_acquire); | |
| 238 |
4/8DetourModKit::EventDispatcher<SimpleEvent>::subscribe(std::function<void (SimpleEvent const&)>):
✓ Branch 11 → 12 taken 10244 times.
✗ Branch 11 → 52 not taken.
DetourModKit::EventDispatcher<StringEvent>::subscribe(std::function<void (StringEvent const&)>):
✓ Branch 11 → 12 taken 2 times.
✗ Branch 11 → 52 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::ScannerFaultEvent const&)>):
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 52 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::HookLifecycleEvent const&)>):
✓ Branch 11 → 12 taken 6 times.
✗ Branch 11 → 52 not taken.
|
10255 | auto next = std::make_shared<HandlerList>(*current); |
| 239 |
4/8DetourModKit::EventDispatcher<SimpleEvent>::subscribe(std::function<void (SimpleEvent const&)>):
✓ Branch 16 → 17 taken 10244 times.
✗ Branch 16 → 47 not taken.
DetourModKit::EventDispatcher<StringEvent>::subscribe(std::function<void (StringEvent const&)>):
✓ Branch 16 → 17 taken 2 times.
✗ Branch 16 → 47 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::ScannerFaultEvent const&)>):
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 47 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::subscribe(std::function<void (DetourModKit::Diagnostics::HookLifecycleEvent const&)>):
✓ Branch 16 → 17 taken 6 times.
✗ Branch 16 → 47 not taken.
|
20510 | next->push_back(Entry{id, std::move(handler)}); |
| 240 | // Publish the new count first so a reader that sees 0 on the counter and skips the snapshot load cannot | ||
| 241 | // miss a handler that has already been installed in the snapshot. | ||
| 242 | 10255 | this->m_handler_count.store(next->size(), std::memory_order_release); | |
| 243 | 20510 | this->m_handlers.store(std::shared_ptr<const HandlerList>(std::move(next)), std::memory_order_release); | |
| 244 | 10255 | } | |
| 245 | |||
| 246 | 10255 | std::weak_ptr<void> weak = this->m_alive; | |
| 247 | 30766 | return Subscription(std::move(weak), [this, id]() noexcept -> bool { return this->unsubscribe(id); }); | |
| 248 | 30765 | } | |
| 249 | |||
| 250 | /** | ||
| 251 | * @brief Emits an event to all subscribers. | ||
| 252 | * @param event The event payload, passed by const reference to each handler. | ||
| 253 | * @note No user-visible mutex on the read path: performs one atomic acquire-load of the snapshot | ||
| 254 | * pointer and iterates. Multiple threads may emit concurrently without contention. Handlers are invoked | ||
| 255 | * synchronously in subscription order. Exceptions thrown by handlers propagate to the caller. | ||
| 256 | * @warning If calling from a game hook callback or any context where an unhandled exception would crash the | ||
| 257 | * host process, use emit_safe() instead. emit() lets handler exceptions propagate uncaught, which will | ||
| 258 | * terminate the process if no catch frame exists above the call site. | ||
| 259 | */ | ||
| 260 | 8719 | void emit(const Event &event) const | |
| 261 | { | ||
| 262 | // Fast path: no subscribers means no snapshot load at all. | ||
| 263 |
3/4DetourModKit::EventDispatcher<SimpleEvent>::emit(SimpleEvent const&) const:
✓ Branch 9 → 10 taken 1004 times.
✓ Branch 9 → 11 taken 7641 times.
DetourModKit::EventDispatcher<StringEvent>::emit(StringEvent const&) const:
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 2 times.
|
17366 | if (this->m_handler_count.load(std::memory_order_acquire) == 0) |
| 264 | { | ||
| 265 | 1004 | return; | |
| 266 | } | ||
| 267 | |||
| 268 | 7643 | SharedList snap = this->m_handlers.load(std::memory_order_acquire); | |
| 269 | 8050 | EmitGuard guard{emitting_depth()}; | |
| 270 |
4/4DetourModKit::EventDispatcher<SimpleEvent>::emit(SimpleEvent const&) const:
✓ Branch 29 → 17 taken 7798 times.
✓ Branch 29 → 30 taken 7673 times.
DetourModKit::EventDispatcher<StringEvent>::emit(StringEvent const&) const:
✓ Branch 29 → 17 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
|
23488 | for (const auto &entry : *snap) |
| 271 | { | ||
| 272 |
3/4DetourModKit::EventDispatcher<SimpleEvent>::emit(SimpleEvent const&) const:
✓ Branch 19 → 20 taken 7850 times.
✓ Branch 19 → 34 taken 1 time.
DetourModKit::EventDispatcher<StringEvent>::emit(StringEvent const&) const:
✓ Branch 19 → 20 taken 2 times.
✗ Branch 19 → 34 not taken.
|
7800 | entry.callback(event); |
| 273 | } | ||
| 274 | 7677 | } | |
| 275 | |||
| 276 | /** | ||
| 277 | * @brief Emits an event, catching and discarding handler exceptions. | ||
| 278 | * @param event The event payload. | ||
| 279 | * @note Same read-path semantics as emit() (no user-visible mutex). Handlers that throw are skipped; remaining | ||
| 280 | * handlers still execute. Prefer this over emit() when calling from hook callbacks or other contexts | ||
| 281 | * where an unhandled exception would crash the host process. | ||
| 282 | */ | ||
| 283 | 809482 | void emit_safe(const Event &event) const noexcept | |
| 284 | { | ||
| 285 |
6/6DetourModKit::EventDispatcher<SimpleEvent>::emit_safe(SimpleEvent const&) const:
✓ Branch 9 → 10 taken 232193 times.
✓ Branch 9 → 11 taken 566711 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::emit_safe(DetourModKit::Diagnostics::ScannerFaultEvent const&) const:
✓ Branch 9 → 10 taken 328 times.
✓ Branch 9 → 11 taken 3183 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::emit_safe(DetourModKit::Diagnostics::HookLifecycleEvent const&) const:
✓ Branch 9 → 10 taken 286 times.
✓ Branch 9 → 11 taken 9 times.
|
1612192 | if (this->m_handler_count.load(std::memory_order_acquire) == 0) |
| 286 | { | ||
| 287 | 232807 | return; | |
| 288 | } | ||
| 289 | |||
| 290 | // std::shared_ptr copy-construction and load are noexcept, so the entire function remains noexcept despite | ||
| 291 | // the per-handler catch. | ||
| 292 | 569903 | SharedList snap = this->m_handlers.load(std::memory_order_acquire); | |
| 293 | 611073 | EmitGuard guard{emitting_depth()}; | |
| 294 |
6/6DetourModKit::EventDispatcher<SimpleEvent>::emit_safe(SimpleEvent const&) const:
✓ Branch 29 → 17 taken 590346 times.
✓ Branch 29 → 30 taken 542287 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::emit_safe(DetourModKit::Diagnostics::ScannerFaultEvent const&) const:
✓ Branch 29 → 17 taken 3183 times.
✓ Branch 29 → 30 taken 3183 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::emit_safe(DetourModKit::Diagnostics::HookLifecycleEvent const&) const:
✓ Branch 29 → 17 taken 9 times.
✓ Branch 29 → 30 taken 9 times.
|
1745952 | for (const auto &entry : *snap) |
| 295 | { | ||
| 296 | try | ||
| 297 | { | ||
| 298 |
4/6DetourModKit::EventDispatcher<SimpleEvent>::emit_safe(SimpleEvent const&) const:
✓ Branch 19 → 20 taken 576527 times.
✓ Branch 19 → 34 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::emit_safe(DetourModKit::Diagnostics::ScannerFaultEvent const&) const:
✓ Branch 19 → 20 taken 3183 times.
✗ Branch 19 → 34 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::emit_safe(DetourModKit::Diagnostics::HookLifecycleEvent const&) const:
✓ Branch 19 → 20 taken 9 times.
✗ Branch 19 → 34 not taken.
|
593538 | entry.callback(event); |
| 299 | } | ||
| 300 | 3 | catch (...) | |
| 301 | { | ||
| 302 | } | ||
| 303 | } | ||
| 304 | 545479 | } | |
| 305 | |||
| 306 | /// Returns the number of active subscribers. | ||
| 307 | 19 | [[nodiscard]] size_t subscriber_count() const noexcept | |
| 308 | { | ||
| 309 | 38 | return this->m_handler_count.load(std::memory_order_acquire); | |
| 310 | } | ||
| 311 | |||
| 312 | /// Returns true if there are no subscribers. | ||
| 313 | 8 | [[nodiscard]] bool empty() const noexcept { return this->m_handler_count.load(std::memory_order_acquire) == 0; } | |
| 314 | |||
| 315 | /** | ||
| 316 | * @brief Removes all subscribers. | ||
| 317 | * @note Serializes with other writers via the writer mutex; readers in flight keep their snapshot alive through | ||
| 318 | * their shared_ptr. Allocates a fresh empty snapshot. On allocation failure the dispatcher state is left | ||
| 319 | * unchanged (best-effort no-op) so the noexcept contract is never violated by a throwing allocator. | ||
| 320 | */ | ||
| 321 | 1 | void clear() noexcept | |
| 322 | { | ||
| 323 | 1 | std::scoped_lock lock{this->m_writer_mutex}; | |
| 324 | // Build the replacement snapshot before touching any published state so a throwing allocator leaves | ||
| 325 | // m_handlers / m_handler_count in their prior consistent pair. Swallowing bad_alloc keeps clear() a | ||
| 326 | // noexcept best-effort teardown. | ||
| 327 | 1 | std::shared_ptr<const HandlerList> empty_snap; | |
| 328 | try | ||
| 329 | { | ||
| 330 |
1/2✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 30 not taken.
|
1 | empty_snap = std::make_shared<const HandlerList>(); |
| 331 | } | ||
| 332 | ✗ | catch (...) | |
| 333 | { | ||
| 334 | ✗ | return; | |
| 335 | } | ||
| 336 | // Counter must go to 0 before publishing the empty snapshot so an emit that reads 0 on the fast-path | ||
| 337 | // counter cannot still see the non-empty old snapshot afterwards. | ||
| 338 | 1 | this->m_handler_count.store(0, std::memory_order_release); | |
| 339 | 2 | this->m_handlers.store(std::move(empty_snap), std::memory_order_release); | |
| 340 |
2/4✓ Branch 21 → 22 taken 1 time.
✗ Branch 21 → 23 not taken.
✓ Branch 25 → 26 taken 1 time.
✗ Branch 25 → 28 not taken.
|
1 | } |
| 341 | |||
| 342 | #if defined(DMK_EVENT_DISPATCHER_INTERNAL_TESTING) | ||
| 343 | /** | ||
| 344 | * @brief Test-only diagnostic: returns the number of outstanding references to the current handler snapshot, | ||
| 345 | * excluding the temporary this call itself creates. A value of 1 means the dispatcher's own atomic is | ||
| 346 | * the sole holder (steady state). A value >1 indicates an in-flight emit or a leaked snapshot reference. | ||
| 347 | * Enabled only when | ||
| 348 | * DMK_EVENT_DISPATCHER_INTERNAL_TESTING is defined by the test translation unit. Not part of the public | ||
| 349 | * API. | ||
| 350 | */ | ||
| 351 | 3 | [[nodiscard]] long debug_snapshot_use_count() const noexcept | |
| 352 | { | ||
| 353 | // load() returns a shared_ptr copy that bumps the refcount by 1 for its own lifetime; subtract that so the | ||
| 354 | // reported count reflects only the other holders (the dispatcher atomic and any in-flight emit snapshots). | ||
| 355 | 3 | auto snap = this->m_handlers.load(std::memory_order_acquire); | |
| 356 | 3 | return snap.use_count() - 1; | |
| 357 | 3 | } | |
| 358 | #endif | ||
| 359 | |||
| 360 | private: | ||
| 361 | // Returns false when called from within a handler (reentrancy) or when the replacement snapshot could not be | ||
| 362 | // allocated. The | ||
| 363 | // Subscription::reset() caller retains its m_unsubscribe lambda on false returns and will retry on the next | ||
| 364 | // reset() call (including the destructor). This is safe because the m_alive weak_ptr prevents calling into a | ||
| 365 | // destroyed dispatcher, and on allocation failure the published state is left untouched so the retry observes | ||
| 366 | // the same entry still present. | ||
| 367 | // | ||
| 368 | // Allocates (std::make_shared + vector growth). On OOM, leaves the dispatcher state unchanged and returns false | ||
| 369 | // so the RAII retry path handles it naturally. | ||
| 370 | 10256 | bool unsubscribe(SubscriptionId id) noexcept | |
| 371 | { | ||
| 372 |
5/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 6 taken 10243 times.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 2 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 6 times.
|
10256 | if (emitting_depth() > 0) |
| 373 | { | ||
| 374 | // Same per-instantiation guard as subscribe(): a handler that triggers an unsubscribe (directly or via | ||
| 375 | // a Subscription reset/destructor) on a same-type dispatcher is rejected here. Surface it best-effort | ||
| 376 | // so the rejection is not silent; the RAII path retries the unsubscribe after the emit stack unwinds. | ||
| 377 | 2 | report_reentrant_rejection("unsubscribe"); | |
| 378 | 2 | return false; | |
| 379 | } | ||
| 380 | |||
| 381 | 10254 | std::scoped_lock lock{this->m_writer_mutex}; | |
| 382 | 10254 | auto current = this->m_handlers.load(std::memory_order_acquire); | |
| 383 | auto it = | ||
| 384 | 20519 | std::find_if(current->begin(), current->end(), [id](const Entry &entry) { return entry.id == id; }); | |
| 385 |
5/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 10241 times.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 2 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 6 times.
|
20508 | if (it == current->end()) |
| 386 | { | ||
| 387 | // Not found; treat as successful (idempotent unsubscribe). | ||
| 388 | 2 | return true; | |
| 389 | } | ||
| 390 | |||
| 391 | // Build the replacement snapshot in full before touching any published state. A throwing allocator (reserve | ||
| 392 | // / push_back / make_shared) must not leave m_handlers and m_handler_count out of sync, and noexcept | ||
| 393 | // forbids propagation, so we catch bad_alloc and fall through to the false-return retry path. | ||
| 394 | 10252 | std::shared_ptr<HandlerList> next; | |
| 395 | try | ||
| 396 | { | ||
| 397 |
4/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 23 → 24 taken 10241 times.
✗ Branch 23 → 69 not taken.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 69 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 23 → 24 taken 3 times.
✗ Branch 23 → 69 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 23 → 24 taken 6 times.
✗ Branch 23 → 69 not taken.
|
10252 | next = std::make_shared<HandlerList>(); |
| 398 |
4/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 29 → 30 taken 10241 times.
✗ Branch 29 → 71 not taken.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 29 → 30 taken 2 times.
✗ Branch 29 → 71 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 29 → 30 taken 3 times.
✗ Branch 29 → 71 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 29 → 30 taken 6 times.
✗ Branch 29 → 71 not taken.
|
10252 | next->reserve(current->size() - 1); |
| 399 |
8/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 47 → 33 taken 10302 times.
✓ Branch 47 → 48 taken 10241 times.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 47 → 33 taken 2 times.
✓ Branch 47 → 48 taken 2 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 47 → 33 taken 3 times.
✓ Branch 47 → 48 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 47 → 33 taken 6 times.
✓ Branch 47 → 48 taken 6 times.
|
30817 | for (const auto &entry : *current) |
| 400 | { | ||
| 401 |
5/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 35 → 36 taken 61 times.
✓ Branch 35 → 38 taken 10241 times.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 38 taken 2 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 38 taken 3 times.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 38 taken 6 times.
|
10313 | if (entry.id != id) |
| 402 | { | ||
| 403 |
1/8DetourModKit::EventDispatcher<SimpleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✓ Branch 37 → 38 taken 61 times.
✗ Branch 37 → 70 not taken.
DetourModKit::EventDispatcher<StringEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 70 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 70 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::unsubscribe(DetourModKit::SubscriptionId):
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 70 not taken.
|
61 | next->push_back(entry); |
| 404 | } | ||
| 405 | } | ||
| 406 | } | ||
| 407 | ✗ | catch (...) | |
| 408 | { | ||
| 409 | ✗ | return false; | |
| 410 | } | ||
| 411 | |||
| 412 | // Publish snapshot first, then the counter. An emit that loads a stale snapshot containing the removed | ||
| 413 | // handler is still safe because the handler callable is retained by the old snapshot. | ||
| 414 | 20504 | this->m_handlers.store(std::shared_ptr<const HandlerList>(std::move(next)), std::memory_order_release); | |
| 415 | 10252 | this->m_handler_count.store(current->size() - 1, std::memory_order_release); | |
| 416 | 10252 | return true; | |
| 417 | 10254 | } | |
| 418 | |||
| 419 | /** | ||
| 420 | * @brief Best-effort report that the reentrancy guard rejected a mutation from within a handler. | ||
| 421 | * @details Emits a Debug log via Logger::try_log so the otherwise-silent per-instantiation rejection surfaces | ||
| 422 | * during development. The whole path is wrapped because Logger::get_instance() may construct the | ||
| 423 | * singleton if logging was not initialized yet; any failure is swallowed so this best-effort | ||
| 424 | * diagnostic never turns a rejected mutation into host termination. Deliberately does NOT assert: an | ||
| 425 | * unsubscribe rejected mid-emit is a legitimate RAII path -- a Subscription reset or destroyed inside | ||
| 426 | * a handler calls unsubscribe(), which is refused here and retried after the emit stack unwinds -- so | ||
| 427 | * aborting on it would be wrong. Zero-cost on the success path because it is only reached after the | ||
| 428 | * guard has already rejected the call. | ||
| 429 | */ | ||
| 430 | 5 | static void report_reentrant_rejection(const char *op) noexcept | |
| 431 | { | ||
| 432 | try | ||
| 433 | { | ||
| 434 |
1/8DetourModKit::EventDispatcher<SimpleEvent>::report_reentrant_rejection(char const*):
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 6 not taken.
DetourModKit::EventDispatcher<StringEvent>::report_reentrant_rejection(char const*):
✗ Branch 2 → 3 not taken.
✗ Branch 2 → 6 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::ScannerFaultEvent>::report_reentrant_rejection(char const*):
✗ Branch 2 → 3 not taken.
✗ Branch 2 → 6 not taken.
DetourModKit::EventDispatcher<DetourModKit::Diagnostics::HookLifecycleEvent>::report_reentrant_rejection(char const*):
✗ Branch 2 → 3 not taken.
✗ Branch 2 → 6 not taken.
|
5 | (void)Logger::get_instance().try_log( |
| 435 | LogLevel::Debug, | ||
| 436 | "EventDispatcher: {} rejected -- called from within a handler on a same-type dispatcher " | ||
| 437 | "(per-instantiation reentrancy guard). Defer the mutation until the emit returns.", | ||
| 438 | op); | ||
| 439 | } | ||
| 440 | ✗ | catch (...) | |
| 441 | { | ||
| 442 | } | ||
| 443 | 5 | } | |
| 444 | |||
| 445 | // Thread-local emit depth counter. This is per-template-instantiation (not per-instance) because making it | ||
| 446 | // per-instance would require a thread_local map keyed by this pointer, adding a hash lookup to every emit() hot | ||
| 447 | // path. The typical usage is one dispatcher per event type, so the shared counter is the correct tradeoff. See | ||
| 448 | // the class-level doc for details. | ||
| 449 | 633799 | [[nodiscard]] int &emitting_depth() const noexcept | |
| 450 | { | ||
| 451 | // Shared across all dispatcher instances on the same thread. The reentrancy guard is per-thread | ||
| 452 | // (intentional), not per-dispatcher. | ||
| 453 | thread_local int depth{0}; | ||
| 454 | 633799 | return depth; | |
| 455 | } | ||
| 456 | |||
| 457 | /// RAII guard that increments/decrements the emit depth counter. | ||
| 458 | struct EmitGuard | ||
| 459 | { | ||
| 460 | int &depth; | ||
| 461 | 614686 | explicit EmitGuard(int &depth_ref) noexcept : depth(depth_ref) { ++depth; } | |
| 462 | 582361 | ~EmitGuard() noexcept { --depth; } | |
| 463 | EmitGuard(const EmitGuard &) = delete; | ||
| 464 | EmitGuard &operator=(const EmitGuard &) = delete; | ||
| 465 | EmitGuard(EmitGuard &&) = delete; | ||
| 466 | EmitGuard &operator=(EmitGuard &&) = delete; | ||
| 467 | }; | ||
| 468 | |||
| 469 | // alignas(64) keeps the hot atomics on their own cache line so the writer mutex and shared_ptr control-block | ||
| 470 | // traffic do not produce false sharing with readers doing the fast-path counter load. | ||
| 471 | alignas(64) mutable std::atomic<SharedList> m_handlers; | ||
| 472 | std::atomic<size_t> m_handler_count{0}; | ||
| 473 | std::atomic<uint64_t> m_next_id{1}; | ||
| 474 | std::mutex m_writer_mutex; // serializes writers only | ||
| 475 | // Prevents Subscription::reset() from calling unsubscribe() after dispatcher destruction. | ||
| 476 | std::shared_ptr<void> m_alive; | ||
| 477 | }; | ||
| 478 | |||
| 479 | } // namespace DetourModKit | ||
| 480 | |||
| 481 | #endif // DETOURMODKIT_EVENT_DISPATCHER_HPP | ||
| 482 |