GCC Code Coverage Report


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

src/internal/async_logger_queue.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_ASYNC_LOGGER_QUEUE_HPP
2 #define DETOURMODKIT_INTERNAL_ASYNC_LOGGER_QUEUE_HPP
3
4 /**
5 * @file internal/async_logger_queue.hpp
6 * @brief True-private async-logger transport: the overflow string pool, the per-message record, and the MPMC queue.
7 * @details Houses the overflow string pool (StringPool), the per-message transport record (LogMessage), and the
8 * bounded Vyukov MPMC ring buffer (DynamicMPMCQueue), all in namespace DetourModKit::detail. It is never
9 * installed: AsyncLogger holds these behind its pimpl (see src/async_logger.cpp), so no installed header names
10 * them and a consumer compiles without the queue/pool/threading internals on its include path. Only the
11 * AsyncLogger pimpl translation unit and the async-logger white-box tests reach in here.
12 */
13
14 #include "DetourModKit/logger.hpp"
15
16 #include <array>
17 #include <atomic>
18 #include <chrono>
19 #include <cstddef>
20 #include <memory>
21 #include <mutex>
22 #include <string>
23 #include <string_view>
24 #include <vector>
25
26 namespace DetourModKit::detail
27 {
28 /// Maximum length, in bytes, of a single log message.
29 inline constexpr size_t MAX_MESSAGE_SIZE = 16777216;
30 /**
31 * @brief Largest request size the StringPool serves from a slot; a larger request falls back to a
32 * nothrow heap string.
33 * @details A request-size ceiling, not the byte size of a Block: a Block holds POOL_SLOTS_PER_BLOCK slots, so its
34 * data array is POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot) bytes, unrelated to this value.
35 */
36 inline constexpr size_t MAX_POOLED_STRING_SIZE = 4095;
37 /// Number of overflow blocks the StringPool preallocates.
38 inline constexpr size_t MEMORY_POOL_BLOCK_COUNT = 64;
39 /// Number of allocation slots carved from each StringPool block.
40 inline constexpr size_t POOL_SLOTS_PER_BLOCK = 16;
41
42 /**
43 * @class StringPool
44 * @brief Memory pool for small string allocations to reduce heap fragmentation.
45 * @details Uses a free-list approach for O(1) allocation/deallocation. Blocks are allocated on-demand up to
46 * MEMORY_POOL_BLOCK_COUNT. Each block is cache-line aligned to prevent false sharing.
47 *
48 * @note The singleton returned by instance() is intentionally leaked to avoid the static destruction order fiasco
49 * with late LogMessage teardown. Neither request_shutdown() nor the Session teardown reclaim it; the OS
50 * releases the memory at process exit. The leak is bounded to MEMORY_POOL_BLOCK_COUNT blocks, each carrying a
51 * POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot)-byte slot array.
52 */
53 class StringPool
54 {
55 public:
56 static StringPool &instance() noexcept;
57
58 [[nodiscard]] std::string *allocate(size_t size) noexcept;
59 void deallocate(std::string *ptr) noexcept;
60
61 StringPool(const StringPool &) = delete;
62 StringPool &operator=(const StringPool &) = delete;
63 StringPool(StringPool &&) = delete;
64 StringPool &operator=(StringPool &&) = delete;
65
66 private:
67 struct PoolSlot
68 {
69 std::string str;
70 PoolSlot *next_free{nullptr};
71 };
72 #if defined(__GNUC__) || defined(__clang__)
73 #pragma GCC diagnostic push
74 #pragma GCC diagnostic ignored "-Winvalid-offsetof"
75 #endif
76 static_assert(
77 offsetof(PoolSlot, str) == 0,
78 "PoolSlot::str must be the first member for pointer arithmetic in deallocate()"
79 );
80 #if defined(__GNUC__) || defined(__clang__)
81 #pragma GCC diagnostic pop
82 #endif
83
84 struct Block
85 {
86 alignas(64) char data[POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot)];
87 Block *next{nullptr};
88 PoolSlot *free_list{nullptr};
89 };
90
91 StringPool() noexcept;
92 ~StringPool() = delete;
93
94 /**
95 * @brief Appends one block to the pool. Must be called with m_pool_mutex held.
96 * @details No-throw: an allocation failure leaves the pool unchanged so
97 * callers can fall back to a nothrow heap string instead of throwing out of the logging path.
98 */
99 void grow_pool_locked() noexcept;
100 PoolSlot *claim_free_slot() noexcept;
101 void return_slot_locked(PoolSlot *slot, Block *block) noexcept;
102
103 std::atomic<Block *> m_head{nullptr};
104 std::mutex m_pool_mutex;
105 };
106
107 /**
108 * @struct LogMessage
109 * @brief A log entry with inline buffer optimization and overflow handling.
110 * @details Messages <= 512 bytes are stored inline. Larger messages use heap allocation via StringPool. This is a
111 * move-only transport/value type (copy deleted, move defined) carried by value through the queue; it
112 * keeps plain field names rather than the m_ member prefix, per the POD-struct naming convention, even
113 * though @ref overflow is an owned heap pointer (its lifetime is self-contained, released by reset() and
114 * the destructor), so no class invariant rides on member encapsulation.
115 */
116 struct LogMessage
117 {
118 LogLevel level{LogLevel::Info};
119 std::chrono::system_clock::time_point timestamp;
120
121 static constexpr size_t MAX_INLINE_SIZE = LOG_INLINE_MESSAGE_SIZE;
122 static constexpr size_t MAX_VALID_LENGTH = MAX_MESSAGE_SIZE;
123 // Left uninitialized (raw storage): only [0, length) is ever read, and every constructor/move writes exactly
124 // length bytes before any read. Zero-filling the whole inline buffer would memset MAX_INLINE_SIZE bytes on each
125 // construction/enqueue for no observable effect.
126 std::array<char, MAX_INLINE_SIZE> buffer;
127 size_t length{0};
128
129 // Owned: allocated by StringPool, freed by reset().
130 std::string *overflow{nullptr};
131
132 // Set when the constructor could not materialize an over-long message (overflow allocation or assign failed
133 // under OOM). It distinguishes that dropped husk from a legitimately empty (length 0) message: a failed
134 // record reports is_valid() == false so the producer counts a drop instead of enqueuing an empty timestamped
135 // line.
136 bool failed{false};
137
138 LogMessage(LogLevel lvl, std::string_view msg) noexcept;
139 2849882 LogMessage() noexcept = default;
140
141 ~LogMessage() noexcept;
142
143 LogMessage(LogMessage &&other) noexcept;
144 LogMessage &operator=(LogMessage &&other) noexcept;
145
146 LogMessage(const LogMessage &) = delete;
147 LogMessage &operator=(const LogMessage &) = delete;
148
149 [[nodiscard]] std::string_view message() const noexcept;
150 [[nodiscard]] bool is_valid() const noexcept;
151 void reset() noexcept;
152 };
153
154 /**
155 * @class DynamicMPMCQueue
156 * @brief A dynamically-sized, bounded Multi-Producer Multi-Consumer queue.
157 * @details Uses a ring buffer with atomic sequence numbers for lock-free synchronization. Capacity is determined at
158 * construction time.
159 * @note This queue is designed to be constructed once and never resized. Moving slots after construction is not
160 * supported and will cause data corruption.
161 */
162 #ifdef _MSC_VER
163 #pragma warning(push)
164 // structure was padded due to alignment specifier
165 #pragma warning(disable : 4324)
166 #endif
167
168 class DynamicMPMCQueue
169 {
170 public:
171 /**
172 * @brief Constructs a queue with the specified capacity.
173 * @param capacity The maximum number of elements (must be power of 2 and >= 2).
174 */
175 explicit DynamicMPMCQueue(size_t capacity);
176
177 386 ~DynamicMPMCQueue() noexcept = default;
178
179 DynamicMPMCQueue(const DynamicMPMCQueue &) = delete;
180 DynamicMPMCQueue &operator=(const DynamicMPMCQueue &) = delete;
181 DynamicMPMCQueue(DynamicMPMCQueue &&) = delete;
182 DynamicMPMCQueue &operator=(DynamicMPMCQueue &&) = delete;
183
184 /**
185 * @brief Attempts to push an item into the queue.
186 * @param item The item to push. Moved into the queue on success only;
187 * left unchanged on failure so the caller can retry or handle overflow.
188 * @return true if successful, false if queue is full.
189 * @note noexcept: the lock-free path is atomic loads/CAS plus a noexcept LogMessage move, so it never
190 * allocates or throws. try_pop_batch and the writer's noexcept frames depend on this; the keyword makes
191 * the contract explicit so a future throwing change fails to compile rather than silently terminating.
192 */
193 [[nodiscard]] bool try_push(LogMessage &item) noexcept;
194
195 /**
196 * @brief Attempts to pop an item from the queue.
197 * @param item Reference to store the popped item.
198 * @return true if successful, false if queue is empty.
199 * @note noexcept: same non-throwing lock-free contract as try_push (atomic ops plus a noexcept LogMessage
200 * move). try_pop_batch relies on it, so the keyword pins the guarantee at the type level.
201 */
202 [[nodiscard]] bool try_pop(LogMessage &item) noexcept;
203
204 /**
205 * @brief Attempts to pop multiple items up to a maximum count.
206 * @param items Reference to a vector to store popped items.
207 * @param max_count Maximum number of items to pop.
208 * @return size_t Number of items actually popped.
209 * @note noexcept and fail-closed under allocation pressure. It is called from the writer thread's noexcept
210 * frame (writer_thread_func), so a throwing reserve would be an unrecoverable
211 * std::terminate. Instead it reserves headroom under a local try/catch and, if that allocation fails,
212 * pops only as many items as the vector's existing spare capacity allows. The LogMessage move is
213 * noexcept, so push_back within capacity never allocates and never throws. Under OOM a smaller batch
214 * (possibly zero) is returned this call; the un-popped items stay queued for the next call.
215 */
216 [[nodiscard]] size_t try_pop_batch(std::vector<LogMessage> &items, size_t max_count) noexcept;
217
218 /// Returns the approximate number of items in the queue.
219 [[nodiscard]] size_t size() const noexcept;
220
221 /// Checks if the queue is approximately empty.
222 [[nodiscard]] bool empty() const noexcept;
223
224 /**
225 * @brief Returns the capacity of the queue.
226 * @return size_t The maximum number of elements.
227 */
228 1 [[nodiscard]] size_t capacity() const noexcept { return m_capacity; }
229
230 private:
231 struct Slot
232 {
233 std::atomic<size_t> sequence;
234 LogMessage data;
235
236 2847604 Slot() noexcept : sequence(0) {}
237
238 Slot(const Slot &) = delete;
239 Slot &operator=(const Slot &) = delete;
240 Slot(Slot &&) = delete;
241 Slot &operator=(Slot &&) = delete;
242 };
243
244 /**
245 * @brief Validates capacity before member initialization to prevent allocation of an invalid-sized buffer in
246 * the initializer list.
247 */
248 static size_t validated_capacity(size_t capacity);
249
250 // Immutable after construction; never resized.
251 const size_t m_capacity;
252 const size_t m_mask;
253
254 // Allocated once in the constructor; the unique_ptr ensures immutability (no accidental resize) while
255 // maintaining contiguous cache-friendly layout.
256 std::unique_ptr<Slot[]> m_buffer;
257
258 // Cache-line aligned to prevent false sharing between producers and consumers.
259 alignas(64) std::atomic<size_t> m_enqueue_pos{0};
260 alignas(64) std::atomic<size_t> m_dequeue_pos{0};
261 };
262
263 #ifdef _MSC_VER
264 #pragma warning(pop)
265 #endif
266
267 } // namespace DetourModKit::detail
268
269 #endif // DETOURMODKIT_INTERNAL_ASYNC_LOGGER_QUEUE_HPP
270