GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 91.5% 390 / 0 / 426
Functions: 100.0% 33 / 0 / 33
Branches: 63.1% 385 / 0 / 610

src/logger.cpp
Line Branch Exec Source
1 #include "DetourModKit/logger.hpp"
2 #include "DetourModKit/diagnostics.hpp"
3 #include "DetourModKit/filesystem.hpp"
4
5 #include "internal/async_logger.hpp"
6 #include "internal/win_file_stream.hpp"
7 #include "platform.hpp"
8
9 #include <chrono>
10 #include <cstdio>
11 #include <ctime>
12 #include <filesystem>
13 #include <iomanip>
14 #include <iostream>
15 #include <new>
16 #include <stdexcept>
17 #include <type_traits>
18
19 namespace DetourModKit::detail
20 {
21 #if defined(DMK_ENABLE_TEST_SEAMS)
22 // Test-only probe fired from Logger::shutdown_internal() inside its dropped-mutex window: async logging already
23 // disabled but the sink stream not yet closed. When non-null, a fixture uses it to prove that enable_async_mode()'s
24 // m_shutdown_called gate refuses to resurrect async logging in exactly that gap (the one interleaving a bare
25 // after-shutdown enable cannot reach, because by then the stream is also closed). Set / cleared on a single thread
26 // inside a test fixture; the definition and fire site compile out of shipping builds.
27 void (*g_logger_shutdown_gap_probe)() noexcept = nullptr;
28
29 // Test-only probe fired inside enable_async_mode()'s arm-then-publish window. When non-null it may throw, which
30 // is the only way to reach the rollback that breaks a retention root whose writer was never published. Set /
31 // cleared on a single thread inside a test fixture; the definition and fire site compile out of shipping builds.
32 void (*g_logger_publication_probe)() = nullptr;
33
34 // Test-only probe at the old success-diagnostic site after enable_async_mode() publishes the writer.
35 // It can throw when non-null. Tests install it on one thread. Shipping builds omit it.
36 void (*g_logger_post_publication_probe)() = nullptr;
37
38 // Test-only probe fired after Logger::log() snapshots an async writer and before it calls enqueue(). A fixture
39 // retires that writer inside the probe to prove a late rejection reaches the facade's cumulative drop counter.
40 void (*g_logger_async_snapshot_probe)() noexcept = nullptr;
41
42 // Test-only probe at entry to each record delivery route. A fixture checks whether a config lock spans that route.
43 void (*g_logger_record_probe)(LogLevel, std::string_view) noexcept = nullptr;
44
45 // Test-only rendezvous after set_log_level() reads the current level and before it attempts the state transition.
46 void (*g_logger_level_transition_probe)() noexcept = nullptr;
47 #endif
48 } // namespace DetourModKit::detail
49
50 namespace DetourModKit
51 {
52 namespace
53 {
54 using StaticConfigAtom = std::atomic<std::shared_ptr<const Logger::StaticConfig>>;
55
56 /**
57 * @brief Returns the never-destroyed process configuration publication slot.
58 * @details The slot matches the process-lifetime Logger storage (`[B-47]`).
59 */
60 3079 StaticConfigAtom &static_config_atom()
61 {
62 alignas(StaticConfigAtom) static unsigned char storage[sizeof(StaticConfigAtom)];
63 static StaticConfigAtom *const atom =
64
1/2
✓ Branch 15 → 16 taken 1529 times.
✗ Branch 15 → 29 not taken.
3058 ::new (static_cast<void *>(storage)) StaticConfigAtom{std::make_shared<const Logger::StaticConfig>(
65
1/2
✓ Branch 14 → 15 taken 1529 times.
✗ Branch 14 → 31 not taken.
3058 std::string{DEFAULT_LOG_PREFIX},
66
1/2
✓ Branch 11 → 12 taken 1529 times.
✗ Branch 11 → 37 not taken.
3058 std::string{DEFAULT_LOG_FILE_NAME},
67
1/2
✓ Branch 8 → 9 taken 1529 times.
✗ Branch 8 → 43 not taken.
3058 std::string{DEFAULT_TIMESTAMP_FORMAT}
68
4/10
✓ Branch 2 → 3 taken 1529 times.
✓ Branch 2 → 27 taken 1550 times.
✓ Branch 4 → 5 taken 1529 times.
✗ Branch 4 → 27 not taken.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 1529 times.
✗ Branch 48 → 49 not taken.
✗ Branch 48 → 50 not taken.
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
6137 )};
69 3079 return *atom;
70 }
71
72 /**
73 * @brief Serializes process-default configuration publication and application.
74 * @details Late static teardown can call configure(). The mutex uses never-destroyed storage (`[B-47]`).
75 */
76 757 [[nodiscard]] std::mutex &static_config_mutex() noexcept
77 {
78 alignas(std::mutex) static unsigned char storage[sizeof(std::mutex)];
79
4/6
✓ Branch 2 → 3 taken 320 times.
✓ Branch 2 → 10 taken 437 times.
✓ Branch 4 → 5 taken 320 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 320 times.
757 static std::mutex *const mutex = ::new (static_cast<void *>(storage)) std::mutex();
80 757 return *mutex;
81 }
82
83 /**
84 * @brief Writes one INFO banner line from a pre-rendered timestamp.
85 * @details Stream insertion records an error through failbit. This helper does not throw after a state
86 * commit.
87 */
88 void
89 2222 write_banner_line(detail::WinFileStream &sink, std::string_view timestamp, std::string_view message) noexcept
90 {
91 2222 sink << "[" << timestamp << "] " << "[" << std::setw(7) << std::left << "INFO" << "] :: " << message
92 2222 << '\n';
93 2222 }
94
95 /**
96 * @brief Abandons a detached writer's handle to the retention root armed before its publication.
97 * @details Retention costs nothing here: the writer already owns itself, so keeping it alive is simply not
98 * breaking that root. Nothing is allocated, nothing can throw, and there is no finite supply of
99 * fallback storage to exhaust, which is what makes this safe on the loader-lock path that reaches it.
100 * No module reference is taken either: the detached writer thread holds its own counted reference on
101 * this module (taken before thread creation, while the module was fully mapped), and that is what
102 * keeps its code mapped.
103 */
104 26 void abandon_detached_async_logger(std::shared_ptr<AsyncLogger> &logger) noexcept
105 {
106
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 26 times.
26 if (!logger)
107 {
108 return;
109 }
110 26 logger.reset();
111 26 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Logger);
112 }
113 } // anonymous namespace
114
115 2307 std::shared_ptr<const Logger::StaticConfig> Logger::get_static_config()
116 {
117 2307 return static_config_atom().load(std::memory_order_acquire);
118 }
119
120 772 void Logger::set_static_config(std::shared_ptr<const StaticConfig> config)
121 {
122 1544 static_config_atom().store(std::move(config), std::memory_order_release);
123 772 }
124
125 802 LogLevel string_to_log_level(std::string_view level_str)
126 {
127
1/2
✓ Branch 4 → 5 taken 802 times.
✗ Branch 4 → 48 not taken.
802 std::string upper_level_str(level_str);
128
2/2
✓ Branch 22 → 8 taken 6997 times.
✓ Branch 22 → 23 taken 802 times.
8601 for (char &c : upper_level_str)
129 {
130 // Fold ASCII a-z by hand, as manifest.cpp does for its keywords: std::toupper is locale sensitive, which
131 // [B-37] forbids on a resolution path. LoggerTest.StringToLogLevelUsesAsciiFold pins the rule.
132
3/4
✓ Branch 10 → 11 taken 107 times.
✓ Branch 10 → 13 taken 6890 times.
✓ Branch 11 → 12 taken 107 times.
✗ Branch 11 → 13 not taken.
6997 if (c >= 'a' && c <= 'z')
133 {
134 107 c = static_cast<char>(c - 'a' + 'A');
135 }
136 }
137
138
3/4
✓ Branch 23 → 24 taken 802 times.
✗ Branch 23 → 51 not taken.
✓ Branch 24 → 25 taken 8 times.
✓ Branch 24 → 26 taken 794 times.
802 if (upper_level_str == "TRACE")
139 8 return LogLevel::Trace;
140
3/4
✓ Branch 26 → 27 taken 794 times.
✗ Branch 26 → 51 not taken.
✓ Branch 27 → 28 taken 9 times.
✓ Branch 27 → 29 taken 785 times.
794 if (upper_level_str == "DEBUG")
141 9 return LogLevel::Debug;
142
3/4
✓ Branch 29 → 30 taken 785 times.
✗ Branch 29 → 51 not taken.
✓ Branch 30 → 31 taken 5 times.
✓ Branch 30 → 32 taken 780 times.
785 if (upper_level_str == "INFO")
143 5 return LogLevel::Info;
144
3/4
✓ Branch 32 → 33 taken 780 times.
✗ Branch 32 → 51 not taken.
✓ Branch 33 → 34 taken 8 times.
✓ Branch 33 → 35 taken 772 times.
780 if (upper_level_str == "WARNING")
145 8 return LogLevel::Warning;
146
3/4
✓ Branch 35 → 36 taken 772 times.
✗ Branch 35 → 51 not taken.
✓ Branch 36 → 37 taken 8 times.
✓ Branch 36 → 38 taken 764 times.
772 if (upper_level_str == "ERROR")
147 8 return LogLevel::Error;
148
149 std::cerr << "[" << DEFAULT_LOG_PREFIX << " Logger WARNING] Unrecognized log level string '" << level_str
150
6/12
✓ Branch 38 → 39 taken 764 times.
✗ Branch 38 → 51 not taken.
✓ Branch 39 → 40 taken 764 times.
✗ Branch 39 → 51 not taken.
✓ Branch 40 → 41 taken 764 times.
✗ Branch 40 → 51 not taken.
✓ Branch 41 → 42 taken 764 times.
✗ Branch 41 → 51 not taken.
✓ Branch 42 → 43 taken 764 times.
✗ Branch 42 → 51 not taken.
✓ Branch 43 → 44 taken 764 times.
✗ Branch 43 → 51 not taken.
764 << "'. Defaulting to INFO." << '\n';
151 764 return LogLevel::Info;
152 802 }
153
154 757 void Logger::configure(
155 std::string_view prefix,
156 std::string_view file_name,
157 std::string_view timestamp_fmt,
158 LogOpenMode open_mode,
159 LogSourceStampMode source_stamp_mode
160 )
161 {
162
1/2
✓ Branch 3 → 4 taken 757 times.
✗ Branch 3 → 127 not taken.
757 std::lock_guard<std::mutex> config_lock(static_config_mutex());
163
164 // The staged snapshot precedes first use because the process-default constructor reads it. The prior snapshot
165 // restores the last accepted defaults after a failed apply.
166 auto staged_config = std::make_shared<const StaticConfig>(
167
2/2
✓ Branch 12 → 13 taken 754 times.
✓ Branch 12 → 77 taken 1 time.
1511 std::string(prefix),
168
2/2
✓ Branch 9 → 10 taken 755 times.
✓ Branch 9 → 83 taken 1 time.
1514 std::string(file_name),
169
2/2
✓ Branch 6 → 7 taken 756 times.
✓ Branch 6 → 89 taken 1 time.
1517 std::string(timestamp_fmt),
170 open_mode,
171 source_stamp_mode
172
2/2
✓ Branch 13 → 14 taken 753 times.
✓ Branch 13 → 75 taken 1 time.
754 );
173
1/2
✓ Branch 20 → 21 taken 753 times.
✗ Branch 20 → 123 not taken.
753 auto previous_config = get_static_config();
174
1/2
✓ Branch 24 → 25 taken 753 times.
✗ Branch 24 → 93 not taken.
753 set_static_config(std::move(staged_config));
175
176 // The qualified free accessor avoids the member log() overload set, which hides the process-default accessor.
177 try
178 {
179 753 Logger &instance = DetourModKit::log();
180
181 // An inert first-use logger owns no sink or mutex and cannot apply the staged defaults. Restore the prior
182 // snapshot before it returns to its process-lifetime inert state.
183
1/2
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 35 taken 753 times.
753 if (instance.is_inert())
184 {
185 set_static_config(std::move(previous_config));
186 return;
187 }
188
189 // The sink locks serialize configuration with shutdown. Neither operation can split the other's sink
190 // transition.
191
1/2
✓ Branch 36 → 37 taken 753 times.
✗ Branch 36 → 107 not taken.
753 std::scoped_lock lock(instance.m_async_mutex, *instance.m_log_mutex_ptr);
192
1/2
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 45 taken 753 times.
753 if (instance.m_async_writer_abandoned.load(std::memory_order_acquire))
193 {
194 // A detached writer retains final sink ownership. Restore the snapshot to avoid a second sink owner.
195 set_static_config(std::move(previous_config));
196 return;
197 }
198 // configure() is the authoritative reset path: it re-enables the process default even after a shutdown,
199 // so a test fixture or a re-attach can reuse the sink. The latch is deliberately outside the sink
200 // transaction. A live facade over an unopenable sink still routes an Error record to stderr.
201 753 instance.m_shutdown_called.store(false, std::memory_order_release);
202
4/4
✓ Branch 46 → 47 taken 738 times.
✓ Branch 46 → 105 taken 15 times.
✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 54 taken 734 times.
753 if (!instance.reconfigure_locked(prefix, file_name, timestamp_fmt))
203 {
204
1/2
✓ Branch 51 → 52 taken 4 times.
✗ Branch 51 → 102 not taken.
4 set_static_config(std::move(previous_config));
205 }
206 else
207 {
208 734 instance.set_source_stamp_mode(source_stamp_mode);
209 }
210
1/2
✓ Branch 57 → 58 taken 738 times.
✗ Branch 57 → 60 not taken.
753 }
211 15 catch (...)
212 {
213 // A post-publication allocation can throw. Rollback pairs the immutable defaults with the last accepted
214 // configuration and preserves configure()'s exception contract.
215
1/2
✓ Branch 113 → 114 taken 15 times.
✗ Branch 113 → 116 not taken.
15 set_static_config(std::move(previous_config));
216 15 throw;
217 15 }
218
3/6
✓ Branch 62 → 63 taken 738 times.
✗ Branch 62 → 64 not taken.
✓ Branch 66 → 67 taken 738 times.
✗ Branch 66 → 68 not taken.
✓ Branch 70 → 71 taken 738 times.
✗ Branch 70 → 73 not taken.
787 }
219
220 22 void Logger::reconfigure(std::string_view prefix, std::string_view file_name, std::string_view timestamp_fmt)
221 {
222
5/6
✓ Branch 3 → 4 taken 22 times.
✗ Branch 3 → 6 not taken.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 21 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 21 times.
22 if (is_inert() || m_async_writer_abandoned.load(std::memory_order_acquire))
223 {
224 2 return;
225 }
226
227
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 20 times.
21 if (m_shutdown_called.load(std::memory_order_acquire))
228 {
229 1 return;
230 }
231
232 // Acquire both m_async_mutex and *m_log_mutex_ptr to prevent concurrent log() calls from reading
233 // partially-updated string members during reconfiguration.
234
1/2
✓ Branch 14 → 15 taken 20 times.
✗ Branch 14 → 33 not taken.
20 std::scoped_lock lock(m_async_mutex, *m_log_mutex_ptr);
235
3/6
✓ Branch 16 → 17 taken 20 times.
✗ Branch 16 → 19 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 20 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 20 times.
40 if (m_shutdown_called.load(std::memory_order_acquire) ||
236 20 m_async_writer_abandoned.load(std::memory_order_acquire))
237 {
238 return;
239 }
240 // The public form reports nothing: a caller that needs the outcome reads the log file it named.
241
2/2
✓ Branch 23 → 24 taken 9 times.
✓ Branch 23 → 31 taken 11 times.
20 (void)reconfigure_locked(prefix, file_name, timestamp_fmt);
242
1/2
✓ Branch 26 → 27 taken 9 times.
✗ Branch 26 → 29 not taken.
20 }
243
244 773 bool Logger::reconfigure_locked(std::string_view prefix, std::string_view file_name, std::string_view timestamp_fmt)
245 {
246 // Precondition: the caller holds m_async_mutex and *m_log_mutex_ptr.
247
248 // Skip only when all parameters match AND the stream is usable. After shutdown or a prior open failure the
249 // stream may be closed, so fall through to reopen even if the strings are identical.
250
4/6
✓ Branch 4 → 5 taken 579 times.
✓ Branch 4 → 9 taken 194 times.
✓ Branch 6 → 7 taken 579 times.
✗ Branch 6 → 180 not taken.
✓ Branch 7 → 8 taken 579 times.
✗ Branch 7 → 9 not taken.
773 const bool sink_usable = m_log_file_stream_ptr->is_open() && m_log_file_stream_ptr->good();
251
10/10
✓ Branch 10 → 11 taken 579 times.
✓ Branch 10 → 21 taken 194 times.
✓ Branch 13 → 14 taken 297 times.
✓ Branch 13 → 21 taken 282 times.
✓ Branch 16 → 17 taken 294 times.
✓ Branch 16 → 21 taken 3 times.
✓ Branch 19 → 20 taken 291 times.
✓ Branch 19 → 21 taken 3 times.
✓ Branch 22 → 23 taken 291 times.
✓ Branch 22 → 24 taken 482 times.
1067 if (sink_usable && m_log_prefix == prefix && m_log_file_name == file_name &&
252 294 m_timestamp_format == timestamp_fmt)
253 {
254 291 return true;
255 }
256
257 // The prefix and timestamp format apply per line, not baked into the open file, so a change that keeps the
258 // same file needs no reopen. A different target file, or an unusable stream, needs a replacement sink. Only
259 // the process-start constructor truncates, so the replacement opens in append mode and the target file keeps
260 // its existing records.
261
4/4
✓ Branch 26 → 27 taken 230 times.
✓ Branch 26 → 28 taken 252 times.
✓ Branch 27 → 28 taken 176 times.
✓ Branch 27 → 29 taken 54 times.
482 const bool needs_new_sink = (m_log_file_name != file_name) || !sink_usable;
262
263 // All transaction allocation precedes file creation. A failed candidate leaves the current configuration and
264 // sink intact. A failed retirement can leave an empty candidate file, but it retains the current handle and
265 // tail.
266 // LoggerTest.Reconfigure_InvalidPath_KeepsOldFile and LoggerTest.Reconfigure_AllocationFailure_KeepsOldSink
267 // pin both halves.
268
2/2
✓ Branch 32 → 33 taken 481 times.
✓ Branch 32 → 147 taken 1 time.
964 std::string staged_prefix(prefix);
269
2/2
✓ Branch 36 → 37 taken 479 times.
✓ Branch 36 → 150 taken 2 times.
962 std::string staged_file_name(file_name);
270
2/2
✓ Branch 40 → 41 taken 478 times.
✓ Branch 40 → 153 taken 1 time.
480 std::string staged_timestamp_format(timestamp_fmt);
271
2/2
✓ Branch 42 → 43 taken 477 times.
✓ Branch 42 → 174 taken 1 time.
478 std::string staged_async_format = staged_timestamp_format;
272
4/4
✓ Branch 43 → 44 taken 423 times.
✓ Branch 43 → 48 taken 54 times.
✓ Branch 46 → 47 taken 229 times.
✓ Branch 46 → 48 taken 194 times.
477 const bool retires_prior_sink = needs_new_sink && m_log_file_stream_ptr->is_open();
273
4/4
✓ Branch 49 → 50 taken 229 times.
✓ Branch 49 → 51 taken 248 times.
✓ Branch 50 → 52 taken 227 times.
✓ Branch 50 → 172 taken 2 times.
477 const std::string retire_timestamp = retires_prior_sink ? get_timestamp(m_timestamp_format) : std::string{};
274 475 std::string retire_notice;
275
2/2
✓ Branch 53 → 54 taken 227 times.
✓ Branch 53 → 56 taken 248 times.
475 if (retires_prior_sink)
276 {
277
2/2
✓ Branch 54 → 55 taken 225 times.
✓ Branch 54 → 168 taken 2 times.
227 retire_notice = "Logger reconfiguring. New file: ";
278
2/2
✓ Branch 55 → 56 taken 223 times.
✓ Branch 55 → 168 taken 2 times.
225 retire_notice += file_name;
279 }
280 471 const std::string adopt_notice = needs_new_sink ? ("Logger reconfigured. Now logging to: " + staged_file_name)
281
8/10
✓ Branch 56 → 57 taken 417 times.
✓ Branch 56 → 58 taken 54 times.
✓ Branch 57 → 61 taken 415 times.
✓ Branch 57 → 156 taken 2 times.
✓ Branch 60 → 61 taken 54 times.
✗ Branch 60 → 156 not taken.
✓ Branch 61 → 62 taken 54 times.
✓ Branch 61 → 64 taken 415 times.
✗ Branch 156 → 157 not taken.
✓ Branch 156 → 159 taken 2 times.
527 : std::string("Logger reconfigured (same file retained).");
282
2/2
✓ Branch 64 → 65 taken 468 times.
✓ Branch 64 → 166 taken 1 time.
469 const std::string adopt_timestamp = get_timestamp(staged_timestamp_format);
283
284 468 std::shared_ptr<detail::WinFileStream> staged_sink;
285
2/2
✓ Branch 65 → 66 taken 414 times.
✓ Branch 65 → 72 taken 54 times.
468 if (needs_new_sink)
286 {
287
2/2
✓ Branch 66 → 67 taken 402 times.
✓ Branch 66 → 161 taken 12 times.
414 staged_sink = open_sink(staged_file_name, /*truncate=*/false);
288
2/2
✓ Branch 70 → 71 taken 6 times.
✓ Branch 70 → 72 taken 396 times.
402 if (!staged_sink)
289 {
290 6 return false;
291 }
292 }
293
294 // The current sink closes before the state commit. A close failure retains the handle and buffered tail.
295 // Cleared stream flags permit a later retry. The candidate stays uncommitted while recoverable state exists.
296
6/6
✓ Branch 73 → 74 taken 396 times.
✓ Branch 73 → 76 taken 54 times.
✓ Branch 74 → 75 taken 204 times.
✓ Branch 74 → 76 taken 192 times.
✓ Branch 77 → 78 taken 204 times.
✓ Branch 77 → 93 taken 246 times.
450 if (staged_sink && retires_prior_sink)
297 {
298
1/2
✓ Branch 78 → 79 taken 204 times.
✗ Branch 78 → 85 not taken.
204 if (sink_usable)
299 {
300 204 write_banner_line(*m_log_file_stream_ptr, retire_timestamp, retire_notice);
301
1/2
✓ Branch 84 → 85 taken 204 times.
✗ Branch 84 → 162 not taken.
204 m_log_file_stream_ptr->flush();
302 }
303 204 m_log_file_stream_ptr->close();
304
2/2
✓ Branch 89 → 90 taken 1 time.
✓ Branch 89 → 93 taken 203 times.
204 if (m_log_file_stream_ptr->is_open())
305 {
306
1/2
✓ Branch 91 → 92 taken 1 time.
✗ Branch 91 → 162 not taken.
1 m_log_file_stream_ptr->clear();
307 1 return false;
308 }
309 }
310
311 // Member moves, banner writes, and both async setters are no-throw. The configuration cannot become
312 // half-applied.
313 449 m_log_prefix = std::move(staged_prefix);
314 449 m_log_file_name = std::move(staged_file_name);
315 449 m_timestamp_format = std::move(staged_timestamp_format);
316
317
2/2
✓ Branch 103 → 104 taken 395 times.
✓ Branch 103 → 111 taken 54 times.
449 if (staged_sink)
318 {
319 395 m_log_file_stream_ptr = std::move(staged_sink);
320 395 write_banner_line(*m_log_file_stream_ptr, adopt_timestamp, adopt_notice);
321 }
322 else
323 {
324 // The same file stays open with its records intact. The in-line banner records the change without
325 // truncation.
326 54 write_banner_line(*m_log_file_stream_ptr, adopt_timestamp, adopt_notice);
327
1/2
✓ Branch 116 → 117 taken 54 times.
✗ Branch 116 → 162 not taken.
54 m_log_file_stream_ptr->flush();
328 }
329
330 // A live async writer receives the committed sink and timestamp format. enable_async_mode captures both at
331 // construction. Without this update, the writer keeps the old format and writes to the retired stream. Each
332 // setter assigns under the scoped lock, and the writer reads both only under *m_log_mutex_ptr.
333
2/2
✓ Branch 118 → 119 taken 2 times.
✓ Branch 118 → 135 taken 447 times.
449 if (m_async_mode_enabled.load(std::memory_order_acquire))
334 {
335
1/2
✓ Branch 121 → 122 taken 2 times.
✗ Branch 121 → 133 not taken.
2 if (auto async_logger = m_async_logger.load(std::memory_order_acquire))
336 {
337 2 async_logger->set_file_stream(m_log_file_stream_ptr);
338 4 async_logger->set_timestamp_format(std::move(staged_async_format));
339 2 }
340 }
341 449 return true;
342 622 }
343
344
1/2
✓ Branch 6 → 7 taken 1529 times.
✗ Branch 6 → 38 not taken.
1529 Logger::Logger() : m_log_mutex_ptr(std::make_shared<std::mutex>())
345 {
346
1/2
✓ Branch 16 → 17 taken 1529 times.
✗ Branch 16 → 31 not taken.
1529 const auto config = get_static_config();
347
1/2
✓ Branch 18 → 19 taken 1529 times.
✗ Branch 18 → 29 not taken.
1529 m_log_prefix = config->log_prefix;
348
1/2
✓ Branch 20 → 21 taken 1529 times.
✗ Branch 20 → 29 not taken.
1529 m_log_file_name = config->log_file_name;
349
1/2
✓ Branch 22 → 23 taken 1529 times.
✗ Branch 22 → 29 not taken.
1529 m_timestamp_format = config->timestamp_format;
350 1529 m_source_stamp_mode.store(config->source_stamp_mode, std::memory_order_relaxed);
351
352 // A default construction starts a fresh log unless the published configuration selected Append.
353 // Reconfiguration never truncates.
354
1/2
✓ Branch 26 → 27 taken 1529 times.
✗ Branch 26 → 29 not taken.
1529 adopt_first_sink(/*truncate=*/config->open_mode == LogOpenMode::Truncate);
355 1529 }
356
357 2 Logger::Logger(InertTag) noexcept
358 {
359 // Inert first-use logger. No sink, shared sink mutex, or writer is allocated, so is_inert() (a null
360 // m_log_mutex_ptr) is true and every enabled log request fails closed by dropping and counting. Constructing
361 // the empty string and atomic members allocates nothing, so this stays no-throw under the OOM that forced the
362 // fallback.
363 2 }
364
365 41 Logger::Logger(
366 std::string_view prefix,
367 std::string_view file_name,
368 std::string_view timestamp_fmt,
369 LogOpenMode open_mode,
370 LogSourceStampMode source_stamp_mode
371 41 )
372
3/6
✓ Branch 4 → 5 taken 41 times.
✗ Branch 4 → 26 not taken.
✓ Branch 8 → 9 taken 41 times.
✗ Branch 8 → 29 not taken.
✓ Branch 12 → 13 taken 41 times.
✗ Branch 12 → 32 not taken.
246 : m_log_prefix(prefix), m_log_file_name(file_name), m_timestamp_format(timestamp_fmt),
373
1/2
✓ Branch 15 → 16 taken 41 times.
✗ Branch 15 → 41 not taken.
41 m_log_mutex_ptr(std::make_shared<std::mutex>()), m_source_stamp_mode(source_stamp_mode)
374 {
375 // Construction starts a fresh log unless the caller selected Append. Reconfiguration never truncates.
376
1/2
✓ Branch 24 → 25 taken 41 times.
✗ Branch 24 → 35 not taken.
41 adopt_first_sink(/*truncate=*/open_mode == LogOpenMode::Truncate);
377 41 }
378
379 1984 std::shared_ptr<detail::WinFileStream> Logger::open_sink(const std::string &file_name, bool truncate) const
380 {
381 // Opens a candidate sink and never touches this Logger, so reconfigure_locked() can prove the replacement
382 // file before it retires the live one. Truncate starts a fresh file. Append preserves prior records.
383
2/2
✓ Branch 2 → 3 taken 1974 times.
✓ Branch 2 → 39 taken 10 times.
1984 const std::wstring log_file_full_path = generate_log_file_path(file_name);
384
2/2
✓ Branch 3 → 4 taken 1567 times.
✓ Branch 3 → 5 taken 407 times.
1974 const auto mode = truncate ? (std::ios::out | std::ios::trunc) : (std::ios::out | std::ios::app);
385
2/2
✓ Branch 6 → 7 taken 1972 times.
✓ Branch 6 → 37 taken 2 times.
1974 auto sink = std::make_shared<detail::WinFileStream>();
386
1/2
✓ Branch 8 → 9 taken 1972 times.
✗ Branch 8 → 35 not taken.
1972 sink->open(log_file_full_path, mode);
387
388
2/2
✓ Branch 11 → 12 taken 7 times.
✓ Branch 11 → 24 taken 1965 times.
1972 if (!sink->is_open())
389 {
390 7 std::cerr << "[" << m_log_prefix << " Logger CRITICAL ERROR] "
391
1/2
✓ Branch 17 → 18 taken 7 times.
✗ Branch 17 → 31 not taken.
14 << "Failed to open log file: " << std::filesystem::path(log_file_full_path).string()
392
8/16
✓ Branch 12 → 13 taken 7 times.
✗ Branch 12 → 35 not taken.
✓ Branch 13 → 14 taken 7 times.
✗ Branch 13 → 35 not taken.
✓ Branch 14 → 15 taken 7 times.
✗ Branch 14 → 35 not taken.
✓ Branch 15 → 16 taken 7 times.
✗ Branch 15 → 35 not taken.
✓ Branch 16 → 17 taken 7 times.
✗ Branch 16 → 33 not taken.
✓ Branch 18 → 19 taken 7 times.
✗ Branch 18 → 29 not taken.
✓ Branch 19 → 20 taken 7 times.
✗ Branch 19 → 29 not taken.
✓ Branch 20 → 21 taken 7 times.
✗ Branch 20 → 29 not taken.
14 << ". Subsequent logs to file will fail." << '\n';
393 7 return nullptr;
394 }
395 1965 return sink;
396 1974 }
397
398 1570 void Logger::adopt_first_sink(bool truncate)
399 {
400 // Construction only: a failed open still leaves a stream object, because every later log() dereferences
401 // m_log_file_stream_ptr and fails closed on the closed stream rather than on a null pointer.
402
1/2
✓ Branch 2 → 3 taken 1570 times.
✗ Branch 2 → 34 not taken.
1570 auto sink = open_sink(m_log_file_name, truncate);
403
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 1569 times.
1570 if (!sink)
404 {
405
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 27 not taken.
1 m_log_file_stream_ptr = std::make_shared<detail::WinFileStream>();
406 1 return;
407 }
408 1569 m_log_file_stream_ptr = std::move(sink);
409 4707 write_banner_line(
410 1569 *m_log_file_stream_ptr,
411
1/2
✓ Branch 14 → 15 taken 1569 times.
✗ Branch 14 → 28 not taken.
3138 get_timestamp(m_timestamp_format),
412
1/2
✓ Branch 12 → 13 taken 1569 times.
✗ Branch 12 → 31 not taken.
3138 "Logger initialized. Logging to: " + m_log_file_name
413 );
414
2/2
✓ Branch 22 → 23 taken 1569 times.
✓ Branch 22 → 25 taken 1 time.
1570 }
415
416 53 Logger::~Logger() noexcept
417 {
418 41 bool expected = false;
419
2/2
✓ Branch 3 → 4 taken 29 times.
✓ Branch 3 → 5 taken 12 times.
41 if (!m_shutdown_called.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
420 {
421 29 return;
422 }
423 12 shutdown_internal();
424
14/14
✓ Branch 8 → 9 taken 12 times.
✓ Branch 8 → 10 taken 29 times.
✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 14 taken 29 times.
✓ Branch 16 → 17 taken 12 times.
✓ Branch 16 → 18 taken 29 times.
✓ Branch 20 → 21 taken 12 times.
✓ Branch 20 → 22 taken 29 times.
✓ Branch 24 → 25 taken 12 times.
✓ Branch 24 → 26 taken 29 times.
✓ Branch 28 → 29 taken 12 times.
✓ Branch 28 → 30 taken 29 times.
✓ Branch 32 → 33 taken 12 times.
✓ Branch 32 → 34 taken 29 times.
215 }
425
426 396 void Logger::shutdown() noexcept
427 {
428 396 bool expected = false;
429
2/2
✓ Branch 3 → 4 taken 29 times.
✓ Branch 3 → 5 taken 367 times.
396 if (!m_shutdown_called.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
430 {
431 29 return;
432 }
433 367 shutdown_internal();
434 }
435
436 379 void Logger::shutdown_internal() noexcept
437 {
438 // An inert first-use logger owns no async writer, sink, or shared sink mutex, so there is nothing to drain or
439 // close.
440
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 377 times.
379 if (is_inert())
441 {
442 16 return;
443 }
444
445 377 std::shared_ptr<AsyncLogger> local_logger;
446 377 bool writer_detached = false;
447
448 {
449 377 std::lock_guard<std::mutex> lock(m_async_mutex);
450
2/2
✓ Branch 7 → 8 taken 303 times.
✓ Branch 7 → 28 taken 74 times.
377 if (m_async_mode_enabled.load(std::memory_order_acquire))
451 {
452 303 local_logger = m_async_logger.exchange(nullptr, std::memory_order_acq_rel);
453 303 m_async_mode_enabled.store(false, std::memory_order_release);
454
1/2
✓ Branch 15 → 16 taken 303 times.
✗ Branch 15 → 28 not taken.
303 if (local_logger)
455 {
456 303 local_logger->shutdown();
457 303 writer_detached = local_logger->writer_was_detached();
458 303 m_dropped_messages.fetch_add(local_logger->dropped_count(), std::memory_order_relaxed);
459
2/2
✓ Branch 24 → 25 taken 14 times.
✓ Branch 24 → 26 taken 289 times.
303 if (writer_detached)
460 {
461 // The retained writer still owns the sink. Latch the facade inert before releasing the
462 // lifecycle mutex so configure cannot reopen behind it.
463 14 m_async_writer_abandoned.store(true, std::memory_order_release);
464 }
465 else
466 {
467 // The writer was joined, so nothing reads its state any more. local_logger is the external
468 // strong owner that makes breaking the root safe: without it the reset below would drop the
469 // last reference and destroy the object from inside its own member function.
470 289 local_logger->release_retention_root();
471 }
472 }
473 }
474 377 }
475
476 #if defined(DMK_ENABLE_TEST_SEAMS)
477 // Test-only probe: fires in the dropped-mutex window opened above (m_async_mode_enabled is now false but the
478 // sink stream is still open), so a fixture can prove enable_async_mode()'s m_shutdown_called gate refuses to
479 // resurrect async logging in exactly this gap. Null and branch-only in production.
480
2/2
✓ Branch 29 → 30 taken 2 times.
✓ Branch 29 → 31 taken 375 times.
377 if (auto *gap_probe = detail::g_logger_shutdown_gap_probe)
481 {
482 2 gap_probe();
483 }
484 #endif
485
486 // A configure call that ran inside the gap above must not leave the facade live after this shutdown continues.
487 377 m_shutdown_called.store(true, std::memory_order_release);
488
489 // A detached writer still owns the AsyncLogger state and final sink access. Drop this frame's handle without
490 // breaking the writer's retention root and return without sink I/O. Keying on the recorded detach outcome
491 // avoids a loader-lock re-query race.
492
2/2
✓ Branch 32 → 33 taken 14 times.
✓ Branch 32 → 35 taken 363 times.
377 if (writer_detached)
493 {
494 14 abandon_detached_async_logger(local_logger);
495 14 return;
496 }
497
498 {
499 // Normal path: the writer was joined (or async was never enabled), so this thread is the single owner of
500 // final sink access. Acquire both mutexes to prevent configure()/reconfigure() from opening a new stream in
501 // the gap after the async-logger teardown block above releases m_async_mutex.
502 363 std::scoped_lock lock(m_async_mutex, *m_log_mutex_ptr);
503 363 m_shutdown_called.store(true, std::memory_order_release);
504
5/6
✓ Branch 39 → 40 taken 363 times.
✗ Branch 39 → 44 not taken.
✓ Branch 42 → 43 taken 362 times.
✓ Branch 42 → 44 taken 1 time.
✓ Branch 45 → 46 taken 362 times.
✓ Branch 45 → 50 taken 1 time.
363 if (m_log_file_stream_ptr && m_log_file_stream_ptr->is_open())
505 {
506 362 m_log_file_stream_ptr->flush();
507 362 m_log_file_stream_ptr->close();
508 }
509 363 }
510
2/2
✓ Branch 53 → 54 taken 363 times.
✓ Branch 53 → 56 taken 14 times.
377 }
511
512 170 void Logger::set_log_level(LogLevel level)
513 {
514 // LogLevel's fixed unsigned base makes a negative value unrepresentable, so the upper bound is the whole
515 // domain check: an out-of-range cast arrives as a value above Error. LoggerTest.SetLogLevel_InvalidLevel pins
516 // the rejection at 5, 99, and the top of the base.
517 170 auto level_int = static_cast<std::underlying_type_t<LogLevel>>(level);
518
2/2
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 5 taken 167 times.
170 if (level_int > static_cast<std::underlying_type_t<LogLevel>>(LogLevel::Error))
519 {
520
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 22 not taken.
3 log(LogLevel::Warning,
521 "Attempted to set an invalid log level value ({}). Keeping current level.",
522 level_int);
523 34 return;
524 }
525
526 167 auto old_level = m_current_log_level.load(std::memory_order_acquire);
527 #if defined(DMK_ENABLE_TEST_SEAMS)
528
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 164 times.
168 if (const auto transition_probe = detail::g_logger_level_transition_probe)
529 {
530 4 transition_probe();
531 }
532 #endif
533
534 for (;;)
535 {
536
2/2
✓ Branch 9 → 10 taken 31 times.
✓ Branch 9 → 11 taken 139 times.
170 if (old_level == level)
537 {
538 31 return;
539 }
540
2/2
✓ Branch 12 → 13 taken 137 times.
✓ Branch 12 → 14 taken 2 times.
139 if (m_current_log_level
541 139 .compare_exchange_weak(old_level, level, std::memory_order_acq_rel, std::memory_order_acquire))
542 {
543 137 break;
544 }
545 }
546
547 // The new threshold precedes this record. The ordinary predicate discards the record after an upward change.
548 (void)format_located(
549
1/2
✓ Branch 18 → 19 taken 137 times.
✗ Branch 18 → 23 not taken.
274 [this](std::string_view rendered) { return this->emit_record(LogLevel::Info, rendered); },
550 137 source_stamp_enabled(LogLevel::Info),
551 137 std::source_location::current(),
552 "Log level changed from {} to {}",
553 137 to_string(old_level),
554 274 to_string(level)
555 );
556 }
557
558 3653 bool Logger::log(LogLevel level, std::string_view message)
559 {
560
2/2
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 3665 times.
3653 if (level < m_current_log_level.load(std::memory_order_acquire))
561 {
562 6 return false;
563 }
564
565 3665 return emit_record(level, message);
566 }
567
568 3799 bool Logger::emit_record(LogLevel level, std::string_view message)
569 {
570 #if defined(DMK_ENABLE_TEST_SEAMS)
571
2/2
✓ Branch 2 → 3 taken 49 times.
✓ Branch 2 → 4 taken 3750 times.
3799 if (const auto record_probe = detail::g_logger_record_probe)
572 {
573 49 record_probe(level, message);
574 }
575 #endif
576
5/6
✓ Branch 5 → 6 taken 3674 times.
✓ Branch 5 → 8 taken 131 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 3676 times.
✓ Branch 10 → 11 taken 131 times.
✓ Branch 10 → 14 taken 3676 times.
7475 if (m_shutdown_called.load(std::memory_order_acquire) ||
577 3674 m_async_writer_abandoned.load(std::memory_order_acquire))
578 {
579 131 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
580 131 return false;
581 }
582
583 // An inert first-use logger (construction failed under OOM) owns no sink or shared sink mutex. Drop and count
584 // the record instead of dereferencing a null pointer; it stays inert for the process lifetime.
585
2/2
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 19 taken 3621 times.
3676 if (is_inert())
586 {
587 6 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
588 6 return false;
589 }
590
591 // Fast path: an atomic<bool> gate (a genuine lock-free read) selects async mode. The atomic<shared_ptr>
592 // snapshot that follows is correct and callback-safe but takes a bounded internal STL lock, not a lock-free
593 // read (see the m_async_logger member comment in logger.hpp), so it is not described as lock-free here.
594
2/2
✓ Branch 20 → 21 taken 1533 times.
✓ Branch 20 → 35 taken 2121 times.
3621 if (m_async_mode_enabled.load(std::memory_order_acquire))
595 {
596 1533 auto local_logger = m_async_logger.load(std::memory_order_acquire);
597
1/2
✓ Branch 23 → 24 taken 1599 times.
✗ Branch 23 → 29 not taken.
1599 if (local_logger)
598 {
599 #if defined(DMK_ENABLE_TEST_SEAMS)
600
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 26 taken 1598 times.
1599 if (auto *snapshot_probe = detail::g_logger_async_snapshot_probe)
601 {
602 1 snapshot_probe();
603 }
604 #endif
605 // The queue result exposes the true delivery status instead of unconditional success.
606 1599 return local_logger->enqueue_from_facade(level, message, m_dropped_messages);
607 }
608
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 34 taken 1586 times.
1596 }
609
610 2121 const auto level_str = to_string(level);
611
1/2
✓ Branch 37 → 38 taken 2124 times.
✗ Branch 37 → 111 not taken.
2124 std::lock_guard<std::mutex> lock(*m_log_mutex_ptr);
612
613 // Close the race where shutdown exchanges the async handle after the first check but before this thread reaches
614 // the synchronous sink. An admitted synchronous write finishes before shutdown can acquire this same mutex.
615
3/6
✓ Branch 39 → 40 taken 2124 times.
✗ Branch 39 → 42 not taken.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 2124 times.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 48 taken 2124 times.
4248 if (m_shutdown_called.load(std::memory_order_acquire) ||
616 2124 m_async_writer_abandoned.load(std::memory_order_acquire))
617 {
618 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
619 return false;
620 }
621
622
6/8
✓ Branch 50 → 51 taken 2120 times.
✓ Branch 50 → 55 taken 4 times.
✓ Branch 52 → 53 taken 2120 times.
✗ Branch 52 → 109 not taken.
✓ Branch 53 → 54 taken 2120 times.
✗ Branch 53 → 55 not taken.
✓ Branch 56 → 57 taken 2120 times.
✓ Branch 56 → 81 taken 4 times.
2124 if (m_log_file_stream_ptr->is_open() && m_log_file_stream_ptr->good())
623 {
624 4240 *m_log_file_stream_ptr << "[" << get_timestamp(m_timestamp_format) << "] "
625
11/20
✓ Branch 58 → 59 taken 2120 times.
✗ Branch 58 → 109 not taken.
✓ Branch 59 → 60 taken 2070 times.
✓ Branch 59 → 105 taken 50 times.
✓ Branch 60 → 61 taken 2070 times.
✗ Branch 60 → 103 not taken.
✓ Branch 61 → 62 taken 2070 times.
✗ Branch 61 → 103 not taken.
✓ Branch 62 → 63 taken 2070 times.
✗ Branch 62 → 103 not taken.
✓ Branch 65 → 66 taken 2070 times.
✗ Branch 65 → 103 not taken.
✓ Branch 66 → 67 taken 2070 times.
✗ Branch 66 → 103 not taken.
✓ Branch 67 → 68 taken 2070 times.
✗ Branch 67 → 103 not taken.
✓ Branch 68 → 69 taken 2070 times.
✗ Branch 68 → 103 not taken.
✓ Branch 69 → 70 taken 2070 times.
✗ Branch 69 → 103 not taken.
2120 << "[" << std::setw(7) << std::left << level_str << "] :: " << message << '\n';
626
627 // Flush on warnings/errors to ensure critical messages survive crashes
628
2/2
✓ Branch 71 → 72 taken 404 times.
✓ Branch 71 → 74 taken 1666 times.
2070 if (level >= LogLevel::Warning)
629 {
630
1/2
✓ Branch 73 → 74 taken 404 times.
✗ Branch 73 → 109 not taken.
404 m_log_file_stream_ptr->flush();
631 }
632
633 // Report whether the write (and any flush) left the stream healthy, so log_noexcept()/try_log() reflect
634 // actual delivery rather than just a no-throw call.
635
1/2
✓ Branch 75 → 76 taken 2070 times.
✗ Branch 75 → 109 not taken.
2070 const bool delivered = m_log_file_stream_ptr->good();
636
1/2
✗ Branch 76 → 77 not taken.
✓ Branch 76 → 80 taken 2070 times.
2070 if (!delivered)
637 {
638 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
639 }
640 2070 return delivered;
641 }
642
643
2/2
✓ Branch 81 → 82 taken 1 time.
✓ Branch 81 → 97 taken 3 times.
4 if (level >= LogLevel::Error)
644 {
645 1 std::cerr << "[" << m_log_prefix << " LOG_FILE_WRITE_ERROR] [" << get_timestamp(m_timestamp_format) << "] ["
646
11/22
✓ Branch 82 → 83 taken 1 time.
✗ Branch 82 → 109 not taken.
✓ Branch 83 → 84 taken 1 time.
✗ Branch 83 → 109 not taken.
✓ Branch 84 → 85 taken 1 time.
✗ Branch 84 → 109 not taken.
✓ Branch 85 → 86 taken 1 time.
✗ Branch 85 → 108 not taken.
✓ Branch 86 → 87 taken 1 time.
✗ Branch 86 → 106 not taken.
✓ Branch 87 → 88 taken 1 time.
✗ Branch 87 → 106 not taken.
✓ Branch 90 → 91 taken 1 time.
✗ Branch 90 → 106 not taken.
✓ Branch 91 → 92 taken 1 time.
✗ Branch 91 → 106 not taken.
✓ Branch 92 → 93 taken 1 time.
✗ Branch 92 → 106 not taken.
✓ Branch 93 → 94 taken 1 time.
✗ Branch 93 → 106 not taken.
✓ Branch 94 → 95 taken 1 time.
✗ Branch 94 → 106 not taken.
1 << std::setw(7) << std::left << level_str << "] :: " << message << '\n';
647 }
648
649 // The file sink was closed or unhealthy; the message was not delivered to it (an error-level message reached
650 // stderr only as a last resort). Count it so dropped_count() reflects the loss.
651 4 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
652 4 return false;
653 2124 }
654
655 1085 bool Logger::log_noexcept(LogLevel level, std::string_view message) noexcept
656 {
657 // The synchronous sink allocates as it formats a timestamp, and a custom stream can raise.
658 // This wrapper keeps the no-throw contract for noexcept-boundary callers. The bool is log()'s delivery status.
659 try
660 {
661
2/2
✓ Branch 2 → 3 taken 1035 times.
✓ Branch 2 → 5 taken 50 times.
1085 return log(level, message);
662 }
663 50 catch (...)
664 {
665 // log() counts every record it refuses and never throws after counting one, so this catch owns the
666 // records that were lost mid-write, which otherwise leave dropped_count() below the real loss.
667 50 m_dropped_messages.fetch_add(1, std::memory_order_relaxed);
668 50 return false;
669 50 }
670 }
671
672 4388 std::string Logger::get_timestamp(const std::string &format) const
673 {
674 try
675 {
676 4388 const auto now = std::chrono::system_clock::now();
677 4388 const auto in_time_t = std::chrono::system_clock::to_time_t(now);
678 4388 std::tm timeinfo_struct = {};
679
680 #if defined(_MSC_VER) || defined(__MINGW32__) || defined(__MINGW64__)
681 // MinGW's localtime_s takes the same (struct tm *, const time_t *) argument order as MSVC, not the ISO C11
682 // Annex K order, so the call is identical.
683
2/4
✓ Branch 8 → 9 taken 4388 times.
✗ Branch 8 → 43 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 4388 times.
4388 if (localtime_s(&timeinfo_struct, &in_time_t) != 0)
684 {
685 throw std::runtime_error("localtime_s failed to convert time.");
686 }
687 #else
688 if (localtime_r(&in_time_t, &timeinfo_struct) == nullptr)
689 {
690 throw std::runtime_error("localtime_r failed to convert time.");
691 }
692 #endif
693 // Single stack buffer for timestamp + milliseconds, no heap allocation
694 char buf[134];
695 4388 const size_t len = std::strftime(buf, sizeof(buf) - 5, format.c_str(), &timeinfo_struct);
696
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 20 taken 4388 times.
4388 if (len == 0)
697 {
698 return "TIMESTAMP_FORMAT_ERROR";
699 }
700
701
2/4
✓ Branch 21 → 22 taken 4388 times.
✗ Branch 21 → 37 not taken.
✓ Branch 22 → 23 taken 4388 times.
✗ Branch 22 → 37 not taken.
4388 const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
702
1/2
✓ Branch 24 → 25 taken 4388 times.
✗ Branch 24 → 43 not taken.
4388 const int ms_len = std::snprintf(buf + len, 5, ".%03d", static_cast<int>(ms.count()));
703
2/2
✓ Branch 27 → 28 taken 4335 times.
✓ Branch 27 → 40 taken 53 times.
8776 return std::string(buf, len + static_cast<size_t>(ms_len));
704 }
705
1/2
✓ Branch 44 → 45 taken 53 times.
✗ Branch 44 → 57 not taken.
53 catch (const std::exception &e)
706 {
707 106 std::cerr << "[" << m_log_prefix << " Logger TIMESTAMP_ERROR] Failed to generate timestamp: " << e.what()
708
5/10
✓ Branch 46 → 47 taken 53 times.
✗ Branch 46 → 70 not taken.
✓ Branch 47 → 48 taken 53 times.
✗ Branch 47 → 70 not taken.
✓ Branch 48 → 49 taken 53 times.
✗ Branch 48 → 70 not taken.
✓ Branch 50 → 51 taken 53 times.
✗ Branch 50 → 70 not taken.
✓ Branch 51 → 52 taken 53 times.
✗ Branch 51 → 70 not taken.
53 << '\n';
709
1/2
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 67 taken 53 times.
106 return "TIMESTAMP_GENERATION_ERROR";
710 53 }
711 catch (...)
712 {
713 std::cerr << "[" << m_log_prefix
714 << " Logger TIMESTAMP_ERROR] Unknown exception during timestamp generation." << '\n';
715 return "TIMESTAMP_GENERATION_ERROR";
716 }
717 }
718
719 1984 std::wstring Logger::generate_log_file_path(const std::string &file_name) const
720 {
721
2/2
✓ Branch 2 → 3 taken 1976 times.
✓ Branch 2 → 81 taken 8 times.
1984 std::filesystem::path log_file_path_obj(file_name);
722
2/2
✓ Branch 4 → 5 taken 552 times.
✓ Branch 4 → 7 taken 1424 times.
1976 if (log_file_path_obj.is_absolute())
723 {
724
2/2
✓ Branch 5 → 6 taken 550 times.
✓ Branch 5 → 79 taken 2 times.
552 return log_file_path_obj.wstring();
725 }
726
727 try
728 {
729
1/2
✓ Branch 7 → 8 taken 1424 times.
✗ Branch 7 → 51 not taken.
1424 std::wstring module_dir = filesystem::get_runtime_directory();
730
4/8
✓ Branch 9 → 10 taken 1424 times.
✗ Branch 9 → 12 not taken.
✓ Branch 10 → 11 taken 1424 times.
✗ Branch 10 → 49 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 1424 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 23 taken 1424 times.
1424 if (module_dir.empty() || module_dir == L".")
731 {
732 std::cerr << "[" << m_log_prefix << " Logger PATH_WARNING] "
733 << "Could not determine module directory. Using relative path: " << file_name << '\n';
734 return log_file_path_obj.wstring();
735 }
736
737
3/6
✓ Branch 23 → 24 taken 1424 times.
✗ Branch 23 → 43 not taken.
✓ Branch 24 → 25 taken 1424 times.
✗ Branch 24 → 40 not taken.
✓ Branch 25 → 26 taken 1424 times.
✗ Branch 25 → 38 not taken.
1424 const std::filesystem::path final_log_path = std::filesystem::path(module_dir) / file_name;
738
2/4
✓ Branch 28 → 29 taken 1424 times.
✗ Branch 28 → 46 not taken.
✓ Branch 29 → 30 taken 1424 times.
✗ Branch 29 → 44 not taken.
1424 return final_log_path.lexically_normal().wstring();
739 1424 }
740 catch (const std::exception &e)
741 {
742 std::cerr << "[" << m_log_prefix
743 << " Logger PATH_WARNING] Failed to determine module directory for log file: " << e.what()
744 << ". Using relative path for log file: " << file_name << '\n';
745 return log_file_path_obj.wstring();
746 }
747 catch (...)
748 {
749 std::cerr << "[" << m_log_prefix
750 << " Logger PATH_WARNING] Unknown exception while determining module directory for log file."
751 << " Using relative path: " << file_name << '\n';
752 return log_file_path_obj.wstring();
753 }
754 1976 }
755
756 4354 void Logger::enable_async_mode(const AsyncLoggerConfig &config) noexcept
757 {
758 // An inert first-use logger has no sink to feed a writer thread; async mode stays unavailable for its
759 // process-lifetime inert state.
760
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4354 times.
4354 if (is_inert())
761 {
762 4002 return;
763 }
764
765 4354 bool sink_closed = false;
766 4354 bool activation_failed = false;
767 4354 bool activated = false;
768 4354 std::size_t queue_cap = 0;
769 4354 std::size_t batch_sz = 0;
770
771 {
772 4354 std::lock_guard<std::mutex> lock(m_async_mutex);
773
774 // Refuse to resurrect async logging after shutdown. shutdown()/~Logger set m_shutdown_called before
775 // shutdown_internal() clears m_async_mode_enabled, and shutdown_internal() drops m_async_mutex between that
776 // clear and the final stream close. Checking the gate under this mutex closes that window: a concurrent
777 // enable could otherwise see async mode off and a still-open stream, then start a writer that outlives
778 // teardown. configure() re-clears the gate after a clean shutdown, so legitimate re-enable still works.
779
2/2
✓ Branch 7 → 8 taken 4001 times.
✓ Branch 7 → 9 taken 353 times.
4354 if (m_shutdown_called.load(std::memory_order_acquire))
780 {
781 4001 return;
782 }
783
784
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 353 times.
353 if (m_async_writer_abandoned.load(std::memory_order_acquire))
785 {
786 return;
787 }
788
789
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 352 times.
353 if (m_async_mode_enabled.load(std::memory_order_acquire))
790 {
791 1 return;
792 }
793
794
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 352 times.
352 if (!m_log_file_stream_ptr->is_open())
795 {
796 sink_closed = true;
797 }
798 else
799 {
800 try
801 {
802 // Override the config's timestamp format with the Logger's own so the async sink emits the same
803 // timestamps as the synchronous path; callers configure the format through the Logger, not the
804 // async config.
805
1/2
✓ Branch 19 → 20 taken 352 times.
✗ Branch 19 → 64 not taken.
352 AsyncLoggerConfig effective_config = config;
806
1/2
✓ Branch 20 → 21 taken 352 times.
✗ Branch 20 → 62 not taken.
352 effective_config.timestamp_format = m_timestamp_format;
807 auto writer =
808
2/2
✓ Branch 21 → 22 taken 350 times.
✓ Branch 21 → 62 taken 2 times.
352 std::make_shared<AsyncLogger>(effective_config, m_log_file_stream_ptr, m_log_mutex_ptr);
809
810 // Arm the writer's retention root here: after make_shared has established shared ownership (so the
811 // stored copy is a reference into an existing control block, not a new allocation) and before
812 // publication (so the storage a loader-lock detach would need already exists the instant a detach
813 // becomes possible). Break it again on any path that does not publish. An unpublished writer has
814 // no owner left to break it later, so an unbroken root outlives the process for nothing.
815 350 writer->arm_retention_root(writer);
816 try
817 {
818 #if defined(DMK_ENABLE_TEST_SEAMS)
819
2/2
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 26 taken 347 times.
350 if (auto *publication_probe = detail::g_logger_publication_probe)
820 {
821
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 53 taken 3 times.
3 publication_probe();
822 }
823 #endif
824 347 m_async_logger.store(writer, std::memory_order_release);
825 347 m_async_mode_enabled.store(true, std::memory_order_release);
826 }
827 3 catch (...)
828 {
829 3 writer->release_retention_root();
830 3 throw;
831 3 }
832 347 activated = true;
833 347 queue_cap = config.queue_capacity;
834 347 batch_sz = config.batch_size;
835 355 }
836 5 catch (...)
837 {
838 // Every throw here occurs before publication. The writer never existed or released its retention
839 // root, so async mode stays off and synchronous delivery remains available.
840 5 activation_failed = true;
841 5 }
842 }
843
2/2
✓ Branch 35 → 36 taken 352 times.
✓ Branch 35 → 38 taken 4002 times.
4354 }
844
845 #if defined(DMK_ENABLE_TEST_SEAMS)
846 // This seam proves that a throw after publication cannot escape or disturb the published writer.
847
2/2
✓ Branch 37 → 39 taken 347 times.
✓ Branch 37 → 41 taken 5 times.
352 if (activated)
848 {
849
2/2
✓ Branch 39 → 40 taken 2 times.
✓ Branch 39 → 41 taken 345 times.
347 if (auto *post_publication_probe = detail::g_logger_post_publication_probe)
850 {
851 try
852 {
853
1/2
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 68 taken 2 times.
2 post_publication_probe();
854 }
855 2 catch (...)
856 {
857 2 }
858 }
859 }
860 #endif
861
862 // Diagnostics stay outside the mutex. Fixed text through try_log keeps the published path no-throw.
863
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 44 taken 352 times.
352 if (sink_closed)
864 {
865 (void)try_log(LogLevel::Error, "Cannot enable async mode: log file is not open.");
866 }
867
2/2
✓ Branch 44 → 45 taken 5 times.
✓ Branch 44 → 47 taken 347 times.
352 else if (activation_failed)
868 {
869 5 (void)try_log(LogLevel::Error, "Failed to enable async mode.");
870 }
871
1/2
✓ Branch 47 → 48 taken 347 times.
✗ Branch 47 → 50 not taken.
347 else if (activated)
872 {
873 347 (void)try_log(
874 LogLevel::Info,
875 "Async logging mode enabled. Queue capacity: {}, Batch size: {}",
876 queue_cap,
877 batch_sz
878 );
879 }
880 }
881
882 4132 void Logger::enable_async_mode() noexcept
883 {
884 4132 enable_async_mode(AsyncLoggerConfig{});
885 4132 }
886
887 41 void Logger::disable_async_mode() noexcept
888 {
889 41 std::shared_ptr<AsyncLogger> local_async;
890 41 bool writer_detached = false;
891 41 bool should_log = false;
892
893 {
894 41 std::lock_guard<std::mutex> lock(m_async_mutex);
895
896
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 39 times.
41 if (!m_async_mode_enabled.load(std::memory_order_acquire))
897 {
898 2 return;
899 }
900
901 39 local_async = m_async_logger.exchange(nullptr, std::memory_order_acq_rel);
902
1/2
✓ Branch 12 → 13 taken 39 times.
✗ Branch 12 → 26 not taken.
39 if (local_async)
903 {
904 39 local_async->shutdown();
905 39 m_dropped_messages.fetch_add(local_async->dropped_count(), std::memory_order_relaxed);
906 39 writer_detached = local_async->writer_was_detached();
907
2/2
✓ Branch 21 → 22 taken 12 times.
✓ Branch 21 → 24 taken 27 times.
39 if (writer_detached)
908 {
909 // A detached writer retains final sink ownership. Make every facade operation fail closed rather
910 // than switching to a second synchronous writer on the same sink.
911 12 m_async_writer_abandoned.store(true, std::memory_order_release);
912 12 m_shutdown_called.store(true, std::memory_order_release);
913 }
914 else
915 {
916 // Joined: local_async is the external strong owner that makes breaking the root safe.
917 27 local_async->release_retention_root();
918 }
919 }
920
921 39 m_async_mode_enabled.store(false, std::memory_order_release);
922 39 should_log = true;
923
2/2
✓ Branch 29 → 30 taken 39 times.
✓ Branch 29 → 32 taken 2 times.
41 }
924
925 // Mirror shutdown_internal's ownership rule: a detached writer keeps exclusive access to live AsyncLogger
926 // state, so leave every detached writer standing on the retention root it was published with. The outcome is
927 // recorded under the lifecycle mutex beside the decision it drove, so this teardown and the latches above
928 // cannot disagree about which writer they are describing.
929
2/2
✓ Branch 31 → 33 taken 12 times.
✓ Branch 31 → 34 taken 27 times.
39 if (writer_detached)
930 {
931 12 abandon_detached_async_logger(local_async);
932 }
933
934
3/4
✓ Branch 34 → 35 taken 39 times.
✗ Branch 34 → 39 not taken.
✓ Branch 35 → 36 taken 27 times.
✓ Branch 35 → 39 taken 12 times.
39 if (should_log && !writer_detached)
935 {
936 // disable_async_mode() is noexcept; the synchronous sink can allocate and throw while formatting this
937 // line. Fail closed: drop the diagnostic rather than letting an exception escape the mode switch.
938 try
939 {
940
1/2
✓ Branch 37 → 38 taken 27 times.
✗ Branch 37 → 46 not taken.
27 log(LogLevel::Info, "Async logging mode disabled. Switched to synchronous mode.");
941 }
942 catch (...)
943 {
944 }
945 }
946
2/2
✓ Branch 41 → 42 taken 39 times.
✓ Branch 41 → 44 taken 2 times.
41 }
947
948 214 bool Logger::is_async_mode_enabled() const noexcept
949 {
950 214 return m_async_mode_enabled.load(std::memory_order_acquire);
951 }
952
953 18 std::size_t Logger::dropped_count() const noexcept
954 {
955 18 std::size_t total = m_dropped_messages.load(std::memory_order_relaxed);
956
2/2
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 19 taken 15 times.
18 if (m_async_mode_enabled.load(std::memory_order_acquire))
957 {
958
1/2
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 17 not taken.
3 if (auto async_logger = m_async_logger.load(std::memory_order_acquire))
959 {
960 3 total += async_logger->dropped_count();
961 3 }
962 }
963 18 return total;
964 }
965
966 127 void Logger::flush() noexcept
967 {
968
5/6
✓ Branch 3 → 4 taken 126 times.
✓ Branch 3 → 6 taken 1 time.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 126 times.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 126 times.
127 if (is_inert() || m_async_writer_abandoned.load(std::memory_order_acquire))
969 {
970 10 return;
971 }
972
973
2/2
✓ Branch 11 → 12 taken 9 times.
✓ Branch 11 → 24 taken 117 times.
126 if (m_async_mode_enabled.load(std::memory_order_acquire))
974 {
975 9 auto local_logger = m_async_logger.load(std::memory_order_acquire);
976
1/2
✓ Branch 14 → 15 taken 9 times.
✗ Branch 14 → 18 not taken.
9 if (local_logger)
977 {
978 9 local_logger->flush();
979 9 return;
980 }
981
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 23 taken 9 times.
9 }
982
983 117 std::lock_guard<std::mutex> lock(*m_log_mutex_ptr);
984
1/2
✓ Branch 28 → 29 taken 117 times.
✗ Branch 28 → 31 not taken.
117 if (m_log_file_stream_ptr->is_open())
985 {
986 117 m_log_file_stream_ptr->flush();
987 }
988 117 }
989
990 10788 Logger &log() noexcept
991 {
992 // The process-default logger, created once and INTENTIONALLY never destroyed. A plain function-local static
993 // Logger would be reclaimed during CRT atexit teardown, so a later static destructor or a detached thread that
994 // logs after that point would touch freed storage. Holding the object behind a leaked pointer keeps it alive
995 // for the whole process; the pointer itself is a reachable static, so a leak sanitizer sees the allocation as
996 // still-reachable rather than leaked. shutdown() (invoked by the Session teardown) flushes and closes the sink
997 // explicitly, so the deliberate leak costs only the object's storage, never a lost flush. First-use
998 // construction can allocate: create_process_default() catches an out-of-memory failure and publishes an inert
999 // drop/count logger instead of letting the throw terminate this noexcept accessor.
1000
4/4
✓ Branch 2 → 3 taken 1541 times.
✓ Branch 2 → 7 taken 9247 times.
✓ Branch 4 → 5 taken 1531 times.
✓ Branch 4 → 7 taken 11 times.
10788 static Logger *const instance = Logger::create_process_default();
1001 10788 return *instance;
1002 }
1003
1004 1531 Logger *Logger::create_process_default() noexcept
1005 {
1006 // Prefer the full logger. If its construction throws (first-use OOM allocating the logger object, its sink,
1007 // or its mutex), publish a process-lifetime inert logger rather than letting the throw escape the noexcept
1008 // free log() and terminate the host. The inert logger allocates nothing, so this fallback is safe even while
1009 // allocation is still failing, and it latches for the process generation because this static runs once.
1010 try
1011 {
1012
4/8
✓ Branch 2 → 3 taken 1529 times.
✓ Branch 2 → 12 taken 2 times.
✓ Branch 3 → 4 taken 1529 times.
✗ Branch 3 → 9 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1529 times.
✗ Branch 9 → 10 not taken.
✗ Branch 9 → 11 not taken.
1531 return new Logger();
1013 }
1014 2 catch (...)
1015 {
1016 alignas(Logger) static unsigned char inert_storage[sizeof(Logger)];
1017
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 2 times.
2 return ::new (static_cast<void *>(inert_storage)) Logger(InertTag{});
1018 2 }
1019 }
1020
1021 } // namespace DetourModKit
1022