GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 88.1% 364 / 0 / 413
Functions: 97.5% 39 / 0 / 40
Branches: 70.3% 161 / 0 / 229

src/async_logger.cpp
Line Branch Exec Source
1 #include "DetourModKit/async_logger.hpp"
2 #include "DetourModKit/diagnostics.hpp"
3 #include "platform.hpp"
4
5 #include <algorithm>
6 #include <cstring>
7 #include <iomanip>
8 #include <iostream>
9 #include <new>
10 #include <type_traits>
11
12 namespace DetourModKit
13 {
14 using detail::is_loader_lock_held;
15 using detail::pin_current_module;
16
17 18 StringPool::StringPool() noexcept
18 {
19 18 std::lock_guard<std::mutex> lock(m_pool_mutex);
20 18 grow_pool_locked();
21 18 }
22
23 StringPool::~StringPool() noexcept
24 {
25 size_t leaked = 0;
26
27 {
28 // Acquire the mutex to synchronize with any in-flight deallocate() calls
29 std::lock_guard<std::mutex> lock(m_pool_mutex);
30 leaked = m_heap_fallback_count.load(std::memory_order_relaxed);
31 }
32
33 if (leaked > 0)
34 {
35 std::cerr << "[StringPool] " << leaked << " heap-fallback string(s) were not returned before destruction\n";
36 }
37
38 Block *current = m_head.load(std::memory_order_relaxed);
39 while (current)
40 {
41 Block *next = current->next;
42
43 PoolSlot *slots = reinterpret_cast<PoolSlot *>(current->data);
44 for (size_t i = 0; i < POOL_SLOTS_PER_BLOCK; ++i)
45 {
46 if (current->constructed_mask & (1u << i))
47 {
48 slots[i].~PoolSlot();
49 }
50 }
51
52 // Block is over-aligned (alignas(64)); it must be released through the aligned operator delete that matches
53 // its aligned allocation in grow_pool_locked().
54 ::operator delete(current, std::align_val_t{alignof(Block)});
55 current = next;
56 }
57 m_head.store(nullptr, std::memory_order_relaxed);
58 }
59
60 23 void StringPool::grow_pool_locked() noexcept
61 {
62 23 Block *existing = m_head.load(std::memory_order_relaxed);
63 23 size_t count = 0;
64
2/2
✓ Branch 7 → 4 taken 11 times.
✓ Branch 7 → 8 taken 23 times.
34 for (Block *b = existing; b; b = b->next)
65 {
66
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 11 times.
11 if (++count >= MEMORY_POOL_BLOCK_COUNT)
67 {
68 return;
69 }
70 }
71
72 // Block is over-aligned via its alignas(64) data member, so it must be allocated through the aligned operator
73 // new; the plain overload is not required to honour an alignment stricter than
74 // __STDCPP_DEFAULT_NEW_ALIGNMENT__ (typically 16 on x64), which would be alignment UB. The allocation is also
75 // nothrow: this runs underneath the noexcept logging path, so on out-of-memory it must leave the pool unchanged
76 // and let the caller fall back to a nothrow heap string (or drop the message) rather than let std::bad_alloc
77 // escape and terminate.
78 23 void *raw = ::operator new(sizeof(Block), std::align_val_t{alignof(Block)}, std::nothrow);
79
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 23 times.
23 if (!raw)
80 {
81 return;
82 }
83
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 23 times.
23 Block *new_block = new (raw) Block();
84
85 23 new_block->next = existing;
86 23 new_block->free_list = nullptr;
87
88 23 PoolSlot *slots = reinterpret_cast<PoolSlot *>(new_block->data);
89 static_assert(POOL_SLOTS_PER_BLOCK <= 32,
90 "constructed_mask is uint32_t; increase its width if POOL_SLOTS_PER_BLOCK > 32");
91 // Slot construction must not throw, otherwise a partially built block could leak with no unwinding under this
92 // noexcept function. std::string's default constructor is noexcept, so the loop below is provably no-throw.
93 static_assert(std::is_nothrow_default_constructible_v<PoolSlot>,
94 "PoolSlot must be nothrow-default-constructible so grow_pool_locked stays no-throw");
95 23 uint32_t constructed = 0;
96
2/2
✓ Branch 24 → 16 taken 368 times.
✓ Branch 24 → 25 taken 23 times.
391 for (size_t i = 0; i < POOL_SLOTS_PER_BLOCK; ++i)
97 {
98
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 368 times.
368 new (&slots[i]) PoolSlot();
99 368 constructed |= (1u << i);
100
2/2
✓ Branch 20 → 21 taken 345 times.
✓ Branch 20 → 22 taken 23 times.
368 slots[i].next_free = (i + 1 < POOL_SLOTS_PER_BLOCK) ? &slots[i + 1] : nullptr;
101 }
102 23 new_block->constructed_mask = constructed;
103 23 new_block->free_list = &slots[0];
104
105 23 m_head.store(new_block, std::memory_order_release);
106 }
107
108 29 StringPool &StringPool::instance() noexcept
109 {
110 // Constructed once into function-local static storage and never destroyed. A Meyers singleton would be
111 // destroyed at static teardown and race late LogMessage destructors that call into deallocate() (use-after-free
112 // under DLL unload and loader-lock teardown). A heap-allocated singleton (`*new StringPool()`) would instead
113 // require a throwing operator new whose std::bad_alloc would escape this noexcept accessor and terminate the
114 // host. Placement-new into static storage avoids both: the object lives for the whole process, its destructor
115 // never runs, and construction performs no throwing allocation because grow_pool_locked() is nothrow. The
116 // bounded block leak (at most MEMORY_POOL_BLOCK_COUNT blocks of
117 // MEMORY_POOL_BLOCK_SIZE bytes) is released by the OS at process exit.
118 alignas(StringPool) static unsigned char storage[sizeof(StringPool)];
119
4/6
✓ Branch 2 → 3 taken 18 times.
✓ Branch 2 → 10 taken 11 times.
✓ Branch 4 → 5 taken 18 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 18 times.
29 static StringPool *const pool = ::new (static_cast<void *>(storage)) StringPool();
120 29 return *pool;
121 }
122
123 513 StringPool::PoolSlot *StringPool::claim_free_slot() noexcept
124 {
125
2/2
✓ Branch 6 → 3 taken 519 times.
✓ Branch 6 → 7 taken 5 times.
524 for (Block *b = m_head.load(std::memory_order_relaxed); b; b = b->next)
126 {
127
2/2
✓ Branch 3 → 4 taken 508 times.
✓ Branch 3 → 5 taken 11 times.
519 if (b->free_list)
128 {
129 508 PoolSlot *slot = b->free_list;
130 508 b->free_list = slot->next_free;
131 508 return slot;
132 }
133 }
134 5 return nullptr;
135 }
136
137 512 std::string *StringPool::allocate(size_t size) noexcept
138 {
139
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 16 taken 508 times.
512 if (size > MEMORY_POOL_BLOCK_SIZE - sizeof(PoolSlot) - 16)
140 {
141
3/6
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 7 not taken.
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 11 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 4 times.
4 auto *ptr = new (std::nothrow) std::string();
142
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 15 not taken.
4 if (ptr)
143 {
144 4 m_heap_fallback_count.fetch_add(1, std::memory_order_relaxed);
145 }
146 4 return ptr;
147 }
148
149 508 std::lock_guard<std::mutex> lock(m_pool_mutex);
150
151 508 PoolSlot *slot = claim_free_slot();
152
2/2
✓ Branch 18 → 19 taken 5 times.
✓ Branch 18 → 21 taken 503 times.
508 if (!slot)
153 {
154 5 grow_pool_locked();
155 5 slot = claim_free_slot();
156 }
157
158
1/2
✓ Branch 21 → 22 taken 508 times.
✗ Branch 21 → 24 not taken.
508 if (slot)
159 {
160 508 slot->str.clear();
161 508 return &slot->str;
162 }
163
164 auto *ptr = new (std::nothrow) std::string();
165 if (ptr)
166 {
167 m_heap_fallback_count.fetch_add(1, std::memory_order_relaxed);
168 }
169 return ptr;
170 508 }
171
172 509 void StringPool::deallocate(std::string *ptr) noexcept
173 {
174
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 509 times.
509 if (!ptr)
175 508 return;
176
177 509 std::lock_guard<std::mutex> lock(m_pool_mutex);
178
179
2/2
✓ Branch 12 → 6 taken 688 times.
✓ Branch 12 → 13 taken 4 times.
692 for (Block *b = m_head.load(std::memory_order_relaxed); b; b = b->next)
180 {
181 688 const auto *block_begin = reinterpret_cast<const char *>(b->data);
182 688 const auto *block_end = block_begin + POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot);
183 688 const auto *raw_ptr = reinterpret_cast<const char *>(ptr);
184
185
4/4
✓ Branch 6 → 7 taken 509 times.
✓ Branch 6 → 11 taken 179 times.
✓ Branch 7 → 8 taken 508 times.
✓ Branch 7 → 11 taken 1 time.
688 if (raw_ptr >= block_begin && raw_ptr < block_end)
186 {
187 508 auto offset = static_cast<size_t>(raw_ptr - block_begin);
188 508 PoolSlot *slot = reinterpret_cast<PoolSlot *>(b->data) + (offset / sizeof(PoolSlot));
189 508 slot->str.clear();
190 508 return_slot_locked(slot, b);
191 508 return;
192 }
193 }
194
195 // Not a pool allocation -- heap fallback. The delete is performed under m_pool_mutex to serialize with
196 // concurrent deallocate() calls that walk the block list above. Without the lock, a concurrent deallocate could
197 // see a partially updated free list. The lock does
198 // not prevent double-free of heap pointers (those are not tracked);
199 // callers must ensure each pointer is deallocated exactly once. The cost is a single free() call (or no-op for
200 // SSO-sized strings).
201
1/2
✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 16 not taken.
4 delete ptr;
202
1/2
✓ Branch 23 → 24 taken 4 times.
✗ Branch 23 → 27 not taken.
8 if (m_heap_fallback_count.load(std::memory_order_relaxed) > 0)
203 {
204 4 m_heap_fallback_count.fetch_sub(1, std::memory_order_relaxed);
205 }
206
2/2
✓ Branch 29 → 30 taken 4 times.
✓ Branch 29 → 32 taken 508 times.
512 }
207
208 508 void StringPool::return_slot_locked(PoolSlot *slot, Block *block) noexcept
209 {
210 508 slot->next_free = block->free_list;
211 508 block->free_list = slot;
212 508 }
213
214 6243 LogMessage::LogMessage(LogLevel lvl, std::string_view msg) noexcept
215 6243 : level(lvl), timestamp(std::chrono::system_clock::now()), thread_id(std::this_thread::get_id())
216 {
217 6269 const size_t msg_size = std::min(msg.size(), MAX_VALID_LENGTH);
218
219
2/2
✓ Branch 6 → 7 taken 6215 times.
✓ Branch 6 → 11 taken 11 times.
6226 if (msg_size <= MAX_INLINE_SIZE)
220 {
221 6215 std::memcpy(buffer.data(), msg.data(), msg_size);
222 6210 length = msg_size;
223 }
224 else
225 {
226 11 overflow = StringPool::instance().allocate(msg_size);
227
1/2
✓ Branch 13 → 14 taken 11 times.
✗ Branch 13 → 18 not taken.
11 if (overflow)
228 {
229 try
230 {
231
1/2
✓ Branch 15 → 16 taken 11 times.
✗ Branch 15 → 20 not taken.
11 overflow->assign(msg.data(), msg_size);
232 11 length = overflow->size();
233 }
234 catch (...)
235 {
236 StringPool::instance().deallocate(overflow);
237 overflow = nullptr;
238 length = 0;
239 }
240 }
241 else
242 {
243 // Allocation failed (OOM) -- message is silently dropped
244 length = 0;
245 }
246 }
247 6221 }
248
249 434607 LogMessage::~LogMessage() noexcept
250 {
251 434607 reset();
252 434602 }
253
254 // Move transfers ownership of the overflow pointer without touching the
255 // StringPool or m_heap_fallback_count. The allocation/deallocation balance is maintained because exactly one
256 // LogMessage owns the pointer at any time, and only reset() (called by the eventual owner's destructor) returns it
257 // to the pool.
258 2471 LogMessage::LogMessage(LogMessage &&other) noexcept
259 2471 : level(other.level), timestamp(other.timestamp), thread_id(other.thread_id), length(other.length),
260 2471 overflow(other.overflow)
261 {
262
4/4
✓ Branch 2 → 3 taken 2469 times.
✓ Branch 2 → 9 taken 2 times.
✓ Branch 3 → 4 taken 2466 times.
✓ Branch 3 → 9 taken 3 times.
2471 if (length > 0 && !overflow)
263 {
264 7398 std::memcpy(buffer.data(), other.buffer.data(), length);
265 }
266 2471 other.overflow = nullptr;
267 2471 other.length = 0;
268 2471 }
269
270 9518 LogMessage &LogMessage::operator=(LogMessage &&other) noexcept
271 {
272
1/2
✓ Branch 2 → 3 taken 9520 times.
✗ Branch 2 → 12 not taken.
9518 if (this != &other)
273 {
274 9520 reset();
275 9509 level = other.level;
276 9509 timestamp = other.timestamp;
277 9509 thread_id = other.thread_id;
278 9509 length = other.length;
279 9509 overflow = other.overflow;
280
3/4
✓ Branch 4 → 5 taken 9515 times.
✗ Branch 4 → 11 not taken.
✓ Branch 5 → 6 taken 9504 times.
✓ Branch 5 → 11 taken 11 times.
9509 if (length > 0 && !overflow)
281 {
282 28512 std::memcpy(buffer.data(), other.buffer.data(), length);
283 }
284 9509 other.overflow = nullptr;
285 9509 other.length = 0;
286 }
287 9507 return *this;
288 }
289
290 2681 std::string_view LogMessage::message() const noexcept
291 {
292
2/2
✓ Branch 2 → 3 taken 9 times.
✓ Branch 2 → 4 taken 2672 times.
2681 if (overflow)
293 {
294 9 return *overflow;
295 }
296 2672 return std::string_view(buffer.data(), length);
297 }
298
299 20 bool LogMessage::is_valid() const noexcept
300 {
301
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 5 taken 14 times.
20 if (overflow)
302 {
303 6 return length == overflow->size();
304 }
305 14 return length <= MAX_INLINE_SIZE;
306 }
307
308 444050 void LogMessage::reset() noexcept
309 {
310
2/2
✓ Branch 2 → 3 taken 11 times.
✓ Branch 2 → 6 taken 444039 times.
444050 if (overflow)
311 {
312 11 StringPool::instance().deallocate(overflow);
313 11 overflow = nullptr;
314 }
315 444050 length = 0;
316 444050 }
317
318 91 size_t DynamicMPMCQueue::validated_capacity(size_t capacity)
319 {
320
4/4
✓ Branch 2 → 3 taken 85 times.
✓ Branch 2 → 4 taken 6 times.
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 7 taken 82 times.
91 if ((capacity & (capacity - 1)) != 0 || capacity < 2)
321 {
322
1/2
✓ Branch 5 → 6 taken 9 times.
✗ Branch 5 → 9 not taken.
9 throw std::invalid_argument("DynamicMPMCQueue capacity must be a power of 2 and at least 2");
323 }
324 82 return capacity;
325 }
326
327 91 DynamicMPMCQueue::DynamicMPMCQueue(size_t capacity)
328 91 : m_capacity(validated_capacity(capacity)), m_mask(m_capacity - 1),
329 82 m_buffer(std::make_unique<Slot[]>(m_capacity))
330 {
331
2/2
✓ Branch 17 → 7 taken 412578 times.
✓ Branch 17 → 18 taken 82 times.
412660 for (size_t i = 0; i < m_capacity; ++i)
332 {
333 412578 m_buffer[i].sequence.store(i, std::memory_order_relaxed);
334 }
335 82 }
336
337 8257 bool DynamicMPMCQueue::try_push(LogMessage &item)
338 {
339 16505 size_t pos = m_enqueue_pos.load(std::memory_order_relaxed);
340
341 for (;;)
342 {
343 8891 Slot &slot = m_buffer[pos & m_mask];
344 8924 size_t seq = slot.sequence.load(std::memory_order_acquire);
345 8936 intptr_t diff = static_cast<intptr_t>(seq) - static_cast<intptr_t>(pos);
346
347
2/2
✓ Branch 18 → 19 taken 5293 times.
✓ Branch 18 → 40 taken 3643 times.
8936 if (diff == 0)
348 {
349
2/2
✓ Branch 27 → 28 taken 4778 times.
✓ Branch 27 → 50 taken 597 times.
10668 if (m_enqueue_pos.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed))
350 {
351 4778 slot.data = std::move(item);
352 4776 slot.sequence.store(pos + 1, std::memory_order_release);
353 4774 return true;
354 }
355 }
356
2/2
✓ Branch 40 → 41 taken 3600 times.
✓ Branch 40 → 42 taken 43 times.
3643 else if (diff < 0)
357 {
358 3600 return false;
359 }
360 else
361 {
362 89 pos = m_enqueue_pos.load(std::memory_order_relaxed);
363 }
364 643 }
365 }
366
367 17877 bool DynamicMPMCQueue::try_pop(LogMessage &item)
368 {
369 35753 size_t pos = m_dequeue_pos.load(std::memory_order_relaxed);
370
371 for (;;)
372 {
373 19062 Slot &slot = m_buffer[pos & m_mask];
374 18773 size_t seq = slot.sequence.load(std::memory_order_acquire);
375 18768 intptr_t diff = static_cast<intptr_t>(seq) - static_cast<intptr_t>(pos + 1);
376
377
2/2
✓ Branch 18 → 19 taken 5566 times.
✓ Branch 18 → 40 taken 13202 times.
18768 if (diff == 0)
378 {
379
2/2
✓ Branch 27 → 28 taken 4773 times.
✓ Branch 27 → 50 taken 1072 times.
11411 if (m_dequeue_pos.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed))
380 {
381 9546 item = std::move(slot.data);
382 4773 slot.sequence.store(pos + m_capacity, std::memory_order_release);
383 4772 return true;
384 }
385 }
386
2/2
✓ Branch 40 → 41 taken 13100 times.
✓ Branch 40 → 42 taken 102 times.
13202 else if (diff < 0)
387 {
388 13100 return false;
389 }
390 else
391 {
392 216 pos = m_dequeue_pos.load(std::memory_order_relaxed);
393 }
394 1186 }
395 }
396
397 12869 size_t DynamicMPMCQueue::try_pop_batch(std::vector<LogMessage> &items, size_t max_count)
398 {
399
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 12868 times.
12869 if (max_count == 0)
400 {
401 1 return 0;
402 }
403
404
1/2
✓ Branch 5 → 6 taken 12868 times.
✗ Branch 5 → 23 not taken.
12868 items.reserve(items.size() + max_count);
405
406 12868 size_t count = 0;
407 12868 LogMessage msg;
408
409
7/8
✓ Branch 12 → 13 taken 15125 times.
✓ Branch 12 → 16 taken 206 times.
✓ Branch 13 → 14 taken 15125 times.
✗ Branch 13 → 21 not taken.
✓ Branch 14 → 15 taken 2463 times.
✓ Branch 14 → 16 taken 12662 times.
✓ Branch 17 → 8 taken 2463 times.
✓ Branch 17 → 18 taken 12868 times.
15331 while (count < max_count && try_pop(msg))
410 {
411
1/2
✓ Branch 10 → 11 taken 2463 times.
✗ Branch 10 → 21 not taken.
2463 items.push_back(std::move(msg));
412 2463 ++count;
413 }
414
415 12868 return count;
416 12868 }
417
418 98902 size_t DynamicMPMCQueue::size() const noexcept
419 {
420 98902 size_t enq = m_enqueue_pos.load(std::memory_order_relaxed);
421 98902 size_t deq = m_dequeue_pos.load(std::memory_order_relaxed);
422
1/2
✓ Branch 16 → 17 taken 98902 times.
✗ Branch 16 → 18 not taken.
98902 return (enq >= deq) ? (enq - deq) : 0;
423 }
424
425 98894 bool DynamicMPMCQueue::empty() const noexcept
426 {
427 98894 return size() == 0;
428 }
429
430 72 AsyncLogger::AsyncLogger(const AsyncLoggerConfig &config, std::shared_ptr<WinFileStream> file_stream,
431 72 std::shared_ptr<std::mutex> log_mutex)
432
1/2
✓ Branch 3 → 4 taken 69 times.
✗ Branch 3 → 59 not taken.
141 : m_queue(config.queue_capacity), m_config(config), m_file_stream(std::move(file_stream)),
433 138 m_log_mutex(std::move(log_mutex))
434 {
435
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 23 taken 69 times.
69 if (!m_config.validate())
436 {
437 throw std::invalid_argument("Invalid AsyncLoggerConfig");
438 }
439
440
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 28 taken 68 times.
69 if (!m_file_stream)
441 {
442
1/2
✓ Branch 26 → 27 taken 1 time.
✗ Branch 26 → 40 not taken.
1 throw std::invalid_argument("file_stream cannot be null");
443 }
444
445
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 33 taken 67 times.
68 if (!m_log_mutex)
446 {
447
1/2
✓ Branch 31 → 32 taken 1 time.
✗ Branch 31 → 42 not taken.
1 throw std::invalid_argument("log_mutex cannot be null");
448 }
449
450 67 m_running.store(true, std::memory_order_release);
451
1/2
✓ Branch 34 → 35 taken 67 times.
✗ Branch 34 → 44 not taken.
67 m_writer_thread = std::jthread(&AsyncLogger::writer_thread_func, this);
452 81 }
453
454 67 AsyncLogger::~AsyncLogger() noexcept
455 {
456 67 shutdown();
457 67 }
458
459 4276 bool AsyncLogger::enqueue(LogLevel level, std::string_view message) noexcept
460 {
461
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 57 taken 4278 times.
4276 if (m_shutdown_requested.load(std::memory_order_acquire))
462 {
463 4 std::lock_guard<std::mutex> lock(*m_log_mutex);
464
3/6
✓ Branch 8 → 9 taken 4 times.
✗ Branch 8 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 4 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 4 times.
4 if (!m_file_stream->is_open() || !m_file_stream->good())
465 {
466 // Stream already closed or failed during teardown: the message cannot be delivered, so report the drop
467 // rather than a false success.
468 return false;
469 }
470
471 4 const auto now = std::chrono::system_clock::now();
472 4 const auto time_t = std::chrono::system_clock::to_time_t(now);
473 4 std::tm tm_buf{};
474
475 #if defined(_WIN32) || defined(_MSC_VER)
476 4 localtime_s(&tm_buf, &time_t);
477 #else
478 localtime_r(&time_t, &tm_buf);
479 #endif
480
481 4 const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
482 4 *m_file_stream << "[" << std::put_time(&tm_buf, m_config.timestamp_format.c_str()) << "."
483 4 << std::setfill('0') << std::setw(3) << ms.count() << std::setfill(' ') << "] "
484 4 << "[" << std::setw(7) << std::left << log_level_to_string(level) << "] :: " << message
485 4 << '\n';
486 4 m_file_stream->flush();
487
488 // Surface a write/flush failure through the no-throw delivery bool.
489 4 return m_file_stream->good();
490 4 }
491
492 4278 LogMessage msg(level, message);
493
494 // Increment before push so flush cannot observe zero while a message is already in the queue but not yet
495 // counted.
496 4202 m_pending_messages.fetch_add(1, std::memory_order_seq_cst);
497
2/2
✓ Branch 61 → 62 taken 2425 times.
✓ Branch 61 → 64 taken 1889 times.
4202 if (m_queue.try_push(msg))
498 {
499 2425 notify_writer();
500 2444 return true;
501 }
502 // Push failed -- undo the pre-increment before entering overflow handling
503 1889 m_pending_messages.fetch_sub(1, std::memory_order_seq_cst);
504 1889 return handle_overflow(std::move(msg));
505 4333 }
506
507 146 bool AsyncLogger::flush_with_timeout(std::chrono::milliseconds timeout) noexcept
508 {
509
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 143 times.
146 if (!m_running.load(std::memory_order_acquire))
510 {
511 3 return true;
512 }
513
514 143 std::unique_lock<std::mutex> lock(m_flush_mutex);
515
516 143 const bool flushed = m_flush_cv.wait_for(lock, timeout, [this]() noexcept
517 642 { return m_pending_messages.load(std::memory_order_acquire) == 0; });
518
519 143 return flushed;
520 143 }
521
522 12 void AsyncLogger::flush() noexcept
523 {
524 12 (void)flush_with_timeout(DEFAULT_FLUSH_TIMEOUT);
525 12 }
526
527 133 void AsyncLogger::shutdown() noexcept
528 {
529 133 bool expected = false;
530
2/2
✓ Branch 3 → 4 taken 66 times.
✓ Branch 3 → 5 taken 67 times.
133 if (!m_shutdown_requested.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
531 {
532 66 return;
533 }
534
535 67 m_running.store(false, std::memory_order_release);
536
537 // Wake the writer if it is parked. Taking m_flush_mutex serializes this notify with the writer's
538 // atomic unlock-and-block inside wait_for, so a parked writer observes m_running == false promptly
539 // instead of waiting out the flush interval before it can exit and be joined below.
540 {
541 67 std::lock_guard<std::mutex> lock(m_flush_mutex);
542 67 m_flush_cv.notify_all();
543 67 }
544
545
1/2
✓ Branch 10 → 11 taken 67 times.
✗ Branch 10 → 17 not taken.
67 if (m_writer_thread.joinable())
546 {
547
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 16 taken 67 times.
67 if (is_loader_lock_held())
548 {
549 pin_current_module();
550 m_writer_thread.detach();
551 DetourModKit::Diagnostics::record_intentional_leak(
552 DetourModKit::Diagnostics::LeakSubsystem::AsyncLogger);
553 }
554 else
555 {
556 67 m_writer_thread.join();
557 }
558 }
559
560 // Drain any messages enqueued between m_running=false and the writer thread exiting. Without this,
561 // late-arriving messages would be silently lost and the force-zero below would mask the discrepancy.
562 //
563 // A narrow race remains: a producer that already passed the m_shutdown_requested check in enqueue() but has not
564 // yet called try_push() can enqueue one message after this drain completes. This is an accepted trade-off --
565 // closing it would require a producers_in_flight atomic counter on every enqueue() call, adding two atomic RMW
566 // operations to the hot path. At most one message per producer thread can be lost, and only during the
567 // nanosecond window between the drain and the force-zero below.
568 67 drain_remaining();
569
570 {
571 67 std::lock_guard<std::mutex> lock(m_flush_mutex);
572 67 m_pending_messages.store(0, std::memory_order_release);
573 67 m_flush_cv.notify_all();
574 67 }
575 }
576
577 7 bool AsyncLogger::is_running() const noexcept
578 {
579 7 return m_running.load(std::memory_order_acquire);
580 }
581
582 3 bool AsyncLogger::is_writer_waiting() const noexcept
583 {
584 3 return m_writer_waiting.load(std::memory_order_acquire);
585 }
586
587 2 size_t AsyncLogger::queue_size() const noexcept
588 {
589 2 return m_queue.size();
590 }
591
592 7 size_t AsyncLogger::dropped_count() const noexcept
593 {
594 14 return m_dropped_messages.load(std::memory_order_relaxed);
595 }
596
597 1 void AsyncLogger::reset_dropped_count() noexcept
598 {
599 1 m_dropped_messages.store(0, std::memory_order_release);
600 1 }
601
602 2750 void AsyncLogger::notify_writer() noexcept
603 {
604 // The caller has already incremented m_pending_messages and published the queue slot. In the
605 // seq_cst order, either the writer's pending-count predicate sees that increment before it parks,
606 // or this load sees the writer's waiting flag and wakes it. That closes the lost-wakeup window
607 // without taking m_flush_mutex while the writer is actively draining.
608
2/2
✓ Branch 3 → 4 taken 1174 times.
✓ Branch 3 → 8 taken 1574 times.
2750 if (m_writer_waiting.load(std::memory_order_seq_cst))
609 {
610 // Notify under m_flush_mutex so the wake cannot be lost against the writer's atomic
611 // unlock-and-block, and notify_all (not notify_one) so a flusher blocked on the same condition
612 // variable cannot absorb the single notification and leave the writer asleep.
613 1174 std::lock_guard<std::mutex> lock(m_flush_mutex);
614 1179 m_flush_cv.notify_all();
615 1179 }
616 2753 }
617
618 67 void AsyncLogger::writer_thread_func() noexcept
619 {
620 // Per-idle-cycle cap on the cooperative yields the writer spins through when the pending count
621 // shows an in-flight push whose queue slot has not landed yet. Small and fixed so a producer
622 // preempted mid-publish cannot turn the idle path into a tight hot loop.
623 67 constexpr size_t INFLIGHT_SPIN_LIMIT = 8;
624
625 67 std::vector<LogMessage> batch;
626 67 batch.reserve(m_config.batch_size);
627
628 67 auto last_flush = std::chrono::steady_clock::now();
629
630
6/6
✓ Branch 59 → 60 taken 100 times.
✓ Branch 59 → 62 taken 12764 times.
✓ Branch 61 → 62 taken 33 times.
✓ Branch 61 → 63 taken 67 times.
✓ Branch 64 → 5 taken 12797 times.
✓ Branch 64 → 65 taken 67 times.
12864 while (m_running.load(std::memory_order_acquire) || !m_queue.empty())
631 {
632 12797 batch.clear();
633 // The popped count is not needed here; the batch.empty() check below decides between the write path and the
634 // idle-flush path.
635 12797 (void)m_queue.try_pop_batch(batch, m_config.batch_size);
636
637
2/2
✓ Branch 8 → 9 taken 265 times.
✓ Branch 8 → 18 taken 12532 times.
12797 if (!batch.empty())
638 {
639 265 write_batch(batch);
640 265 const size_t batch_size = batch.size();
641 {
642 265 std::lock_guard<std::mutex> flock(m_flush_mutex);
643 265 m_pending_messages.fetch_sub(batch_size, std::memory_order_acq_rel);
644 265 }
645 265 m_flush_cv.notify_all();
646 265 last_flush = std::chrono::steady_clock::now();
647 }
648 else
649 {
650 // A producer bumps m_pending_messages before it publishes its queue slot, so a non-zero
651 // pending count with an empty pop means a push is in flight. Spin a small, fixed number of
652 // cooperative yields to let that push land; the common in-flight window is a few
653 // instructions, so the producer usually publishes here and the next pop drains it. The cap
654 // bounds the spin: if the producer is preempted past the cap, the loop falls through to the
655 // wait_for below, but with pending still non-zero the predicate is already satisfied, so it
656 // returns without blocking and the next cycle spins again -- bounded each pass rather than a
657 // tight hot loop. The writer only truly blocks once pending reaches zero (the genuinely idle
658 // case), where notify_writer() or the flush-interval timeout wakes it.
659
1/2
✓ Branch 23 → 24 taken 98968 times.
✗ Branch 23 → 35 not taken.
210284 for (size_t spin = 0; spin < INFLIGHT_SPIN_LIMIT && m_running.load(std::memory_order_acquire) &&
660
7/8
✓ Branch 21 → 22 taken 98968 times.
✓ Branch 21 → 35 taken 12348 times.
✓ Branch 31 → 32 taken 98784 times.
✓ Branch 31 → 35 taken 184 times.
✓ Branch 33 → 34 taken 98784 times.
✗ Branch 33 → 35 not taken.
✓ Branch 36 → 19 taken 98784 times.
✓ Branch 36 → 37 taken 12532 times.
309252 m_pending_messages.load(std::memory_order_seq_cst) != 0 && m_queue.empty();
661 ++spin)
662 {
663 98784 std::this_thread::yield();
664 }
665
666 12532 auto now = std::chrono::steady_clock::now();
667
2/2
✓ Branch 41 → 42 taken 10 times.
✓ Branch 41 → 51 taken 12522 times.
12532 if (now - last_flush >= m_config.flush_interval)
668 {
669 10 std::lock_guard<std::mutex> lock(*m_log_mutex);
670
1/2
✓ Branch 46 → 47 taken 10 times.
✗ Branch 46 → 49 not taken.
10 if (m_file_stream->is_open())
671 {
672 10 m_file_stream->flush();
673 }
674 10 last_flush = now;
675 10 }
676
677 12532 std::unique_lock<std::mutex> lock(m_flush_mutex);
678
679 // Publish that the writer is about to park, then check the producer-maintained pending
680 // count while m_writer_waiting is still true. In the seq_cst order, a racing producer is
681 // either counted here or observes m_writer_waiting in notify_writer() and signals this
682 // condition variable under m_flush_mutex.
683 12532 m_writer_waiting.store(true, std::memory_order_seq_cst);
684 12532 m_flush_cv.wait_for(lock, m_config.flush_interval,
685 12716 [this]() noexcept
686 {
687
2/2
✓ Branch 9 → 10 taken 234 times.
✓ Branch 9 → 12 taken 12482 times.
25666 return m_pending_messages.load(std::memory_order_seq_cst) != 0 ||
688
2/2
✓ Branch 11 → 12 taken 40 times.
✓ Branch 11 → 13 taken 194 times.
12950 !m_running.load(std::memory_order_acquire);
689 });
690 12532 m_writer_waiting.store(false, std::memory_order_seq_cst);
691 12532 }
692 }
693
694 {
695 67 std::lock_guard<std::mutex> lock(*m_log_mutex);
696
1/2
✓ Branch 69 → 70 taken 67 times.
✗ Branch 69 → 72 not taken.
67 if (m_file_stream->is_open())
697 {
698 67 m_file_stream->flush();
699 }
700 67 }
701
702 {
703 67 std::lock_guard<std::mutex> lock(m_flush_mutex);
704 67 m_pending_messages.store(0, std::memory_order_release);
705 67 m_flush_cv.notify_all();
706 67 }
707 67 }
708
709 67 void AsyncLogger::drain_remaining() noexcept
710 {
711 67 std::vector<LogMessage> remaining;
712 67 remaining.reserve(m_config.batch_size);
713
1/2
✗ Branch 8 → 4 not taken.
✓ Branch 8 → 9 taken 67 times.
67 while (m_queue.try_pop_batch(remaining, m_config.batch_size) > 0)
714 {
715 write_batch(remaining);
716 remaining.clear();
717 }
718 67 }
719
720 265 void AsyncLogger::write_batch(std::span<LogMessage> messages) noexcept
721 {
722 265 std::lock_guard<std::mutex> lock(*m_log_mutex);
723
724
3/6
✓ Branch 6 → 7 taken 265 times.
✗ Branch 6 → 10 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 265 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 265 times.
265 if (!m_file_stream->is_open() || !m_file_stream->good())
725 {
726 return;
727 }
728
729 // Cache the localtime result across consecutive messages that share the same second to avoid repeated CRT lock
730 // acquisition inside localtime_s.
731 265 std::time_t cached_second{-1};
732 265 std::tm cached_tm{};
733
734
2/2
✓ Branch 62 → 16 taken 2448 times.
✓ Branch 62 → 63 taken 265 times.
2978 for (const auto &msg : messages)
735 {
736 2448 const auto time_t = std::chrono::system_clock::to_time_t(msg.timestamp);
737
738
2/2
✓ Branch 23 → 24 taken 265 times.
✓ Branch 23 → 25 taken 2183 times.
2448 if (time_t != cached_second)
739 {
740 265 cached_second = time_t;
741 #if defined(_WIN32) || defined(_MSC_VER)
742 265 localtime_s(&cached_tm, &time_t);
743 #else
744 localtime_r(&time_t, &cached_tm);
745 #endif
746 }
747
748 const auto ms =
749 2448 std::chrono::duration_cast<std::chrono::milliseconds>(msg.timestamp.time_since_epoch()) % 1000;
750
751 2448 *m_file_stream << "[" << std::put_time(&cached_tm, m_config.timestamp_format.c_str()) << "."
752 2448 << std::setfill('0') << std::setw(3) << ms.count() << std::setfill(' ') << "] "
753 2448 << "[" << std::setw(7) << std::left << log_level_to_string(msg.level)
754 2448 << "] :: " << msg.message() << '\n';
755 }
756
757 265 m_file_stream->flush();
758
1/2
✓ Branch 67 → 68 taken 265 times.
✗ Branch 67 → 70 not taken.
265 }
759
760 1882 bool AsyncLogger::handle_overflow(LogMessage &&message) noexcept
761 {
762
4/5
✓ Branch 2 → 3 taken 1378 times.
✓ Branch 2 → 6 taken 306 times.
✓ Branch 2 → 23 taken 3 times.
✓ Branch 2 → 51 taken 195 times.
✗ Branch 2 → 106 not taken.
1882 switch (m_config.overflow_policy)
763 {
764 1378 case OverflowPolicy::DropNewest:
765 1378 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
766 1378 return false;
767
768 306 case OverflowPolicy::DropOldest:
769 {
770 306 LogMessage oldest;
771
1/2
✓ Branch 8 → 9 taken 306 times.
✗ Branch 8 → 18 not taken.
306 if (m_queue.try_pop(oldest))
772 {
773 // Count the evicted oldest message as dropped
774 306 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
775
1/2
✓ Branch 12 → 13 taken 306 times.
✗ Branch 12 → 15 not taken.
306 if (m_queue.try_push(message))
776 {
777 // Net effect on m_pending_messages: pop(-1) + push(+1) = 0
778 306 notify_writer();
779 306 return true;
780 }
781 // Pop succeeded but push failed: net -1
782 m_pending_messages.fetch_sub(1, std::memory_order_seq_cst);
783 }
784 // Count the new message as dropped (separate from the evicted oldest above). m_dropped_messages counts
785 // individual lost messages, not overflow events.
786 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
787 return false;
788 306 }
789
790 3 case OverflowPolicy::Block:
791 {
792 3 const auto deadline = std::chrono::steady_clock::now() + m_config.block_timeout_ms;
793 3 size_t spin_count = 0;
794
795 // Pre-increment so flush sees the in-flight message throughout the retry loop
796 3 m_pending_messages.fetch_add(1, std::memory_order_seq_cst);
797
798
1/2
✓ Branch 44 → 28 taken 1690 times.
✗ Branch 44 → 45 not taken.
1690 while (std::chrono::steady_clock::now() < deadline)
799 {
800
2/2
✓ Branch 29 → 30 taken 3 times.
✓ Branch 29 → 32 taken 1687 times.
1690 if (m_queue.try_push(message))
801 {
802 3 notify_writer();
803 3 return true;
804 }
805
806
2/2
✓ Branch 32 → 33 taken 96 times.
✓ Branch 32 → 34 taken 1591 times.
1687 if (spin_count < m_config.spin_backoff_iterations)
807 {
808 96 ++spin_count;
809 }
810
2/2
✓ Branch 34 → 35 taken 1590 times.
✓ Branch 34 → 37 taken 1 time.
1591 else if (spin_count < m_config.block_max_spin_iterations)
811 {
812 1590 std::this_thread::yield();
813 1590 ++spin_count;
814 }
815 else
816 {
817 1 std::this_thread::sleep_for(std::chrono::milliseconds(1));
818 }
819 }
820 // Timed out -- undo the pre-increment
821 m_pending_messages.fetch_sub(1, std::memory_order_seq_cst);
822 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
823 return false;
824 }
825
826 195 case OverflowPolicy::SyncFallback:
827 {
828 195 std::lock_guard<std::mutex> lock(*m_log_mutex);
829
3/6
✓ Branch 55 → 56 taken 195 times.
✗ Branch 55 → 59 not taken.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 195 times.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 63 taken 195 times.
195 if (!m_file_stream->is_open() || !m_file_stream->good())
830 {
831 return false;
832 }
833
834 195 const auto time_t = std::chrono::system_clock::to_time_t(message.timestamp);
835 195 std::tm tm_buf{};
836
837 #if defined(_WIN32) || defined(_MSC_VER)
838 195 localtime_s(&tm_buf, &time_t);
839 #else
840 localtime_r(&time_t, &tm_buf);
841 #endif
842
843 const auto ms =
844 195 std::chrono::duration_cast<std::chrono::milliseconds>(message.timestamp.time_since_epoch()) % 1000;
845 195 *m_file_stream << "[" << std::put_time(&tm_buf, m_config.timestamp_format.c_str()) << "."
846 195 << std::setfill('0') << std::setw(3) << ms.count() << std::setfill(' ') << "] "
847 195 << "[" << std::setw(7) << std::left << log_level_to_string(message.level)
848 195 << "] :: " << message.message() << '\n';
849 195 m_file_stream->flush();
850
851
1/2
✗ Branch 101 → 102 not taken.
✓ Branch 101 → 103 taken 195 times.
195 if (m_file_stream->fail())
852 {
853 return false;
854 }
855 195 return true;
856 195 }
857
858 default:
859 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
860 return false;
861 }
862 }
863
864 } // namespace DetourModKit
865