./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
111 / 118
94.1%
Branches:
62 / 100
62.0%
Functions:
16 / 17
94.1%

src/profiler.cpp
Line Branch Exec Source
1 /**
2 * @file profiler.cpp
3 * @brief Implementation of the lock-free ring buffer profiler with Chrome Tracing export.
4 */
5
6 #include "DetourModKit/profiler.hpp"
7
8 #include "internal/utf8_conversion.hpp"
9
10 #include <windows.h>
11 #include <cstdio>
12 #include <cstring>
13 #include <format>
14 #include <memory>
15 #include <new>
16 #include <string>
17 #include <string_view>
18
19 namespace DetourModKit
20 {
21 namespace
22 {
23 /**
24 * @brief Escapes @p input for use in a JSON value, without the outer quotes.
25 * @details Forward slash is left alone. RFC 8259 permits that form.
26 */
27 130734 std::string escape_json_string(std::string_view input)
28 {
29 130734 std::string out;
30
1/2
✓ Branch 4 → 5 taken 130734 times.
✗ Branch 4 → 33 not taken.
130734 out.reserve(input.size());
31
2/2
✓ Branch 29 → 7 taken 1684511 times.
✓ Branch 29 → 30 taken 130734 times.
1815245 for (const char c : input)
32 {
33
8/8
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 10 taken 1 time.
✓ Branch 7 → 12 taken 1 time.
✓ Branch 7 → 14 taken 1 time.
✓ Branch 7 → 16 taken 1 time.
✓ Branch 7 → 18 taken 1 time.
✓ Branch 7 → 20 taken 1 time.
✓ Branch 7 → 22 taken 1684504 times.
1684511 switch (c)
34 {
35 1 case '"':
36
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 33 not taken.
1 out += "\\\"";
37 1 break;
38 1 case '\\':
39
1/2
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 33 not taken.
1 out += "\\\\";
40 1 break;
41 1 case '\b':
42
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 33 not taken.
1 out += "\\b";
43 1 break;
44 1 case '\f':
45
1/2
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 33 not taken.
1 out += "\\f";
46 1 break;
47 1 case '\n':
48
1/2
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 33 not taken.
1 out += "\\n";
49 1 break;
50 1 case '\r':
51
1/2
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 33 not taken.
1 out += "\\r";
52 1 break;
53 1 case '\t':
54
1/2
✓ Branch 20 → 21 taken 1 time.
✗ Branch 20 → 33 not taken.
1 out += "\\t";
55 1 break;
56 1684504 default:
57
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 26 taken 1684503 times.
1684504 if (static_cast<unsigned char>(c) < 0x20)
58 {
59 char buf[8];
60 1 std::snprintf(
61 buf,
62 sizeof(buf),
63 "\\u%04x",
64
1/2
✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 32 not taken.
1 static_cast<unsigned int>(static_cast<unsigned char>(c))
65 );
66
1/2
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 32 not taken.
1 out += buf;
67 }
68 else
69 {
70
1/2
✓ Branch 26 → 27 taken 1684503 times.
✗ Branch 26 → 33 not taken.
1684503 out += c;
71 }
72 1684504 break;
73 }
74 }
75 130734 return out;
76 ✗ }
77 } // namespace
78
79 43 Profiler::Profiler() noexcept : m_ring(DEFAULT_CAPACITY)
80 {
81 LARGE_INTEGER freq;
82 // QueryPerformanceFrequency cannot fail and is always non-zero on Windows XP and later, but guard regardless: a
83 // zero frequency would make every conversion degenerate. Fall back to a 10 MHz tick so durations stay finite if
84 // the API ever misbehaves.
85
3/6
✓ Branch 4 → 5 taken 43 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 43 times.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 43 times.
✗ Branch 8 → 10 not taken.
43 if (QueryPerformanceFrequency(&freq) && freq.QuadPart > 0)
86 {
87 43 m_qpc_frequency = freq.QuadPart;
88 }
89 else
90 {
91 ✗ m_qpc_frequency = 10'000'000;
92 }
93 43 }
94
95 1836 Profiler &Profiler::get_instance() noexcept
96 {
97 // Constructed once into function-local static storage and never destroyed, mirroring StringPool::instance().
98 // A Meyers singleton (`static Profiler instance;`) registers a static destructor that frees the ring at
99 // static-teardown time. A ScopedProfile whose own destructor runs after that (a static or thread_local
100 // ScopedProfile, or one on a thread still alive at process teardown) then records through this accessor
101 // into freed storage. Placement-new into raw static storage keeps the object alive for the whole process; the
102 // single fixed-size ring is reclaimed by the OS at exit.
103 //
104 // Construction cannot throw: the ring allocates with nothrow new and reports capacity 0 if that fails, so the
105 // worst first-use outcome is a disabled profiler rather than a terminate out of this noexcept accessor.
106 alignas(Profiler) static unsigned char storage[sizeof(Profiler)];
107
5/6
✓ Branch 2 → 3 taken 46 times.
✓ Branch 2 → 10 taken 1790 times.
✓ Branch 4 → 5 taken 43 times.
✓ Branch 4 → 10 taken 3 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 43 times.
1836 static Profiler *const instance = ::new (static_cast<void *>(storage)) Profiler();
108 1849 return *instance;
109 }
110
111 369747 void Profiler::record(const char *name, int64_t start_ticks, int64_t end_ticks, uint32_t thread_id) noexcept
112 {
113
1/2
✓ Branch 2 → 3 taken 371241 times.
✗ Branch 2 → 4 not taken.
369747 record(name, name != nullptr ? std::strlen(name) : 0, start_ticks, end_ticks, thread_id);
114 380782 }
115
116 369431 void Profiler::record(
117 const char *name,
118 size_t name_length,
119 int64_t start_ticks,
120 int64_t end_ticks,
121 uint32_t thread_id
122 ) noexcept
123 {
124 369431 const auto claim = m_ring.claim();
125
2/2
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 380033 times.
380041 if (!claim.owned)
126 {
127 8 return;
128 }
129 380033 const size_t bounded_length = name_length < UINT32_MAX ? name_length : UINT32_MAX;
130 380033 m_ring.publish(
131 claim,
132 name,
133 static_cast<uint32_t>(bounded_length),
134 start_ticks,
135 detail::ticks_to_microseconds(start_ticks, end_ticks, m_qpc_frequency),
136 thread_id
137 );
138 }
139
140 // Caller must ensure no concurrent record() calls are in flight. There is no runtime guard because an atomic
141 // "recording active" counter would penalize every record() call on the hot path for a contract that only matters at
142 // session boundaries.
143 75 void Profiler::reset() noexcept
144 {
145 75 m_ring.reset();
146 75 }
147
148 32 std::string Profiler::export_chrome_json() const
149 {
150 32 std::string json;
151 32 const auto resident = static_cast<size_t>(m_ring.resident());
152
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 10 taken 26 times.
32 if (resident == 0)
153 {
154
1/2
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 24 not taken.
12 return "[]";
155 }
156
157 // ~120 bytes per JSON event is a reasonable estimate.
158
1/2
✓ Branch 10 → 11 taken 26 times.
✗ Branch 10 → 31 not taken.
26 json.reserve(resident * 120 + 4);
159
1/2
✓ Branch 11 → 12 taken 26 times.
✗ Branch 11 → 31 not taken.
26 json += "[\n";
160
161 26 const double ticks_to_us = 1'000'000.0 / static_cast<double>(m_qpc_frequency);
162 26 bool first = true;
163 26 m_ring.visit_committed(
164
1/2
✓ Branch 12 → 13 taken 26 times.
✗ Branch 12 → 27 not taken.
26 [&](const char *name, uint32_t name_length, int64_t start_ticks, uint32_t duration_us, uint32_t thread_id)
165 {
166
2/2
✓ Branch 2 → 3 taken 130708 times.
✓ Branch 2 → 4 taken 26 times.
130734 if (!first)
167 {
168
1/2
✓ Branch 3 → 4 taken 130708 times.
✗ Branch 3 → 19 not taken.
130708 json += ",\n";
169 }
170 130734 first = false;
171
172 // Chrome Trace Event Format: "X" = complete event (has duration). The name is escaped so a caller's
173 // quotes or backslashes still produce valid JSON. The published extent bounds the read, so a source
174 // array with no terminator cannot cause an over-read.
175 130734 const double ts = static_cast<double>(start_ticks) * ticks_to_us;
176
1/2
✓ Branch 6 → 7 taken 130734 times.
✗ Branch 6 → 13 not taken.
261468 json += std::format(
177 R"({{"name":"{}","ph":"X","ts":{:.1f},"dur":{},"pid":1,"tid":{}}})",
178
1/2
✓ Branch 5 → 6 taken 130734 times.
✗ Branch 5 → 16 not taken.
261468 escape_json_string(std::string_view{name, name_length}),
179 ts,
180 duration_us,
181 thread_id
182
1/2
✓ Branch 7 → 8 taken 130734 times.
✗ Branch 7 → 11 not taken.
130734 );
183 130734 }
184 );
185
186
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 19 taken 26 times.
26 if (first)
187 {
188
0/2
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 28 not taken.
✗ return "[]";
189 }
190
1/2
✓ Branch 19 → 20 taken 26 times.
✗ Branch 19 → 31 not taken.
26 json += "\n]";
191 26 return json;
192 32 }
193
194 7 bool Profiler::export_to_file(std::string_view path) const
195 {
196 // A narrow CRT open decodes through the ANSI code page, so the UTF-8 path opens in its wide form.
197
1/2
✓ Branch 2 → 3 taken 7 times.
✗ Branch 2 → 40 not taken.
7 const std::wstring wide_path = detail::widen_utf8(path);
198
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 6 times.
7 if (wide_path.empty())
199 {
200 1 return false;
201 }
202
1/2
✓ Branch 6 → 7 taken 6 times.
✗ Branch 6 → 38 not taken.
6 const std::string json = export_chrome_json();
203
204 ✗ const auto closer = [](std::FILE *f) { std::fclose(f); };
205 6 std::FILE *file_ptr = nullptr;
206
207
1/2
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 36 not taken.
6 const errno_t err = _wfopen_s(&file_ptr, wide_path.c_str(), L"wb");
208
3/4
✓ Branch 9 → 10 taken 5 times.
✓ Branch 9 → 11 taken 1 time.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 5 times.
6 if (err != 0 || file_ptr == nullptr)
209 {
210 1 return false;
211 }
212
213 5 std::unique_ptr<std::FILE, decltype(closer)> fp(file_ptr, closer);
214
1/2
✓ Branch 16 → 17 taken 5 times.
✗ Branch 16 → 34 not taken.
5 const size_t written = std::fwrite(json.data(), 1, json.size(), fp.get());
215
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 5 times.
5 if (written != json.size())
216 {
217 ✗ return false;
218 }
219
2/4
✓ Branch 21 → 22 taken 5 times.
✗ Branch 21 → 34 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 5 times.
5 if (std::fflush(fp.get()) != 0)
220 {
221 ✗ return false;
222 }
223 // Release the pointer so unique_ptr does not double-close.
224
2/4
✓ Branch 25 → 26 taken 5 times.
✗ Branch 25 → 34 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 5 times.
5 if (std::fclose(fp.release()) != 0)
225 {
226 ✗ return false;
227 }
228 5 return true;
229 7 }
230
231 36 size_t Profiler::total_samples_recorded() const noexcept
232 {
233 36 return static_cast<size_t>(m_ring.claims());
234 }
235
236 13 size_t Profiler::available_samples() const noexcept
237 {
238 13 return static_cast<size_t>(m_ring.resident());
239 }
240
241 6 size_t Profiler::dropped_samples() const noexcept
242 {
243 6 return static_cast<size_t>(m_ring.dropped());
244 }
245
246 12 size_t Profiler::capacity() const noexcept
247 {
248 12 return m_ring.capacity();
249 }
250
251 5 int64_t Profiler::qpc_frequency() const noexcept
252 {
253 5 return m_qpc_frequency;
254 }
255
256 1746 ScopedProfile::ScopedProfile(const char *name, size_t name_length, LiteralTag) noexcept
257 1746 : m_name(name), m_name_length(name_length), m_thread_id(GetCurrentThreadId())
258 {
259 LARGE_INTEGER ticks;
260 1744 QueryPerformanceCounter(&ticks);
261 1764 m_start_ticks = ticks.QuadPart;
262 1764 }
263
264 1742 ScopedProfile::~ScopedProfile() noexcept
265 {
266 LARGE_INTEGER ticks;
267 1742 QueryPerformanceCounter(&ticks);
268 1769 Profiler::get_instance().record(m_name, m_name_length, m_start_ticks, ticks.QuadPart, m_thread_id);
269 1761 }
270
271 } // namespace DetourModKit
272