GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 83.9% 230 / 0 / 274
Functions: 94.6% 53 / 0 / 56
Branches: 61.7% 100 / 0 / 162

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/16
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/4
DetourModKit::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/4
DetourModKit::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/4
DetourModKit::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/6
DetourModKit::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/6
DetourModKit::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/6
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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/8
DetourModKit::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