GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 18 / 0 / 18
Functions: 100.0% 5 / 0 / 5
Branches: 100.0% 14 / 0 / 14

include/DetourModKit/async_logger.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_ASYNC_LOGGER_HPP
2 #define DETOURMODKIT_ASYNC_LOGGER_HPP
3
4 #include "DetourModKit/logger.hpp"
5
6 #include <array>
7 #include <atomic>
8 #include <chrono>
9 #include <condition_variable>
10 #include <cstddef>
11 #include <cstdint>
12 #include <memory>
13 #include <mutex>
14 #include <span>
15 #include <string>
16 #include <string_view>
17 #include <thread>
18 #include <vector>
19
20 namespace DetourModKit
21 {
22 /// Default capacity (slot count) of the bounded MPMC message queue.
23 inline constexpr size_t DEFAULT_QUEUE_CAPACITY = 8192;
24 /// Default number of messages the writer drains per write batch.
25 inline constexpr size_t DEFAULT_BATCH_SIZE = 64;
26 /// Default interval between periodic writer flushes.
27 inline constexpr auto DEFAULT_FLUSH_INTERVAL = std::chrono::milliseconds(100);
28 /// Maximum length, in bytes, of a single log message.
29 inline constexpr size_t MAX_MESSAGE_SIZE = 16777216;
30 /// Default spin-backoff iteration count before a producer yields/parks.
31 inline constexpr size_t DEFAULT_SPIN_BACKOFF_ITERATIONS = 32;
32 /// Default timeout for a blocking flush to complete.
33 inline constexpr auto DEFAULT_FLUSH_TIMEOUT = std::chrono::milliseconds(500);
34 /// Byte size of one StringPool overflow block.
35 inline constexpr size_t MEMORY_POOL_BLOCK_SIZE = 4096;
36 /// Number of overflow blocks the StringPool preallocates.
37 inline constexpr size_t MEMORY_POOL_BLOCK_COUNT = 64;
38 /// Number of allocation slots carved from each StringPool block.
39 inline constexpr size_t POOL_SLOTS_PER_BLOCK = 16;
40
41 /**
42 * @enum OverflowPolicy
43 * @brief Action taken by AsyncLogger::enqueue when the bounded queue is full.
44 * @warning Only DropNewest and DropOldest are callback-safe. Block parks the producer and SyncFallback writes
45 * synchronously, so neither may be used by a logger that hook or input callbacks emit through; reserve
46 * them for setup/control-plane logging where a brief stall under sink backpressure is acceptable.
47 */
48 enum class OverflowPolicy
49 {
50 /// Drop the message being enqueued when the queue is full. Non-blocking and callback-safe.
51 DropNewest,
52 /// Evict the oldest queued message to make room for the new one. Non-blocking and callback-safe.
53 DropOldest,
54 /// Park the producer until space frees or block_timeout_ms elapses. Not callback-safe (can stall the caller).
55 Block,
56 /// Write the message synchronously on the producer thread. Not callback-safe (synchronous sink I/O).
57 SyncFallback
58 };
59
60 /**
61 * @class StringPool
62 * @brief Memory pool for small string allocations to reduce heap fragmentation.
63 * @details Uses a free-list approach for O(1) allocation/deallocation. Blocks are allocated on-demand up to
64 * MEMORY_POOL_BLOCK_COUNT. Each block is cache-line aligned to prevent false sharing.
65 *
66 * @note The singleton returned by instance() is intentionally leaked to avoid the static destruction order fiasco
67 * with late LogMessage teardown. Neither Bootstrap::request_shutdown() nor DMK_Shutdown() reclaim it; the OS
68 * releases the memory at process exit. The leak is bounded to MEMORY_POOL_BLOCK_COUNT blocks of
69 * MEMORY_POOL_BLOCK_SIZE bytes.
70 */
71 class StringPool
72 {
73 public:
74 static StringPool &instance() noexcept;
75
76 [[nodiscard]] std::string *allocate(size_t size) noexcept;
77 void deallocate(std::string *ptr) noexcept;
78
79 StringPool(const StringPool &) = delete;
80 StringPool &operator=(const StringPool &) = delete;
81 StringPool(StringPool &&) = delete;
82 StringPool &operator=(StringPool &&) = delete;
83
84 private:
85 struct PoolSlot
86 {
87 std::string str;
88 PoolSlot *next_free{nullptr};
89 };
90 #if defined(__GNUC__) || defined(__clang__)
91 #pragma GCC diagnostic push
92 #pragma GCC diagnostic ignored "-Winvalid-offsetof"
93 #endif
94 static_assert(offsetof(PoolSlot, str) == 0,
95 "PoolSlot::str must be the first member for pointer arithmetic in deallocate()");
96 #if defined(__GNUC__) || defined(__clang__)
97 #pragma GCC diagnostic pop
98 #endif
99
100 struct Block
101 {
102 alignas(64) char data[POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot)];
103 Block *next{nullptr};
104 PoolSlot *free_list{nullptr};
105 uint32_t constructed_mask{0};
106 };
107
108 StringPool() noexcept;
109 ~StringPool() noexcept;
110
111 /**
112 * @brief Appends one block to the pool. Must be called with m_pool_mutex held.
113 * @details No-throw: an allocation failure leaves the pool unchanged so
114 * callers can fall back to a nothrow heap string instead of throwing out of the logging path.
115 */
116 void grow_pool_locked() noexcept;
117 PoolSlot *claim_free_slot() noexcept;
118 void return_slot_locked(PoolSlot *slot, Block *block) noexcept;
119
120 std::atomic<Block *> m_head{nullptr};
121 std::atomic<size_t> m_heap_fallback_count{0};
122 std::mutex m_pool_mutex;
123 };
124
125 /**
126 * @struct LogMessage
127 * @brief A log entry with inline buffer optimization and overflow handling.
128 * @details Messages <= 512 bytes are stored inline. Larger messages use heap allocation via StringPool. This is a
129 * move-only transport/value type (copy deleted, move defined) carried by value through the queue; it
130 * keeps plain field names rather than the m_ member prefix, per the POD-struct naming convention, even
131 * though @ref overflow is an owned heap pointer -- its lifetime is self-contained, released by reset()
132 * and the destructor, so no class invariant rides on member encapsulation.
133 */
134 struct LogMessage
135 {
136 LogLevel level{LogLevel::Info};
137 std::chrono::system_clock::time_point timestamp;
138 std::thread::id thread_id;
139
140 static constexpr size_t MAX_INLINE_SIZE = LOG_INLINE_MESSAGE_SIZE;
141 static constexpr size_t MAX_VALID_LENGTH = MAX_MESSAGE_SIZE;
142 // Left uninitialized (raw storage): only [0, length) is ever read, and every constructor/move writes exactly
143 // length bytes before any read. Zero-filling the whole inline buffer would memset MAX_INLINE_SIZE bytes on each
144 // construction/enqueue for no observable effect.
145 std::array<char, MAX_INLINE_SIZE> buffer;
146 size_t length{0};
147
148 // Owned: allocated by StringPool, freed by reset().
149 std::string *overflow{nullptr};
150
151 LogMessage(LogLevel lvl, std::string_view msg) noexcept;
152 425761 LogMessage() noexcept = default;
153
154 ~LogMessage() noexcept;
155
156 LogMessage(LogMessage &&other) noexcept;
157 LogMessage &operator=(LogMessage &&other) noexcept;
158
159 LogMessage(const LogMessage &) = delete;
160 LogMessage &operator=(const LogMessage &) = delete;
161
162 [[nodiscard]] std::string_view message() const noexcept;
163 [[nodiscard]] bool is_valid() const noexcept;
164 void reset() noexcept;
165 };
166
167 /**
168 * @class DynamicMPMCQueue
169 * @brief A dynamically-sized, bounded Multi-Producer Multi-Consumer queue.
170 * @details Uses a ring buffer with atomic sequence numbers for lock-free synchronization. Capacity is determined at
171 * construction time.
172 * @note This queue is designed to be constructed once and never resized. Moving slots after construction is not
173 * supported and will cause data corruption.
174 */
175 #ifdef _MSC_VER
176 #pragma warning(push)
177 // structure was padded due to alignment specifier
178 #pragma warning(disable : 4324)
179 #endif
180
181 class DynamicMPMCQueue
182 {
183 public:
184 /**
185 * @brief Constructs a queue with the specified capacity.
186 * @param capacity The maximum number of elements (must be power of 2 and >= 2).
187 */
188 explicit DynamicMPMCQueue(size_t capacity);
189
190 82 ~DynamicMPMCQueue() noexcept = default;
191
192 DynamicMPMCQueue(const DynamicMPMCQueue &) = delete;
193 DynamicMPMCQueue &operator=(const DynamicMPMCQueue &) = delete;
194 DynamicMPMCQueue(DynamicMPMCQueue &&) = delete;
195 DynamicMPMCQueue &operator=(DynamicMPMCQueue &&) = delete;
196
197 /**
198 * @brief Attempts to push an item into the queue.
199 * @param item The item to push. Moved into the queue on success only;
200 * left unchanged on failure so the caller can retry or handle overflow.
201 * @return true if successful, false if queue is full.
202 */
203 [[nodiscard]] bool try_push(LogMessage &item);
204
205 /**
206 * @brief Attempts to pop an item from the queue.
207 * @param item Reference to store the popped item.
208 * @return true if successful, false if queue is empty.
209 */
210 [[nodiscard]] bool try_pop(LogMessage &item);
211
212 /**
213 * @brief Attempts to pop multiple items up to a maximum count.
214 * @param items Reference to a vector to store popped items.
215 * @param max_count Maximum number of items to pop.
216 * @return size_t Number of items actually popped.
217 */
218 [[nodiscard]] size_t try_pop_batch(std::vector<LogMessage> &items, size_t max_count);
219
220 /// Returns the approximate number of items in the queue.
221 [[nodiscard]] size_t size() const noexcept;
222
223 /// Checks if the queue is approximately empty.
224 [[nodiscard]] bool empty() const noexcept;
225
226 /**
227 * @brief Returns the capacity of the queue.
228 * @return size_t The maximum number of elements.
229 */
230 1 [[nodiscard]] size_t capacity() const noexcept { return m_capacity; }
231
232 private:
233 struct Slot
234 {
235 std::atomic<size_t> sequence;
236 LogMessage data;
237
238 412578 Slot() noexcept : sequence(0) {}
239
240 Slot(const Slot &) = delete;
241 Slot &operator=(const Slot &) = delete;
242 Slot(Slot &&) = delete;
243 Slot &operator=(Slot &&) = delete;
244 };
245
246 /**
247 * @brief Validates capacity before member initialization to prevent allocation of an invalid-sized buffer in
248 * the initializer list.
249 */
250 static size_t validated_capacity(size_t capacity);
251
252 // Immutable after construction -- never resized.
253 const size_t m_capacity;
254 const size_t m_mask;
255
256 // Allocated once in the constructor; the unique_ptr ensures immutability (no accidental resize) while
257 // maintaining contiguous cache-friendly layout.
258 std::unique_ptr<Slot[]> m_buffer;
259
260 // Cache-line aligned to prevent false sharing between producers and consumers.
261 alignas(64) std::atomic<size_t> m_enqueue_pos{0};
262 alignas(64) std::atomic<size_t> m_dequeue_pos{0};
263 };
264
265 #ifdef _MSC_VER
266 #pragma warning(pop)
267 #endif
268
269 /**
270 * @struct AsyncLoggerConfig
271 * @brief Configuration for the async logger.
272 * @details The default queue holds DEFAULT_QUEUE_CAPACITY (8192) slots; each slot embeds a LogMessage with a
273 * LOG_INLINE_MESSAGE_SIZE (512) byte inline buffer, so the ring buffer's resident footprint is on the
274 * order of a few MiB at the default capacity (queue_capacity must stay a power of two). The StringPool
275 * used for overflow (> 512 byte) messages is a separate, lazily grown allocation bounded to
276 * MEMORY_POOL_BLOCK_COUNT * MEMORY_POOL_BLOCK_SIZE (256 KiB); see StringPool. Shrink queue_capacity for
277 * memory-constrained hosts.
278 */
279 struct AsyncLoggerConfig
280 {
281 size_t queue_capacity = DEFAULT_QUEUE_CAPACITY;
282 size_t batch_size = DEFAULT_BATCH_SIZE;
283 std::chrono::milliseconds flush_interval = DEFAULT_FLUSH_INTERVAL;
284 OverflowPolicy overflow_policy = OverflowPolicy::DropOldest;
285 size_t spin_backoff_iterations = DEFAULT_SPIN_BACKOFF_ITERATIONS;
286 std::chrono::milliseconds block_timeout_ms{16};
287 size_t block_max_spin_iterations{1000};
288 /**
289 * @brief strftime-style date/time format for the async sink.
290 * @details Kept in sync with the synchronous Logger by Logger::enable_async_mode so both sinks emit identical
291 * timestamps; the trailing ".<ms>" is appended by the writer, not this format.
292 */
293 std::string timestamp_format{"%Y-%m-%d %H:%M:%S"};
294
295 81 [[nodiscard]] constexpr bool validate() const noexcept
296 {
297
4/4
✓ Branch 2 → 3 taken 79 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 78 times.
81 if (queue_capacity < 2 || (queue_capacity & (queue_capacity - 1)) != 0)
298 3 return false;
299
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 77 times.
78 if (batch_size == 0)
300 1 return false;
301
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 75 times.
77 if (flush_interval.count() <= 0)
302 2 return false;
303
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 74 times.
75 if (spin_backoff_iterations == 0)
304 1 return false;
305
2/2
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 15 taken 72 times.
74 if (block_timeout_ms.count() <= 0)
306 2 return false;
307
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 71 times.
72 if (block_max_spin_iterations == 0)
308 1 return false;
309 71 return true;
310 }
311 };
312
313 // Compile-time validation: Default queue capacity must be a power of 2 and >= 2
314 static_assert(DEFAULT_QUEUE_CAPACITY >= 2 && (DEFAULT_QUEUE_CAPACITY & (DEFAULT_QUEUE_CAPACITY - 1)) == 0,
315 "DEFAULT_QUEUE_CAPACITY must be a power of 2 and at least 2");
316
317 /**
318 * @class AsyncLogger
319 * @brief Asynchronous logger that decouples log production from file I/O.
320 * @details Uses a lock-free queue to accept log messages from multiple threads and a dedicated writer thread to
321 * perform batched file writes. This significantly reduces latency on the producer side.
322 * @note Uses shared_ptr<WinFileStream> to safely handle Logger reconfiguration during runtime.
323 */
324 class AsyncLogger
325 {
326 public:
327 /**
328 * @brief Constructs an AsyncLogger with the given configuration.
329 * @param config The async logger configuration.
330 * @param file_stream Shared pointer to the output file stream (allows safe reconfigure).
331 * @param log_mutex Shared pointer to the mutex protecting the file stream.
332 */
333 explicit AsyncLogger(const AsyncLoggerConfig &config, std::shared_ptr<WinFileStream> file_stream,
334 std::shared_ptr<std::mutex> log_mutex);
335
336 ~AsyncLogger() noexcept;
337
338 AsyncLogger(const AsyncLogger &) = delete;
339 AsyncLogger &operator=(const AsyncLogger &) = delete;
340 AsyncLogger(AsyncLogger &&) = delete;
341 AsyncLogger &operator=(AsyncLogger &&) = delete;
342
343 /**
344 * @brief Enqueues a log message for asynchronous writing.
345 * @param level The log level.
346 * @param message The message string.
347 * @return true if the message was successfully enqueued or written, false if dropped or timed out.
348 * @details Non-blocking under the DropNewest / DropOldest policies. Under OverflowPolicy::Block a full queue
349 * parks the caller up to block_timeout_ms, and under OverflowPolicy::SyncFallback a full queue writes
350 * the message synchronously on the calling thread, so neither of those policies is callback-safe (see
351 * OverflowPolicy). Otherwise the message is written by the writer thread.
352 * @note Best-effort: never throws and returns false on drop. Callback-safe only under the DropNewest /
353 * DropOldest policies (see @details).
354 */
355 [[nodiscard]] bool enqueue(LogLevel level, std::string_view message) noexcept;
356
357 /**
358 * @brief Flushes all pending log messages with a timeout.
359 * @param timeout Maximum time to wait for flush to complete.
360 * @return true if all messages were flushed, false if timeout occurred.
361 */
362 [[nodiscard]] bool flush_with_timeout(std::chrono::milliseconds timeout) noexcept;
363
364 /**
365 * @brief Flushes all pending log messages.
366 * @details Waits up to 500ms for all queued messages to be written. Uses a timeout to prevent indefinite
367 * blocking.
368 */
369 void flush() noexcept;
370
371 /**
372 * @brief Stops the writer thread and drains remaining queued messages.
373 * @details Sets m_shutdown_requested, joins the writer thread, then drains any messages that arrived between
374 * the stop signal and thread exit.
375 * @note A producer that already passed the m_shutdown_requested check but has not yet completed try_push() can
376 * enqueue at most one message after the final drain. This is an accepted trade-off to avoid adding atomic
377 * overhead (producers_in_flight counter) to every enqueue() call.
378 */
379 void shutdown() noexcept;
380
381 [[nodiscard]] bool is_running() const noexcept;
382
383 /**
384 * @brief Reports whether the writer thread is currently parked on the flush condition variable.
385 * @details Observability accessor for the idle-park state set by the writer immediately before it
386 * blocks in wait_for and cleared when it wakes. Lets a test or diagnostic confirm the
387 * writer has reached the parked path deterministically instead of relying on a fixed
388 * sleep. The flag can flip at any time, so treat the result as a point-in-time snapshot.
389 */
390 [[nodiscard]] bool is_writer_waiting() const noexcept;
391
392 [[nodiscard]] size_t queue_size() const noexcept;
393
394 /**
395 * @brief Returns the total number of messages dropped due to queue overflow.
396 * @return size_t Number of dropped messages.
397 */
398 [[nodiscard]] size_t dropped_count() const noexcept;
399
400 /**
401 * @brief Resets the dropped message counter.
402 */
403 void reset_dropped_count() noexcept;
404
405 private:
406 void writer_thread_func() noexcept;
407
408 /**
409 * @brief Drains any messages remaining in the queue after the writer thread exits.
410 * @details Called during shutdown to flush late-enqueued messages that arrived between m_running=false and the
411 * writer thread observing an empty queue. No external lock is required; the writer thread has already
412 * been joined.
413 */
414 void drain_remaining() noexcept;
415
416 void write_batch(std::span<LogMessage> messages) noexcept;
417
418 bool handle_overflow(LogMessage &&message) noexcept;
419
420 /**
421 * @brief Wakes the writer thread if it is parked on m_flush_cv after a successful push.
422 * @details A successful producer has already made m_pending_messages non-zero before publishing
423 * a new queue slot, or preserved it while replacing an old slot. The writer publishes
424 * m_writer_waiting before checking that same pending count and blocking. Those seq_cst
425 * operations form a store/load handshake: if the writer misses the pending state, the
426 * producer observes m_writer_waiting and notifies under m_flush_mutex; if the producer
427 * observes false, the writer's pending-count predicate sees the work and does not block.
428 * notify_all (not notify_one) is used so a flusher waiting on the same condition variable
429 * cannot absorb the notification and leave the writer asleep. When the writer is
430 * draining, this is a seq_cst flag load with no mutex or syscall.
431 */
432 void notify_writer() noexcept;
433
434 DynamicMPMCQueue m_queue;
435 AsyncLoggerConfig m_config;
436
437 std::shared_ptr<WinFileStream> m_file_stream;
438 std::shared_ptr<std::mutex> m_log_mutex;
439
440 std::jthread m_writer_thread;
441 std::atomic<bool> m_running{false};
442 std::atomic<bool> m_shutdown_requested{false};
443
444 std::mutex m_flush_mutex;
445 std::condition_variable m_flush_cv;
446
447 // Set true by the writer immediately before it parks on m_flush_cv and cleared when it wakes.
448 // Producers read it outside m_flush_mutex after a successful queue push; the seq_cst order shared
449 // with m_pending_messages makes a racing push either visible to the writer's wait predicate or
450 // visible here as a parked-writer wake.
451 std::atomic<bool> m_writer_waiting{false};
452
453 std::atomic<size_t> m_pending_messages{0};
454 std::atomic<size_t> m_dropped_messages{0};
455 };
456
457 } // namespace DetourModKit
458
459 #endif // DETOURMODKIT_ASYNC_LOGGER_HPP
460