GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 89.2% 83 / 0 / 93
Functions: 96.6% 28 / 0 / 29
Branches: 71.4% 30 / 0 / 42

src/internal/input_binding_lifecycle.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP
2 #define DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP
3
4 /**
5 * @file input_binding_lifecycle.hpp
6 * @brief Per-registration callback admission state shared by an input binding's engine entries and teardown gate.
7 * @details The control block is allocated with the registration and keeps reshape-time retirement allocation-free.
8 * Staged callbacks use its generation and in-flight counters to synchronize with remove, clear, and rebind.
9 * Not installed.
10 */
11
12 #include "drain_backoff.hpp"
13
14 #include <array>
15 #include <atomic>
16 #include <chrono>
17 #include <cstddef>
18 #include <cstdint>
19 #include <memory>
20 #include <thread>
21 #include <utility>
22
23 namespace DetourModKit::detail
24 {
25 namespace input_callback_lifecycle
26 {
27 inline constexpr std::uint32_t ADMISSION_OPEN = 1U;
28 inline constexpr std::uint32_t DRAIN_PENDING = 1U << 1U;
29
30 inline std::atomic<std::uint32_t> s_state{ADMISSION_OPEN};
31 inline std::atomic<std::uint32_t> s_staged_count{0};
32 } // namespace input_callback_lifecycle
33
34 /// Returns whether process-wide staging admission is open.
35 3921 [[nodiscard]] inline bool input_callback_admission_open() noexcept
36 {
37 3922 return (input_callback_lifecycle::s_state.load(std::memory_order_seq_cst) &
38 3922 input_callback_lifecycle::ADMISSION_OPEN) != 0;
39 }
40
41 /**
42 * @brief Opens process-wide admission for staged input callback storage unless a drain remains unresolved.
43 * @return true when admission is open; false when a pending drain kept it closed.
44 */
45 359 [[nodiscard]] inline bool open_input_callback_admission() noexcept
46 {
47 359 std::uint32_t state = input_callback_lifecycle::s_state.load(std::memory_order_seq_cst);
48
1/2
✓ Branch 22 → 10 taken 359 times.
✗ Branch 22 → 23 not taken.
359 while ((state & input_callback_lifecycle::DRAIN_PENDING) == 0)
49 {
50
2/2
✓ Branch 10 → 11 taken 228 times.
✓ Branch 10 → 12 taken 131 times.
359 if ((state & input_callback_lifecycle::ADMISSION_OPEN) != 0)
51 {
52 228 return true;
53 }
54 131 const std::uint32_t desired = state | input_callback_lifecycle::ADMISSION_OPEN;
55
1/2
✓ Branch 20 → 21 taken 131 times.
✗ Branch 20 → 22 not taken.
131 if (input_callback_lifecycle::s_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst))
56 {
57 131 return true;
58 }
59 }
60 return false;
61 }
62
63 /// Closes process-wide admission for staged input callback storage.
64 inline void close_input_callback_admission() noexcept
65 {
66 input_callback_lifecycle::s_state.fetch_and(
67 ~input_callback_lifecycle::ADMISSION_OPEN,
68 std::memory_order_seq_cst
69 );
70 }
71
72 /// Marks input callback rundown as unresolved and atomically closes staging admission.
73 150 inline void mark_input_callback_drain_pending() noexcept
74 {
75 150 std::uint32_t state = input_callback_lifecycle::s_state.load(std::memory_order_seq_cst);
76 for (;;)
77 {
78 150 const std::uint32_t desired =
79 150 (state | input_callback_lifecycle::DRAIN_PENDING) & ~input_callback_lifecycle::ADMISSION_OPEN;
80
1/2
✓ Branch 18 → 19 taken 150 times.
✗ Branch 18 → 20 not taken.
150 if (input_callback_lifecycle::s_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst))
81 {
82 150 return;
83 }
84 }
85 }
86
87 /// Marks the current input callback rundown as complete.
88 156 inline void resolve_input_callback_drain() noexcept
89 {
90 input_callback_lifecycle::s_state.fetch_and(
91 ~input_callback_lifecycle::DRAIN_PENDING,
92 std::memory_order_seq_cst
93 );
94 156 }
95
96 /// Returns whether a failed or active callback rundown still needs completion.
97 3619 [[nodiscard]] inline bool input_callback_drain_pending() noexcept
98 {
99 3617 return (input_callback_lifecycle::s_state.load(std::memory_order_seq_cst) &
100 3617 input_callback_lifecycle::DRAIN_PENDING) != 0;
101 }
102
103 /// Returns the number of staged callback records whose callable storage is still alive.
104 362 [[nodiscard]] inline std::uint32_t staged_input_callback_count() noexcept
105 {
106 362 return input_callback_lifecycle::s_staged_count.load(std::memory_order_seq_cst);
107 }
108
109 /**
110 * @brief Converts a caller timeout into an absolute rundown deadline, saturating instead of overflowing.
111 * @details One owner for the clamp so the input and config halves of an unload transaction cannot drift apart on
112 * it. A non-positive timeout yields "now" (poll once, never wait), and a timeout that would run past the
113 * clock's range yields time_point::max() rather than wrapping into an already-expired deadline.
114 */
115 [[nodiscard]] inline std::chrono::steady_clock::time_point
116 280 drain_deadline(std::chrono::milliseconds timeout) noexcept
117 {
118 280 const auto now = std::chrono::steady_clock::now();
119
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 280 times.
280 if (timeout <= std::chrono::milliseconds{0})
120 {
121 return now;
122 }
123 const auto remaining =
124 280 std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::time_point::max() - now);
125
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 280 times.
280 if (timeout >= remaining)
126 {
127 return std::chrono::steady_clock::time_point::max();
128 }
129 280 return now + std::chrono::duration_cast<std::chrono::steady_clock::duration>(timeout);
130 }
131
132 /**
133 * @brief Waits until every staged input callback record has been destroyed or @p deadline is reached.
134 * @return true when no staged record remains; false on timeout.
135 */
136 141 [[nodiscard]] inline bool await_staged_input_callbacks(std::chrono::steady_clock::time_point deadline) noexcept
137 {
138 499 return drain_until_zero([]() noexcept { return staged_input_callback_count(); }, deadline);
139 }
140
141 /**
142 * @brief Generation, tombstone, and in-flight counts for one input registration.
143 * @details Invocation admission uses increment-then-recheck with sequentially consistent atomics. A reshape first
144 * advances the generation or publishes the one-way tombstone, then drains in-flight callbacks. An advanced
145 * (surviving) registration drains only the retired parity slot so it never waits on live new-generation
146 * work; a tombstone drains BOTH slots, because it admits nothing further and a caller relies on the drain
147 * to see out an admit-across release edge that a prior advance may have left in the other parity slot.
148 * Presses and held(true) edges are refused across any advance, so only a benign, gate-serialized release
149 * edge can ever straddle two generations.
150 */
151 class BindingLifecycle
152 {
153 public:
154 1723 explicit BindingLifecycle(std::uint64_t initial_generation) noexcept : m_generation(initial_generation) {}
155
156 /// Returns the generation to carry with a staged callback.
157 7604 [[nodiscard]] std::uint64_t generation() const noexcept { return m_generation.load(std::memory_order_acquire); }
158
159 /// Returns whether removal permanently retired this registration.
160 5811 [[nodiscard]] bool tombstoned() const noexcept { return m_tombstoned.load(std::memory_order_acquire); }
161
162 /**
163 * @brief Advances to the next generation and returns the retired generation.
164 * @note Serialized by the poller's binding writer lock.
165 */
166 2011 [[nodiscard]] std::uint64_t advance_generation() noexcept
167 {
168 4022 return m_generation.fetch_add(1, std::memory_order_seq_cst);
169 }
170
171 /**
172 * @brief Permanently retires this registration and returns the generation to drain.
173 * @note Serialized by the poller's binding writer lock.
174 */
175 531 [[nodiscard]] std::uint64_t tombstone() noexcept
176 {
177 531 const std::uint64_t retired_generation = m_generation.load(std::memory_order_seq_cst);
178 531 m_tombstoned.store(true, std::memory_order_seq_cst);
179 531 return retired_generation;
180 }
181
182 /**
183 * @brief Attempts to admit a callback staged from @p expected_generation.
184 * @param admit_across_generation When true, admit even if the generation advanced since staging, provided the
185 * registration was not tombstoned. Set only for a terminal hold-release (false) edge: it can only end a
186 * held state and never fires a stale activation, so an in-place rebind that merely advanced the
187 * generation must still deliver it, or the gate's held count desyncs from the poller and the consumer
188 * is stranded holding a released binding. A tombstone (remove / clear) still refuses it, because that
189 * path publishes its own balancing false and must not race a post-return delivery against state the
190 * caller is destroying.
191 * @return true when the callback is counted and may begin; false when its registration was reshaped.
192 */
193 468 [[nodiscard]] bool try_enter(std::uint64_t expected_generation, bool admit_across_generation) noexcept
194 {
195
4/4
✓ Branch 3 → 4 taken 463 times.
✓ Branch 3 → 13 taken 5 times.
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 17 taken 462 times.
931 if (m_tombstoned.load(std::memory_order_acquire) ||
196
4/4
✓ Branch 4 → 5 taken 462 times.
✓ Branch 4 → 14 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 461 times.
925 (!admit_across_generation && m_generation.load(std::memory_order_acquire) != expected_generation))
197 {
198 6 return false;
199 }
200
201 462 auto &counter = m_in_flight[slot(expected_generation)];
202 462 counter.fetch_add(1, std::memory_order_seq_cst);
203
2/4
✓ Branch 22 → 23 taken 462 times.
✗ Branch 22 → 32 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 38 taken 462 times.
924 if (m_tombstoned.load(std::memory_order_seq_cst) ||
204
3/4
✓ Branch 23 → 24 taken 461 times.
✓ Branch 23 → 33 taken 1 time.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 461 times.
923 (!admit_across_generation && m_generation.load(std::memory_order_seq_cst) != expected_generation))
205 {
206 counter.fetch_sub(1, std::memory_order_seq_cst);
207 return false;
208 }
209 462 return true;
210 }
211
212 /// Releases one callback admitted for @p entered_generation.
213 462 void leave(std::uint64_t entered_generation) noexcept
214 {
215 462 m_in_flight[slot(entered_generation)].fetch_sub(1, std::memory_order_seq_cst);
216 462 }
217
218 /// Returns callbacks still running from @p retired_generation.
219 2076 [[nodiscard]] std::uint32_t in_flight(std::uint64_t retired_generation) const noexcept
220 {
221 4152 return m_in_flight[slot(retired_generation)].load(std::memory_order_seq_cst);
222 }
223
224 /**
225 * @brief Returns callbacks still running from either generation slot.
226 * @details A tombstone drains on this so an admit-across release edge left in a prior advance's parity slot
227 * cannot outlive the reshape that retired the binding.
228 */
229 416 [[nodiscard]] std::uint32_t in_flight_total() const noexcept
230 {
231 1248 return m_in_flight[0].load(std::memory_order_seq_cst) + m_in_flight[1].load(std::memory_order_seq_cst);
232 }
233
234 private:
235 3000 [[nodiscard]] static constexpr std::size_t slot(std::uint64_t generation) noexcept
236 {
237 3000 return static_cast<std::size_t>(generation & 1U);
238 }
239
240 std::atomic<std::uint64_t> m_generation;
241 std::atomic<bool> m_tombstoned{false};
242 std::array<std::atomic<std::uint32_t>, 2> m_in_flight{};
243 };
244
245 /**
246 * @brief RAII lease spanning a staged callback's callable copies, dispatch, and destruction.
247 * @details Counts process-wide staged callable storage so an unload drain can wait, to its own deadline, until
248 * every copied callable and capture manager is gone. The lease must be declared before callable-storage
249 * members so reverse member destruction releases it only after that storage has been destroyed.
250 *
251 * It deliberately does NOT hold a BindingLifecycle in-flight slot. That slot is the per-registration
252 * reshape rundown, which waits with no deadline; pinning it from staging until the whole poll cycle's
253 * staged storage is destroyed would make one binding's reshape or removal block on every other binding
254 * dispatched in the same cycle, and a control thread holding a lock one of those callbacks wants would
255 * deadlock. BindingInvocation scopes that slot to the callback body instead.
256 */
257 class StagedCallbackLease
258 {
259 public:
260 472 StagedCallbackLease(std::shared_ptr<BindingLifecycle> lifecycle, std::uint64_t staged_generation) noexcept
261 944 : m_lifecycle(std::move(lifecycle)), m_generation(staged_generation)
262 {
263
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 472 times.
472 if (!input_callback_admission_open())
264 {
265 return;
266 }
267
268 input_callback_lifecycle::s_staged_count.fetch_add(1, std::memory_order_seq_cst);
269
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 15 taken 472 times.
472 if (!input_callback_admission_open())
270 {
271 input_callback_lifecycle::s_staged_count.fetch_sub(1, std::memory_order_seq_cst);
272 return;
273 }
274 472 m_engaged = true;
275 }
276
277 1410 ~StagedCallbackLease() noexcept
278 {
279
2/2
✓ Branch 2 → 3 taken 472 times.
✓ Branch 2 → 6 taken 938 times.
1410 if (m_engaged)
280 {
281 input_callback_lifecycle::s_staged_count.fetch_sub(1, std::memory_order_seq_cst);
282 }
283 1410 }
284
285 StagedCallbackLease(const StagedCallbackLease &) = delete;
286 StagedCallbackLease &operator=(const StagedCallbackLease &) = delete;
287 StagedCallbackLease &operator=(StagedCallbackLease &&) = delete;
288
289 // Move construction is what lets a lease reach the staged record it guards, both into the record and through a
290 // vector reallocation of the poll cycle's staged storage.
291 938 StagedCallbackLease(StagedCallbackLease &&other) noexcept
292 1876 : m_lifecycle(std::move(other.m_lifecycle)), m_generation(other.m_generation),
293 938 m_engaged(std::exchange(other.m_engaged, false))
294 {
295 938 }
296
297 /// Returns whether the callback was admitted into staged storage.
298 472 [[nodiscard]] bool engaged() const noexcept { return m_engaged; }
299
300 /// The registration this callback was staged from, for the dispatch-time BindingInvocation.
301 468 [[nodiscard]] BindingLifecycle *lifecycle() const noexcept { return m_lifecycle.get(); }
302
303 /// The generation this callback was staged at.
304 468 [[nodiscard]] std::uint64_t generation() const noexcept { return m_generation; }
305
306 private:
307 std::shared_ptr<BindingLifecycle> m_lifecycle;
308 std::uint64_t m_generation{0};
309 bool m_engaged{false};
310 };
311
312 /**
313 * @brief RAII admission for one staged callback's dispatch.
314 * @details Holds the registration's in-flight slot for exactly the callback body, so a reshape or removal of this
315 * binding waits only on its own callback and never on an unrelated binding dispatched in the same cycle.
316 */
317 class BindingInvocation
318 {
319 public:
320 468 BindingInvocation(
321 BindingLifecycle *lifecycle,
322 std::uint64_t staged_generation,
323 bool admit_across_generation
324 ) noexcept
325 468 : m_lifecycle(lifecycle), m_generation(staged_generation),
326
3/4
✓ Branch 2 → 3 taken 468 times.
✗ Branch 2 → 5 not taken.
✓ Branch 4 → 5 taken 462 times.
✓ Branch 4 → 6 taken 6 times.
468 m_admitted(lifecycle == nullptr || lifecycle->try_enter(staged_generation, admit_across_generation))
327 {
328 468 }
329
330 468 ~BindingInvocation() noexcept
331 {
332
3/4
✓ Branch 2 → 3 taken 468 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 462 times.
✓ Branch 3 → 5 taken 6 times.
468 if (m_lifecycle != nullptr && m_admitted)
333 {
334 462 m_lifecycle->leave(m_generation);
335 }
336 468 }
337
338 BindingInvocation(const BindingInvocation &) = delete;
339 BindingInvocation &operator=(const BindingInvocation &) = delete;
340 BindingInvocation(BindingInvocation &&) = delete;
341 BindingInvocation &operator=(BindingInvocation &&) = delete;
342
343 /// Returns whether this callback may begin.
344 468 [[nodiscard]] bool admitted() const noexcept { return m_admitted; }
345
346 private:
347 BindingLifecycle *m_lifecycle;
348 std::uint64_t m_generation;
349 bool m_admitted;
350 };
351 } // namespace DetourModKit::detail
352
353 #endif // DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP
354