GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 75.0% 87 / 0 / 116
Functions: 100.0% 11 / 0 / 11
Branches: 46.9% 46 / 0 / 98

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