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 <cassert> | ||
| 9 | #include <chrono> | ||
| 10 | #include <condition_variable> | ||
| 11 | #include <cstddef> | ||
| 12 | #include <cstdint> | ||
| 13 | #include <memory> | ||
| 14 | #include <mutex> | ||
| 15 | #include <optional> | ||
| 16 | #include <span> | ||
| 17 | #include <string> | ||
| 18 | #include <string_view> | ||
| 19 | #include <thread> | ||
| 20 | #include <vector> | ||
| 21 | |||
| 22 | namespace DetourModKit | ||
| 23 | { | ||
| 24 | inline constexpr size_t DEFAULT_QUEUE_CAPACITY = 8192; | ||
| 25 | inline constexpr size_t DEFAULT_BATCH_SIZE = 64; | ||
| 26 | inline constexpr auto DEFAULT_FLUSH_INTERVAL = std::chrono::milliseconds(100); | ||
| 27 | inline constexpr size_t MAX_MESSAGE_SIZE = 16777216; | ||
| 28 | inline constexpr size_t DEFAULT_SPIN_BACKOFF_ITERATIONS = 32; | ||
| 29 | inline constexpr auto DEFAULT_FLUSH_TIMEOUT = std::chrono::milliseconds(500); | ||
| 30 | inline constexpr size_t MEMORY_POOL_BLOCK_SIZE = 4096; | ||
| 31 | inline constexpr size_t MEMORY_POOL_BLOCK_COUNT = 64; | ||
| 32 | inline constexpr size_t POOL_SLOTS_PER_BLOCK = 16; | ||
| 33 | |||
| 34 | enum class OverflowPolicy | ||
| 35 | { | ||
| 36 | DropNewest, | ||
| 37 | DropOldest, | ||
| 38 | Block, | ||
| 39 | SyncFallback | ||
| 40 | }; | ||
| 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. | ||
| 46 | * Blocks are allocated on-demand up to MEMORY_POOL_BLOCK_COUNT. | ||
| 47 | * Each block is cache-line aligned to prevent false sharing. | ||
| 48 | */ | ||
| 49 | class StringPool | ||
| 50 | { | ||
| 51 | public: | ||
| 52 | static StringPool &instance() noexcept; | ||
| 53 | |||
| 54 | [[nodiscard]] std::string *allocate(size_t size); | ||
| 55 | void deallocate(std::string *ptr) noexcept; | ||
| 56 | |||
| 57 | StringPool(const StringPool &) = delete; | ||
| 58 | StringPool &operator=(const StringPool &) = delete; | ||
| 59 | StringPool(StringPool &&) = delete; | ||
| 60 | StringPool &operator=(StringPool &&) = delete; | ||
| 61 | |||
| 62 | private: | ||
| 63 | struct PoolSlot | ||
| 64 | { | ||
| 65 | std::string str; | ||
| 66 | PoolSlot *next_free{nullptr}; | ||
| 67 | }; | ||
| 68 | #if defined(__GNUC__) || defined(__clang__) | ||
| 69 | #pragma GCC diagnostic push | ||
| 70 | #pragma GCC diagnostic ignored "-Winvalid-offsetof" | ||
| 71 | #endif | ||
| 72 | static_assert(offsetof(PoolSlot, str) == 0, | ||
| 73 | "PoolSlot::str must be the first member for pointer arithmetic in deallocate()"); | ||
| 74 | #if defined(__GNUC__) || defined(__clang__) | ||
| 75 | #pragma GCC diagnostic pop | ||
| 76 | #endif | ||
| 77 | |||
| 78 | struct Block | ||
| 79 | { | ||
| 80 | alignas(64) char data[POOL_SLOTS_PER_BLOCK * sizeof(PoolSlot)]; | ||
| 81 | Block *next{nullptr}; | ||
| 82 | PoolSlot *free_list{nullptr}; | ||
| 83 | size_t slot_count{0}; | ||
| 84 | uint32_t constructed_mask{0}; | ||
| 85 | |||
| 86 | PoolSlot *get_slot(size_t index) noexcept | ||
| 87 | { | ||
| 88 | return reinterpret_cast<PoolSlot *>(data) + index; | ||
| 89 | } | ||
| 90 | }; | ||
| 91 | |||
| 92 | StringPool(); | ||
| 93 | ~StringPool() noexcept; | ||
| 94 | |||
| 95 | /// Must be called with pool_mutex_ held. | ||
| 96 | void grow_pool_locked(); | ||
| 97 | PoolSlot *claim_free_slot() noexcept; | ||
| 98 | void return_slot_locked(PoolSlot *slot, Block *block) noexcept; | ||
| 99 | |||
| 100 | std::atomic<Block *> head_{nullptr}; | ||
| 101 | std::atomic<size_t> pool_size_{0}; | ||
| 102 | std::atomic<size_t> heap_fallback_count_{0}; | ||
| 103 | std::mutex pool_mutex_; | ||
| 104 | }; | ||
| 105 | |||
| 106 | /** | ||
| 107 | * @struct LogMessage | ||
| 108 | * @brief A log entry with inline buffer optimization and overflow handling. | ||
| 109 | * @details Messages <= 512 bytes are stored inline. Larger messages use | ||
| 110 | * heap allocation via StringPool. | ||
| 111 | */ | ||
| 112 | struct LogMessage | ||
| 113 | { | ||
| 114 | LogLevel level; | ||
| 115 | std::chrono::system_clock::time_point timestamp; | ||
| 116 | std::thread::id thread_id; | ||
| 117 | |||
| 118 | static constexpr size_t MAX_INLINE_SIZE = 512; | ||
| 119 | static constexpr size_t MAX_VALID_LENGTH = MAX_MESSAGE_SIZE; | ||
| 120 | std::array<char, MAX_INLINE_SIZE> buffer; | ||
| 121 | size_t length{0}; | ||
| 122 | |||
| 123 | // Owned: allocated by StringPool, freed by reset(). | ||
| 124 | std::string *overflow{nullptr}; | ||
| 125 | |||
| 126 | LogMessage(LogLevel lvl, std::string_view msg); | ||
| 127 | 338158 | LogMessage() noexcept = default; | |
| 128 | |||
| 129 | ~LogMessage() noexcept; | ||
| 130 | |||
| 131 | LogMessage(LogMessage &&other) noexcept; | ||
| 132 | LogMessage &operator=(LogMessage &&other) noexcept; | ||
| 133 | |||
| 134 | LogMessage(const LogMessage &) = delete; | ||
| 135 | LogMessage &operator=(const LogMessage &) = delete; | ||
| 136 | |||
| 137 | [[nodiscard]] std::string_view message() const noexcept; | ||
| 138 | [[nodiscard]] bool is_valid() const noexcept; | ||
| 139 | void reset() noexcept; | ||
| 140 | }; | ||
| 141 | |||
| 142 | /** | ||
| 143 | * @class DynamicMPMCQueue | ||
| 144 | * @brief A dynamically-sized, bounded Multi-Producer Multi-Consumer queue. | ||
| 145 | * @details Uses a ring buffer with atomic sequence numbers for lock-free | ||
| 146 | * synchronization. Capacity is determined at construction time. | ||
| 147 | * @note This queue is designed to be constructed once and never resized. | ||
| 148 | * Moving slots after construction is not supported and will cause data corruption. | ||
| 149 | */ | ||
| 150 | class DynamicMPMCQueue | ||
| 151 | { | ||
| 152 | public: | ||
| 153 | /** | ||
| 154 | * @brief Constructs a queue with the specified capacity. | ||
| 155 | * @param capacity The maximum number of elements (must be power of 2 and >= 2). | ||
| 156 | */ | ||
| 157 | explicit DynamicMPMCQueue(size_t capacity); | ||
| 158 | |||
| 159 | 71 | ~DynamicMPMCQueue() = default; | |
| 160 | |||
| 161 | DynamicMPMCQueue(const DynamicMPMCQueue &) = delete; | ||
| 162 | DynamicMPMCQueue &operator=(const DynamicMPMCQueue &) = delete; | ||
| 163 | DynamicMPMCQueue(DynamicMPMCQueue &&) = delete; | ||
| 164 | DynamicMPMCQueue &operator=(DynamicMPMCQueue &&) = delete; | ||
| 165 | |||
| 166 | /** | ||
| 167 | * @brief Attempts to push an item into the queue. | ||
| 168 | * @param item The item to push. Moved into the queue on success only; | ||
| 169 | * left unchanged on failure so the caller can retry or handle overflow. | ||
| 170 | * @return true if successful, false if queue is full. | ||
| 171 | */ | ||
| 172 | bool try_push(LogMessage &item); | ||
| 173 | |||
| 174 | /** | ||
| 175 | * @brief Attempts to pop an item from the queue. | ||
| 176 | * @param item Reference to store the popped item. | ||
| 177 | * @return true if successful, false if queue is empty. | ||
| 178 | */ | ||
| 179 | bool try_pop(LogMessage &item); | ||
| 180 | |||
| 181 | /** | ||
| 182 | * @brief Attempts to pop multiple items up to a maximum count. | ||
| 183 | * @param items Reference to a vector to store popped items. | ||
| 184 | * @param max_count Maximum number of items to pop. | ||
| 185 | * @return size_t Number of items actually popped. | ||
| 186 | */ | ||
| 187 | size_t try_pop_batch(std::vector<LogMessage> &items, size_t max_count); | ||
| 188 | |||
| 189 | /// Returns the approximate number of items in the queue. | ||
| 190 | size_t size() const noexcept; | ||
| 191 | |||
| 192 | /// Checks if the queue is approximately empty. | ||
| 193 | bool empty() const noexcept; | ||
| 194 | |||
| 195 | /** | ||
| 196 | * @brief Returns the capacity of the queue. | ||
| 197 | * @return size_t The maximum number of elements. | ||
| 198 | */ | ||
| 199 | 1 | size_t capacity() const noexcept { return capacity_; } | |
| 200 | |||
| 201 | private: | ||
| 202 | struct Slot | ||
| 203 | { | ||
| 204 | std::atomic<size_t> sequence; | ||
| 205 | LogMessage data; | ||
| 206 | |||
| 207 | 337762 | Slot() noexcept : sequence(0) {} | |
| 208 | |||
| 209 | Slot(const Slot &) = delete; | ||
| 210 | Slot &operator=(const Slot &) = delete; | ||
| 211 | Slot(Slot &&) = delete; | ||
| 212 | Slot &operator=(Slot &&) = delete; | ||
| 213 | }; | ||
| 214 | |||
| 215 | /// Validates capacity before member initialization to prevent | ||
| 216 | /// allocation of an invalid-sized buffer in the initializer list. | ||
| 217 | static size_t validated_capacity(size_t capacity); | ||
| 218 | |||
| 219 | // Immutable after construction — never resized. | ||
| 220 | const size_t capacity_; | ||
| 221 | const size_t mask_; | ||
| 222 | |||
| 223 | // Allocated once in the constructor; the unique_ptr ensures immutability | ||
| 224 | // (no accidental resize) while maintaining contiguous cache-friendly layout. | ||
| 225 | std::unique_ptr<Slot[]> buffer_; | ||
| 226 | |||
| 227 | // Cache-line aligned to prevent false sharing between producers and consumers. | ||
| 228 | alignas(64) std::atomic<size_t> enqueue_pos_{0}; | ||
| 229 | alignas(64) std::atomic<size_t> dequeue_pos_{0}; | ||
| 230 | }; | ||
| 231 | |||
| 232 | /** | ||
| 233 | * @struct AsyncLoggerConfig | ||
| 234 | * @brief Configuration for the async logger. | ||
| 235 | */ | ||
| 236 | struct AsyncLoggerConfig | ||
| 237 | { | ||
| 238 | size_t queue_capacity = DEFAULT_QUEUE_CAPACITY; | ||
| 239 | size_t batch_size = DEFAULT_BATCH_SIZE; | ||
| 240 | std::chrono::milliseconds flush_interval = DEFAULT_FLUSH_INTERVAL; | ||
| 241 | OverflowPolicy overflow_policy = OverflowPolicy::DropOldest; | ||
| 242 | size_t spin_backoff_iterations = DEFAULT_SPIN_BACKOFF_ITERATIONS; | ||
| 243 | std::chrono::milliseconds block_timeout_ms{16}; | ||
| 244 | size_t block_max_spin_iterations{1000}; | ||
| 245 | |||
| 246 | 66 | [[nodiscard]] constexpr bool validate() const noexcept | |
| 247 | { | ||
| 248 |
4/4✓ Branch 2 → 3 taken 64 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 63 times.
|
66 | if (queue_capacity < 2 || (queue_capacity & (queue_capacity - 1)) != 0) |
| 249 | 3 | return false; | |
| 250 |
2/2✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 62 times.
|
63 | if (batch_size == 0) |
| 251 | 1 | return false; | |
| 252 |
2/2✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 61 times.
|
62 | if (flush_interval.count() <= 0) |
| 253 | 1 | return false; | |
| 254 |
2/2✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 60 times.
|
61 | if (spin_backoff_iterations == 0) |
| 255 | 1 | return false; | |
| 256 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 60 times.
|
60 | if (block_timeout_ms.count() <= 0) |
| 257 | ✗ | return false; | |
| 258 |
1/2✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 60 times.
|
60 | if (block_max_spin_iterations == 0) |
| 259 | ✗ | return false; | |
| 260 | 60 | return true; | |
| 261 | } | ||
| 262 | }; | ||
| 263 | |||
| 264 | // Compile-time validation: Default queue capacity must be a power of 2 and >= 2 | ||
| 265 | static_assert(DEFAULT_QUEUE_CAPACITY >= 2 && (DEFAULT_QUEUE_CAPACITY & (DEFAULT_QUEUE_CAPACITY - 1)) == 0, | ||
| 266 | "DEFAULT_QUEUE_CAPACITY must be a power of 2 and at least 2"); | ||
| 267 | |||
| 268 | /** | ||
| 269 | * @class AsyncLogger | ||
| 270 | * @brief Asynchronous logger that decouples log production from file I/O. | ||
| 271 | * @details Uses a lock-free queue to accept log messages from multiple threads | ||
| 272 | * and a dedicated writer thread to perform batched file writes. | ||
| 273 | * This significantly reduces latency on the producer side. | ||
| 274 | * @note Uses shared_ptr<WinFileStream> to safely handle Logger reconfiguration during runtime. | ||
| 275 | */ | ||
| 276 | class AsyncLogger | ||
| 277 | { | ||
| 278 | public: | ||
| 279 | /** | ||
| 280 | * @brief Constructs an AsyncLogger with the given configuration. | ||
| 281 | * @param config The async logger configuration. | ||
| 282 | * @param file_stream Shared pointer to the output file stream (allows safe reconfigure). | ||
| 283 | * @param log_mutex Shared pointer to the mutex protecting the file stream. | ||
| 284 | */ | ||
| 285 | explicit AsyncLogger(const AsyncLoggerConfig &config, | ||
| 286 | std::shared_ptr<WinFileStream> file_stream, | ||
| 287 | std::shared_ptr<std::mutex> log_mutex); | ||
| 288 | |||
| 289 | ~AsyncLogger() noexcept; | ||
| 290 | |||
| 291 | AsyncLogger(const AsyncLogger &) = delete; | ||
| 292 | AsyncLogger &operator=(const AsyncLogger &) = delete; | ||
| 293 | AsyncLogger(AsyncLogger &&) = delete; | ||
| 294 | AsyncLogger &operator=(AsyncLogger &&) = delete; | ||
| 295 | |||
| 296 | /** | ||
| 297 | * @brief Enqueues a log message for asynchronous writing. | ||
| 298 | * @param level The log level. | ||
| 299 | * @param message The message string. | ||
| 300 | * @return true if the message was successfully enqueued or written, false if dropped or timed out. | ||
| 301 | * @details This method is non-blocking (unless OverflowPolicy::Block is used). | ||
| 302 | * The message will be written to the log file by the writer thread. | ||
| 303 | */ | ||
| 304 | [[nodiscard]] bool enqueue(LogLevel level, std::string_view message) noexcept; | ||
| 305 | |||
| 306 | /** | ||
| 307 | * @brief Flushes all pending log messages with a timeout. | ||
| 308 | * @param timeout Maximum time to wait for flush to complete. | ||
| 309 | * @return true if all messages were flushed, false if timeout occurred. | ||
| 310 | */ | ||
| 311 | [[nodiscard]] bool flush_with_timeout(std::chrono::milliseconds timeout) noexcept; | ||
| 312 | |||
| 313 | /** | ||
| 314 | * @brief Flushes all pending log messages. | ||
| 315 | * @details Waits up to 500ms for all queued messages to be written. | ||
| 316 | * Uses a timeout to prevent indefinite blocking. | ||
| 317 | */ | ||
| 318 | void flush() noexcept; | ||
| 319 | |||
| 320 | void shutdown() noexcept; | ||
| 321 | |||
| 322 | [[nodiscard]] bool is_running() const noexcept; | ||
| 323 | |||
| 324 | [[nodiscard]] size_t queue_size() const noexcept; | ||
| 325 | |||
| 326 | /** | ||
| 327 | * @brief Returns the total number of messages dropped due to queue overflow. | ||
| 328 | * @return size_t Number of dropped messages. | ||
| 329 | */ | ||
| 330 | [[nodiscard]] size_t dropped_count() const noexcept; | ||
| 331 | |||
| 332 | /** | ||
| 333 | * @brief Resets the dropped message counter. | ||
| 334 | */ | ||
| 335 | void reset_dropped_count() noexcept; | ||
| 336 | |||
| 337 | private: | ||
| 338 | void writer_thread_func() noexcept; | ||
| 339 | |||
| 340 | void write_batch(std::span<LogMessage> messages) noexcept; | ||
| 341 | |||
| 342 | bool handle_overflow(LogMessage &&message) noexcept; | ||
| 343 | |||
| 344 | DynamicMPMCQueue queue_; | ||
| 345 | AsyncLoggerConfig config_; | ||
| 346 | |||
| 347 | std::shared_ptr<WinFileStream> file_stream_; | ||
| 348 | std::shared_ptr<std::mutex> log_mutex_; | ||
| 349 | |||
| 350 | std::jthread writer_thread_; | ||
| 351 | std::atomic<bool> running_{false}; | ||
| 352 | std::atomic<bool> shutdown_requested_{false}; | ||
| 353 | |||
| 354 | std::mutex flush_mutex_; | ||
| 355 | std::condition_variable flush_cv_; | ||
| 356 | std::atomic<size_t> pending_messages_{0}; | ||
| 357 | std::atomic<size_t> dropped_messages_{0}; | ||
| 358 | }; | ||
| 359 | |||
| 360 | } // namespace DetourModKit | ||
| 361 | |||
| 362 | #endif // DETOURMODKIT_ASYNC_LOGGER_HPP | ||
| 363 |