./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
100 / 127
78.7%
Branches:
68 / 116
58.6%
Functions:
12 / 12
100.0%

src/internal/lifecycle_reaper.cpp
Line Branch Exec Source
1 /**
2 * @file internal/lifecycle_reaper.cpp
3 * @brief Process-lifetime reaper thread that joins self-retiring workers off their own thread.
4 */
5
6 #include "lifecycle_reaper.hpp"
7
8 #include "DetourModKit/diagnostics.hpp"
9 #include "diagnostics_population.hpp"
10 #include "platform.hpp"
11
12 #include <atomic>
13 #include <condition_variable>
14 #include <list>
15 #include <mutex>
16 #include <new>
17 #include <thread>
18 #include <utility>
19
20 namespace DetourModKit::detail
21 {
22 namespace
23 {
24 // Exactly one parcel form is populated: a worker thread plus module reference, an erased owner, or a
25 // shared-owner reference to drop. Both owner forms carry their rundown callback.
26 struct Parcel
27 {
28 std::unique_ptr<std::jthread> thread;
29 std::shared_ptr<void> shared_owner;
30 OwnerRetire retire{nullptr};
31 void *module_ref{nullptr};
32 // The deferred release must decrement the reason that the module_ref acquire booked.
33 DetourModKit::diagnostics::ModulePinReason ref_reason{DetourModKit::diagnostics::ModulePinReason::Worker};
34 void *owner{nullptr};
35 void (*destroy)(void *) noexcept {nullptr};
36
37 12 void clear() noexcept
38 {
39 12 thread.reset();
40 12 shared_owner.reset();
41 12 retire = nullptr;
42 12 module_ref = nullptr;
43 12 ref_reason = DetourModKit::diagnostics::ModulePinReason::Worker;
44 12 owner = nullptr;
45 12 destroy = nullptr;
46 12 }
47 };
48
49 // Queue nodes reserved while allocation still works, so a retirement requested under host OOM (the case the
50 // whole facility exists for) still reaches the reaper. Recycled nodes return to the reserve, so the depth
51 // bounds concurrent in-flight retirements, not total ones.
52 constexpr std::size_t RESERVED_PARCELS = 8;
53
54 // Process-lifetime, never destroyed. Leaking the heap cell keeps the reaper thread out of a
55 // static-destruction join under the loader lock, and lets a background thread run library code
56 // (owner destructors) safely against a held module reference.
57 class Reaper
58 {
59 public:
60 // Not noexcept: the list members allocate on the MSVC STL, and reaper_instance contains that throw.
61 16 Reaper()
62 16 {
63 // A permanent worker requires a permanent module reference before its code can run.
64 const HMODULE self_ref =
65 16 acquire_module_ref(DetourModKit::diagnostics::ModulePinReason::LifecycleReaper);
66
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 16 times.
16 if (self_ref == nullptr)
67 {
68 ✗ return;
69 }
70 try
71 {
72
2/2
✓ Branch 15 → 13 taken 121 times.
✓ Branch 15 → 16 taken 15 times.
136 for (std::size_t i = 0; i < RESERVED_PARCELS; ++i)
73 {
74
2/2
✓ Branch 13 → 14 taken 120 times.
✓ Branch 13 → 24 taken 1 time.
121 m_free.emplace_back();
75 }
76
1/2
✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 22 not taken.
30 m_thread = std::thread([this]() noexcept { run(); });
77 // The reaper has process lifetime, so its matching module reference intentionally does too: the
78 // success path never releases self_ref. Both facts are observable through
79 // diagnostics::lifecycle_counters.
80 15 lifecycle_observability::record_reaper_started();
81 15 m_accepting.store(true, std::memory_order_release);
82 }
83 1 catch (...)
84 {
85 1 release_module_ref(self_ref, DetourModKit::diagnostics::ModulePinReason::LifecycleReaper);
86
1/2
✓ Branch 27 → 21 taken 1 time.
✗ Branch 27 → 28 not taken.
1 }
87 ✗ }
88
89 Reaper(const Reaper &) = delete;
90 Reaper &operator=(const Reaper &) = delete;
91
92 // A failed enqueue leaves the parcel untouched so its caller can take the retain-and-detach path.
93 19 [[nodiscard]] bool enqueue(Parcel &&parcel) noexcept
94 {
95
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 17 times.
19 if (!m_accepting.load(std::memory_order_acquire))
96 {
97 2 return false;
98 }
99 try
100 {
101 {
102
1/2
✓ Branch 5 → 6 taken 17 times.
✗ Branch 5 → 34 not taken.
17 std::lock_guard<std::mutex> lock(m_mutex);
103
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 17 times.
17 if (!m_accepting.load(std::memory_order_relaxed))
104 {
105 ✗ return false;
106 }
107
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 17 times.
17 if (m_free.empty())
108 {
109
0/2
✗ Branch 13 → 24 not taken.
✗ Branch 13 → 32 not taken.
✗ m_queue.push_back(std::move(parcel));
110 }
111 else
112 {
113 // Splicing a reserved node cannot allocate, which is what lets a retirement requested
114 // while every allocation fails still be queued.
115 17 m_free.front() = std::move(parcel);
116 17 m_queue.splice(m_queue.end(), m_free, m_free.begin());
117 }
118
1/2
✓ Branch 26 → 27 taken 17 times.
✗ Branch 26 → 30 not taken.
17 }
119 17 m_cv.notify_one();
120 17 return true;
121 }
122 ✗ catch (...)
123 {
124 // A self-retiring owner is queued immediately after its worker. Permanently stop accepting
125 // after one failure so that a detached worker can never be followed by queued owner destruction.
126 ✗ m_accepting.store(false, std::memory_order_release);
127 ✗ return false;
128 ✗ }
129 }
130
131 private:
132 15 void run() noexcept
133 {
134 for (;;)
135 {
136 32 std::list<Parcel>::iterator parcel;
137 {
138 32 std::unique_lock<std::mutex> lock(m_mutex);
139 64 m_cv.wait(lock, [this] { return !m_queue.empty(); });
140 17 parcel = m_queue.begin();
141 17 }
142 // An owner rundown can block on its worker join, so it must not hold the queue mutex.
143 17 const bool retired = process(*parcel);
144 {
145 17 std::lock_guard<std::mutex> lock(m_mutex);
146
2/2
✓ Branch 11 → 12 taken 12 times.
✓ Branch 11 → 19 taken 5 times.
17 if (retired)
147 {
148 // Recycle rather than erase so the allocation-free reserve refills itself.
149 12 parcel->clear();
150 12 m_free.splice(m_free.end(), m_queue, parcel);
151 }
152 else
153 {
154 // Splice reuses the queue node and cannot allocate. This is where a retirement that could
155 // not be completed stays reachable: a still-joinable thread whose std::jthread destructor
156 // would otherwise terminate the host, or an owner whose rundown the callback refused and
157 // which therefore must not be released. The count is unbounded by design, which is why it
158 // is observable through diagnostics::lifecycle_counters.
159 5 m_abandoned.splice(m_abandoned.end(), m_queue, parcel);
160 5 lifecycle_observability::record_abandoned_owner();
161 }
162 17 }
163 17 }
164 }
165
166 17 [[nodiscard]] static bool process(Parcel &parcel) noexcept
167 {
168
6/6
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 5 taken 5 times.
✓ Branch 4 → 5 taken 10 times.
✓ Branch 4 → 6 taken 2 times.
✓ Branch 7 → 8 taken 15 times.
✓ Branch 7 → 23 taken 2 times.
17 if (parcel.destroy != nullptr || parcel.shared_owner)
169 {
170 // Complete the worker rundown while the owner is alive. A destructor that starts before the join
171 // lets the worker body access an object whose lifetime ended. reap_owner and reap_shared_owner both
172 // refuse a null callback, and this check retains any parcel without one.
173
2/2
✓ Branch 8 → 9 taken 5 times.
✓ Branch 8 → 10 taken 10 times.
15 void *const owner = parcel.destroy != nullptr ? parcel.owner : parcel.shared_owner.get();
174
5/6
✓ Branch 11 → 12 taken 15 times.
✗ Branch 11 → 14 not taken.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 15 taken 10 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 18 taken 10 times.
15 if (parcel.retire == nullptr || !parcel.retire(owner))
175 {
176 5 return false;
177 }
178
2/2
✓ Branch 18 → 19 taken 3 times.
✓ Branch 18 → 21 taken 7 times.
10 if (parcel.destroy != nullptr)
179 {
180 3 parcel.destroy(parcel.owner);
181 3 return true;
182 }
183 // Drop here, not in the recycle step. The last release can run the owner's destructor, and the
184 // recycle step holds the queue mutex.
185 7 parcel.shared_owner.reset();
186 7 return true;
187 }
188 try
189 {
190
3/6
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 29 not taken.
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 29 not taken.
✓ Branch 30 → 31 taken 2 times.
✗ Branch 30 → 33 not taken.
2 if (parcel.thread != nullptr && parcel.thread->joinable())
191 {
192
1/2
✓ Branch 32 → 33 taken 2 times.
✗ Branch 32 → 37 not taken.
2 parcel.thread->join();
193 }
194
1/2
✓ Branch 33 → 34 taken 2 times.
✗ Branch 33 → 35 not taken.
2 if (parcel.module_ref != nullptr)
195 {
196 2 release_module_ref(static_cast<HMODULE>(parcel.module_ref), parcel.ref_reason);
197 }
198 2 return true;
199 }
200 ✗ catch (...)
201 {
202 // Completion is uncertain. Detach if possible and retain the module reference so no running
203 // instruction can outlive its code pages.
204 try
205 {
206
0/6
✗ Branch 40 → 41 not taken.
✗ Branch 40 → 45 not taken.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
✗ Branch 46 → 47 not taken.
✗ Branch 46 → 49 not taken.
✗ if (parcel.thread != nullptr && parcel.thread->joinable())
207 {
208
0/2
✗ Branch 48 → 49 not taken.
✗ Branch 48 → 59 not taken.
✗ parcel.thread->detach();
209 }
210 }
211 ✗ catch (...)
212 {
213 ✗ }
214 ✗ DetourModKit::diagnostics::record_intentional_leak(
215 DetourModKit::diagnostics::LeakSubsystem::Worker
216 );
217
0/4
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 55 not taken.
✗ Branch 54 → 55 not taken.
✗ Branch 54 → 56 not taken.
✗ return parcel.thread == nullptr || !parcel.thread->joinable();
218 ✗ }
219 }
220
221 std::mutex m_mutex;
222 std::condition_variable m_cv;
223 std::list<Parcel> m_queue;
224 std::list<Parcel> m_free;
225 std::list<Parcel> m_abandoned;
226 std::thread m_thread;
227 std::atomic<bool> m_accepting{false};
228 };
229
230 21 Reaper *reaper_instance() noexcept
231 {
232 // Static destruction cannot safely join this permanent worker under the loader lock. A first-use
233 // allocation failure latches a null reaper, so every caller takes its retain-and-detach path (`[B-91]`).
234 // Lifecycle.ReaperFirstUseOomFailsClosed pins the latch.
235 17 static Reaper *const s_reaper = []() noexcept -> Reaper *
236 {
237 try
238 {
239
6/12
✓ Branch 3 → 4 taken 16 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 4 → 5 taken 16 times.
✗ Branch 4 → 12 not taken.
✓ Branch 7 → 8 taken 16 times.
✓ Branch 7 → 10 taken 1 time.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 16 times.
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 15 not taken.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
17 return new (std::nothrow) Reaper();
240 }
241 ✗ catch (...)
242 {
243 ✗ return nullptr;
244 ✗ }
245
3/4
✓ Branch 2 → 3 taken 17 times.
✓ Branch 2 → 8 taken 4 times.
✓ Branch 4 → 5 taken 17 times.
✗ Branch 4 → 8 not taken.
21 }();
246 21 return s_reaper;
247 }
248 } // namespace
249
250 6 void reap_worker_thread(
251 std::unique_ptr<std::jthread> thread,
252 void *module_ref,
253 DetourModKit::diagnostics::ModulePinReason ref_reason
254 ) noexcept
255 {
256 6 Parcel parcel;
257 6 parcel.thread = std::move(thread);
258 6 parcel.module_ref = module_ref;
259 6 parcel.ref_reason = ref_reason;
260
261
6/6
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 12 taken 2 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 2 times.
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 15 taken 4 times.
10 if (Reaper *reaper = reaper_instance(); reaper != nullptr && reaper->enqueue(std::move(parcel)))
262 {
263 2 return;
264 }
265
266 // Retain the module reference if the running body cannot be queued for a safe off-thread join.
267 // enqueue consumes `parcel` only when it returns true; every false path above leaves it intact, and this
268 // fallback tolerates even a moved-from parcel (a null thread skips the detach below).
269 try
270 {
271 // NOLINTNEXTLINE(bugprone-use-after-move)
272
3/6
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 21 not taken.
✓ Branch 19 → 20 taken 4 times.
✗ Branch 19 → 21 not taken.
✓ Branch 22 → 23 taken 4 times.
✗ Branch 22 → 25 not taken.
4 if (parcel.thread != nullptr && parcel.thread->joinable())
273 {
274
1/2
✓ Branch 24 → 25 taken 4 times.
✗ Branch 24 → 33 not taken.
4 parcel.thread->detach();
275 }
276 }
277 ✗ catch (...)
278 {
279 // Retain a still-joinable jthread so its destructor cannot terminate the host.
280 ✗ (void)parcel.thread.release();
281 ✗ }
282 4 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Worker);
283
2/2
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 31 taken 2 times.
6 }
284
285 11 bool reap_shared_owner(std::shared_ptr<void> &owner, OwnerRetire retire) noexcept
286 {
287
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 11 times.
11 if (!owner)
288 {
289 ✗ return true;
290 }
291
292
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 10 times.
11 if (retire == nullptr)
293 {
294 // The reaper cannot run down a worker it has no callback for, and releasing the reference anyway would
295 // destroy an owner whose body may still be running. Refuse at the door so the caller's own retention takes
296 // over, rather than queuing a parcel that could only be abandoned.
297 1 return false;
298 }
299
300 10 Parcel parcel;
301 10 parcel.shared_owner = owner;
302 10 parcel.retire = retire;
303
304 10 Reaper *const reaper = reaper_instance();
305
3/6
✓ Branch 9 → 10 taken 10 times.
✗ Branch 9 → 15 not taken.
✓ Branch 13 → 14 taken 10 times.
✗ Branch 13 → 15 not taken.
✓ Branch 16 → 17 taken 10 times.
✗ Branch 16 → 19 not taken.
20 if (reaper != nullptr && reaper->enqueue(std::move(parcel)))
306 {
307 10 owner.reset();
308 10 return true;
309 }
310
311 // A failed enqueue leaves the parcel populated. Drop the reaper's copy (never the last one, since the caller
312 // still holds theirs) and report the failure so the caller can abandon its own into permanent storage.
313 // NOLINTNEXTLINE(bugprone-use-after-move)
314 ✗ parcel.shared_owner.reset();
315 ✗ return false;
316 10 }
317
318 5 void reaper_detail::reap_owner_erased(void *owner, OwnerRetire retire, void (*destroy)(void *) noexcept) noexcept
319 {
320
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 5 times.
5 if (owner == nullptr)
321 {
322 ✗ return;
323 }
324
325
1/2
✓ Branch 4 → 5 taken 5 times.
✗ Branch 4 → 21 not taken.
5 if (retire != nullptr)
326 {
327 5 Parcel parcel;
328 5 parcel.owner = owner;
329 5 parcel.retire = retire;
330 5 parcel.destroy = destroy;
331
332
3/6
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 12 not taken.
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 12 not taken.
✓ Branch 13 → 14 taken 5 times.
✗ Branch 13 → 15 not taken.
10 if (Reaper *reaper = reaper_instance(); reaper != nullptr && reaper->enqueue(std::move(parcel)))
333 {
334 5 return;
335 }
336
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 20 taken 5 times.
5 }
337
338 // Destruction on the caller's worker thread self-joins, and the reaper cannot run down an owner without a
339 // rundown callback. Either refusal deliberately retains the owner.
340 ✗ DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Worker);
341 }
342 } // namespace DetourModKit::detail
343