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 |