GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 94.4% 237 / 0 / 251
Functions: 100.0% 33 / 0 / 33
Branches: 78.1% 189 / 0 / 242

src/internal/input_binding_gate.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_INPUT_BINDING_GATE_HPP
2 #define DETOURMODKIT_INTERNAL_INPUT_BINDING_GATE_HPP
3
4 /**
5 * @file input_binding_gate.hpp
6 * @brief Provides per-binding teardown gates for press and hold callbacks.
7 * @details Each gate serializes callback delivery against teardown. Consumer callbacks and capture destructors run
8 * outside the gate mutex.
9 *
10 * A control-plane release waits for every delivery and teardown span. Retirement uses a deadline and refuses
11 * if it cannot prove quiescence. A caller must hold no lock that consumer code can acquire. It must own no
12 * join that consumer code can await.
13 *
14 * A callback-side release never waits. It defers the final Hold edge to delivery unwind or emits that edge
15 * inline when no delivery exists.
16 *
17 * docs/design/input.md owns the delivery-scope and ABBA rationale. This private header keeps the
18 * synchronization unit-testable.
19 */
20
21 #include "internal/input_binding_lifecycle.hpp"
22 #include "internal/input_delivery_scope.hpp"
23
24 #include <atomic>
25 #include <chrono>
26 #include <condition_variable>
27 #include <cstddef>
28 #include <exception>
29 #include <functional>
30 #include <memory>
31 #include <mutex>
32 #include <utility>
33
34 namespace DetourModKit
35 {
36 namespace detail
37 {
38 /**
39 * @class GateCallbackSlot
40 * @brief Owns one gate callback behind a stable heap address.
41 * @details A std::function move can destroy an inline source target. Retirement moves only the pointer, so the
42 * mutex never covers consumer destruction.
43 */
44 template <typename Signature> class GateCallbackSlot;
45
46 template <typename Return, typename... Args> class GateCallbackSlot<Return(Args...)>
47 {
48 public:
49 using Function = std::function<Return(Args...)>;
50 using Owner = std::unique_ptr<Function>;
51
52 1702 GateCallbackSlot() noexcept = default;
53 1693 ~GateCallbackSlot() noexcept = default;
54
55 GateCallbackSlot(const GateCallbackSlot &) = delete;
56 GateCallbackSlot &operator=(const GateCallbackSlot &) = delete;
57 GateCallbackSlot(GateCallbackSlot &&) = delete;
58 GateCallbackSlot &operator=(GateCallbackSlot &&) = delete;
59
60 /// Installs a callback during unpublished gate setup and stores no owner for an empty callback.
61 1702 GateCallbackSlot &operator=(Function callback)
62 {
63 1702 Owner replacement;
64
3/4
DetourModKit::detail::GateCallbackSlot<void (bool)>::operator=(std::function<void (bool)>):
✓ Branch 3 → 4 taken 698 times.
✗ Branch 3 → 10 not taken.
DetourModKit::detail::GateCallbackSlot<void ()>::operator=(std::function<void ()>):
✓ Branch 3 → 4 taken 803 times.
✓ Branch 3 → 10 taken 201 times.
1702 if (callback)
65 {
66
2/4
DetourModKit::detail::GateCallbackSlot<void (bool)>::operator=(std::function<void (bool)>):
✓ Branch 6 → 7 taken 698 times.
✗ Branch 6 → 16 not taken.
DetourModKit::detail::GateCallbackSlot<void ()>::operator=(std::function<void ()>):
✓ Branch 6 → 7 taken 803 times.
✗ Branch 6 → 16 not taken.
1501 replacement = std::make_unique<Function>(std::move(callback));
67 }
68 1700 m_callback = std::move(replacement);
69 1701 return *this;
70 1701 }
71
72 /// Clears a callback during unpublished gate setup.
73 1 GateCallbackSlot &operator=(std::nullptr_t) noexcept
74 {
75 1 m_callback.reset();
76 1 return *this;
77 }
78
79 /// Returns true when this slot owns a callback.
80 22448 [[nodiscard]] explicit operator bool() const noexcept { return m_callback != nullptr; }
81
82 /// Invokes the owned callback.
83 44880 Return operator()(Args... args) const { return (*m_callback)(std::forward<Args>(args)...); }
84
85 #if defined(DMK_ENABLE_TEST_SEAMS)
86 /// Installs a test-only probe that runs immediately after ownership transfer.
87 2 void set_after_take_probe_for_test(void (*probe)(void *) noexcept, void *context) noexcept
88 {
89 2 m_after_take_probe_for_test = probe;
90 2 m_after_take_context_for_test = context;
91 2 }
92 #endif
93
94 /// Transfers callback ownership by pointer while the std::function target stays at one address.
95 151 [[nodiscard]] Owner take() noexcept
96 {
97 302 Owner callback = std::move(m_callback);
98 #if defined(DMK_ENABLE_TEST_SEAMS)
99
4/4
DetourModKit::detail::GateCallbackSlot<void (bool)>::take():
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 21 times.
DetourModKit::detail::GateCallbackSlot<void ()>::take():
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 128 times.
151 if (m_after_take_probe_for_test != nullptr)
100 {
101 2 m_after_take_probe_for_test(m_after_take_context_for_test);
102 }
103 #endif
104 151 return callback;
105 }
106
107 private:
108 Owner m_callback;
109 #if defined(DMK_ENABLE_TEST_SEAMS)
110 void (*m_after_take_probe_for_test)(void *) noexcept = nullptr;
111 void *m_after_take_context_for_test = nullptr;
112 #endif
113 };
114
115 /**
116 * @struct BindingGate
117 * @brief Type-erased handle a binding's engine entry keeps on the gate its wrappers dispatch through.
118 * @details A gate has two strong owners, the poller's exploded engine entries and the BindingGuard's one-shot
119 * release closure, and it owns the consumer callback. Dropping the engine entries therefore retires
120 * the binding without destroying the callback, which is safe for an ordinary reshape and unsafe
121 * before a Logic DLL is unmapped. This base gives the unload drain a way to reach the gate itself.
122 */
123 struct BindingGate
124 {
125 1693 virtual ~BindingGate() = default;
126
127 /**
128 * @brief Ends delivery, emits a still-held hold's balancing edge, and destroys the consumer callback.
129 * @param deadline Bound on the wait for an in-flight delivery to unwind.
130 * @return False when the deadline expired with a delivery still running; the callback is then retained,
131 * because destroying a callable a poll thread is executing would free code out from under it.
132 * @details Idempotent. Unlike @c release(), which leaves the callback in place for the binding's guard,
133 * this hands ownership of the callback to the calling thread and destroys it there, so no later
134 * guard release can reach it. Control-plane only: the wait would deadlock a caller that is
135 * itself inside a delivery.
136 */
137 [[nodiscard]] virtual bool retire(std::chrono::steady_clock::time_point deadline) = 0;
138 };
139
140 /**
141 * @struct HoldGate
142 * @brief Per-binding teardown gate shared between a hold binding's callback wrapper and its guard.
143 * @details A hold callback carries lingering state: the consumer is told true (held) until told false
144 * (released), so cancelling mid-hold must deliver exactly one balancing false and never let a stale
145 * true land after it. A multi-combo hold ("X = combo A | combo B") explodes into N engine entries that
146 * all share ONE gate; the gate reference-counts the held entries in @ref active_entries and forwards
147 * only the 0->1 and 1->0 crossings, so the consumer sees "held" for the whole span any combo is down.
148 *
149 * Deliveries arrive from the poll thread (edges) and from control threads (a reshape's synchronized
150 * released(false)); @ref lifecycle guards against a late true resurrecting a torn-down binding.
151 */
152 struct HoldGate : BindingGate
153 {
154 std::mutex mutex;
155 std::condition_variable idle_cv;
156 std::shared_ptr<std::atomic<bool>> enabled;
157 // Shared with the binding's engine entries. Its tombstone is the resurrection guard below; may be null for
158 // a gate not tied to an engine entry (never happens in the facade, tolerated for direct unit use).
159 std::shared_ptr<BindingLifecycle> lifecycle;
160 GateCallbackSlot<void(bool)> on_state_change;
161
162 // Exploded entries sharing this gate that are currently held; consumer-visible held state is (count > 0).
163 int active_entries = 0;
164 // A true edge was delivered to the user and not yet balanced by a false edge.
165 bool forwarded_active = false;
166 // The guard has torn the binding down; further edges are swallowed.
167 bool released = false;
168 // Callback invocations running outside the mutex, including a guard-release balancing edge.
169 int in_flight = 0;
170 // A release arrived while a delivery was in flight and could not block; the delivery emits the balancing
171 // false on its unwind.
172 bool deferred_final = false;
173 // A control-plane teardown (a guard release or the unload drain's retire) has marked the gate released but
174 // has not finished its consumer-code span: the release's balancing edge, or retirement's edge plus callable
175 // disposal. `in_flight` cannot express this on its own: the claimant drops the mutex to wait for deliveries
176 // to drain, so without a claim taken at the same moment as `released` a second teardown could observe
177 // `in_flight == 0` inside that window and report a quiesced gate while the first is about to enter the
178 // callback.
179 bool teardown_active = false;
180 // Native thread running that span, set and cleared with `teardown_active`. The same-gate recursion guard:
181 // it names the one claim a waiter must not wait for, its own. Win32 thread identity is allocation-free,
182 // unlike std::this_thread::get_id() on a foreign MinGW/winpthreads thread.
183 std::uint32_t teardown_owner = 0;
184
185 #if defined(DMK_ENABLE_TEST_SEAMS)
186 // Set these test pause points before worker start. Clear them after every worker joins.
187 void (*before_idle_wait_for_test)(void *) noexcept = nullptr;
188 void *before_idle_wait_context_for_test = nullptr;
189 void (*after_bookkeeping_for_test)(void *) noexcept = nullptr;
190 void *after_bookkeeping_context_for_test = nullptr;
191 #endif
192
193 /**
194 * @brief Holds one @ref in_flight slot for an unlocked callback.
195 * @details deliver() owns the primary callback slot through its critical sections.
196 * This holder covers a deferred false edge after that callback returns.
197 * release() and retire() wait for in_flight == 0 before they destroy consumer state.
198 * The holder releases its slot on every exit, even after an exception.
199 * TeardownScope covers consumer code that teardown invokes.
200 */
201 struct InFlightSlot
202 {
203 6 explicit InFlightSlot(HoldGate *owner) noexcept : m_gate(owner) {}
204 6 ~InFlightSlot() noexcept
205 {
206 6 std::lock_guard<std::mutex> exit_lock(m_gate->mutex);
207 6 --m_gate->in_flight;
208 6 m_gate->idle_cv.notify_all();
209 6 }
210
211 // One slot decrements exactly once, like the DeliveryScope it brackets. A copy would decrement twice
212 // and drive in_flight negative, at which point the == 0 predicate release() and retire() wait on is
213 // either already true while a callback runs or never true again.
214 InFlightSlot(const InFlightSlot &) = delete;
215 InFlightSlot &operator=(const InFlightSlot &) = delete;
216 InFlightSlot(InFlightSlot &&) = delete;
217 InFlightSlot &operator=(InFlightSlot &&) = delete;
218
219 private:
220 HoldGate *m_gate;
221 };
222
223 /**
224 * @brief RAII holder for a teardown path's claim on @ref teardown_active.
225 * @details Claimed under the mutex at the same moment the path sets @ref released, and cleared once its
226 * consumer-code span is over. A release spans its balancing edge; retirement also spans callable
227 * disposal. A concurrent teardown therefore waits for the whole span rather than for the callback
228 * window alone.
229 */
230 struct TeardownScope
231 {
232 207 explicit TeardownScope(HoldGate *owner) noexcept : m_gate(owner) {}
233 207 ~TeardownScope() noexcept
234 {
235 207 std::lock_guard<std::mutex> exit_lock(m_gate->mutex);
236 207 m_gate->teardown_active = false;
237 207 m_gate->teardown_owner = 0;
238 207 m_gate->idle_cv.notify_all();
239 207 }
240
241 // One claim, cleared exactly once, for the same reason InFlightSlot is neither copyable nor movable.
242 TeardownScope(const TeardownScope &) = delete;
243 TeardownScope &operator=(const TeardownScope &) = delete;
244 TeardownScope(TeardownScope &&) = delete;
245 TeardownScope &operator=(TeardownScope &&) = delete;
246
247 private:
248 HoldGate *m_gate;
249 };
250
251 /**
252 * @brief Wrapper the poller (and a reshape's release path) invoke on each hold edge; forwards only the
253 * aggregate held/released transition to the user callback, run outside the mutex.
254 */
255 37493 void deliver(bool active)
256 {
257
1/2
✓ Branch 2 → 3 taken 37518 times.
✗ Branch 2 → 121 not taken.
37493 std::unique_lock<std::mutex> lock(mutex);
258
2/2
✓ Branch 3 → 4 taken 11011 times.
✓ Branch 3 → 5 taken 26507 times.
37518 if (released)
259 {
260 11011 return;
261 }
262
4/6
✓ Branch 6 → 7 taken 21687 times.
✓ Branch 6 → 11 taken 4820 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 21687 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 26507 times.
26507 if (enabled && !enabled->load(std::memory_order_acquire))
263 {
264 return;
265 }
266 // Serialize this delivery's callback against any other in-flight delivery for this gate, so a true and
267 // a false forwarded from two threads reach the consumer in order. A delivery reached from inside a
268 // callback (depth > 0) skips the wait, so no wait chain runs through user code; the concurrent-delivery
269 // case that skip would otherwise expose is handled by the defer below.
270 26507 const bool in_callback = current_thread_in_delivery();
271
2/2
✓ Branch 15 → 16 taken 26505 times.
✓ Branch 15 → 30 taken 2 times.
26507 if (!in_callback)
272 {
273 #if defined(DMK_ENABLE_TEST_SEAMS)
274
2/2
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 26503 times.
26505 if (before_idle_wait_for_test != nullptr)
275 {
276 2 before_idle_wait_for_test(before_idle_wait_context_for_test);
277 }
278 #endif
279
1/2
✓ Branch 18 → 19 taken 26505 times.
✗ Branch 18 → 119 not taken.
53081 idle_cv.wait(lock, [this] { return in_flight == 0; });
280
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 26505 times.
26505 if (released)
281 {
282 return;
283 }
284 // Recheck every predicate after the wait. A concurrent disable must not wake this delivery into a
285 // stale callback (proof: GateRaceProbe.HoldGateDisableDuringParkedDeliveryEmitsNoStaleCallback).
286
6/6
✓ Branch 22 → 23 taken 21685 times.
✓ Branch 22 → 27 taken 4820 times.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 21684 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 26504 times.
26505 if (enabled && !enabled->load(std::memory_order_acquire))
287 {
288 1 return;
289 }
290 }
291 // Resurrection guard: once the binding is tombstoned, refuse a new held(true) edge; a balancing
292 // released(false) still passes so a removed-while-held binding ends not-held regardless of the order a
293 // late poll-thread true and the reshape's false arrive in.
294
6/8
✓ Branch 30 → 31 taken 13349 times.
✓ Branch 30 → 37 taken 13157 times.
✓ Branch 32 → 33 taken 420 times.
✓ Branch 32 → 37 taken 12929 times.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 420 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 26506 times.
26506 if (active && lifecycle && lifecycle->tombstoned())
295 {
296 return;
297 }
298
299 26506 bool crosses_boundary = false;
300
2/2
✓ Branch 40 → 41 taken 13349 times.
✓ Branch 40 → 42 taken 13157 times.
26506 if (active)
301 {
302 13349 crosses_boundary = (active_entries == 0);
303 13349 ++active_entries;
304 }
305 else
306 {
307
2/2
✓ Branch 42 → 43 taken 5 times.
✓ Branch 42 → 44 taken 13152 times.
13157 if (active_entries == 0)
308 {
309 5 return;
310 }
311 13152 --active_entries;
312 13152 crosses_boundary = (active_entries == 0);
313 }
314
2/2
✓ Branch 45 → 46 taken 4253 times.
✓ Branch 45 → 47 taken 22248 times.
26501 if (!crosses_boundary)
315 {
316 4253 return;
317 }
318
319 // A delivery reached from inside a callback could not wait for an in-flight delivery to drain (that
320 // would risk the cross-binding deadlock the skip above avoids). If one is in flight on another thread,
321 // running this callback now would deliver two edges for one gate concurrently and out of decision order
322 // (a teardown false racing the poll thread's held true), which can strand the consumer observing the
323 // stale held. Defer this crossing to the in-flight delivery's unwind instead, so the consumer sees held
324 // then released in order with no concurrent callback. At depth > 0 the crossing edge is always a
325 // teardown false (only the poll cycle raises a held true, and it never runs inside a callback), so
326 // exactly one balancing false is owed and the in-flight delivery emits it.
327
4/6
✓ Branch 47 → 48 taken 2 times.
✓ Branch 47 → 51 taken 22246 times.
✓ Branch 48 → 49 taken 2 times.
✗ Branch 48 → 51 not taken.
✓ Branch 49 → 50 taken 2 times.
✗ Branch 49 → 51 not taken.
22248 if (in_callback && in_flight > 0 && !active)
328 {
329 2 deferred_final = true;
330 2 return;
331 }
332
333 // Take this thread's delivery identity before the crossing commits to a callback, and give the edge up
334 // if it cannot be taken. Running the callback anyway would leave a control-plane release on another
335 // thread unable to see that this thread is inside consumer code, and that release promises its caller
336 // the opposite. Nothing consumer-visible has happened yet, so refusing here costs one edge; the entry
337 // count taken above is the only state to undo. Constructing under the gate mutex is what makes that
338 // undo a single decrement rather than a re-lock and a three-field rollback.
339 22246 DeliveryScope scope;
340
2/2
✓ Branch 53 → 54 taken 4 times.
✓ Branch 53 → 58 taken 22242 times.
22246 if (!scope.admitted())
341 {
342
2/2
✓ Branch 54 → 55 taken 3 times.
✓ Branch 54 → 56 taken 1 time.
4 if (active)
343 {
344 3 --active_entries;
345 }
346 else
347 {
348 1 ++active_entries;
349 }
350 4 return;
351 }
352
353 22242 forwarded_active = active;
354 22242 ++in_flight;
355
1/2
✓ Branch 58 → 59 taken 22242 times.
✗ Branch 58 → 117 not taken.
22242 lock.unlock();
356
357 22242 std::exception_ptr err;
358 try
359 {
360
2/2
✓ Branch 61 → 62 taken 22241 times.
✓ Branch 61 → 63 taken 1 time.
22242 if (on_state_change)
361 {
362
2/2
✓ Branch 62 → 63 taken 22237 times.
✓ Branch 62 → 99 taken 2 times.
22241 on_state_change(active);
363 }
364 }
365 2 catch (...)
366 {
367 2 err = std::current_exception();
368
1/2
✓ Branch 104 → 63 taken 2 times.
✗ Branch 104 → 115 not taken.
2 }
369
370 // Claim deferred_final in the final slot-release critical section.
371 // Separate decisions can let two exits miss the deferred false edge.
372 // Keep the claimed slot through the deferred callback.
373 // A release() or retire() waiter must not observe false quiescence
374 // (proof: GateRaceProbe.HoldGateDeferredFinalClaimSharesLastSlotRelease).
375 22240 bool emit_deferred = false;
376 {
377
1/2
✓ Branch 63 → 64 taken 22230 times.
✗ Branch 63 → 105 not taken.
22240 std::lock_guard<std::mutex> bookkeeping(mutex);
378 22230 --in_flight;
379
3/4
✓ Branch 64 → 65 taken 22231 times.
✗ Branch 64 → 68 not taken.
✓ Branch 65 → 66 taken 372 times.
✓ Branch 65 → 68 taken 21859 times.
22230 if (in_flight == 0 && deferred_final)
380 {
381 372 deferred_final = false;
382 372 emit_deferred = forwarded_active;
383 372 forwarded_active = false;
384
2/2
✓ Branch 66 → 67 taken 6 times.
✓ Branch 66 → 68 taken 366 times.
372 if (emit_deferred)
385 {
386 6 ++in_flight;
387 }
388 }
389
2/2
✓ Branch 68 → 69 taken 22221 times.
✓ Branch 68 → 70 taken 9 times.
22230 if (!emit_deferred)
390 {
391 22221 idle_cv.notify_all();
392 }
393 22231 }
394
395 #if defined(DMK_ENABLE_TEST_SEAMS)
396
2/2
✓ Branch 71 → 72 taken 1 time.
✓ Branch 71 → 73 taken 22236 times.
22237 if (after_bookkeeping_for_test != nullptr)
397 {
398 1 after_bookkeeping_for_test(after_bookkeeping_context_for_test);
399 }
400 #endif
401
402 // A release() that could not block (self-release, or cross-binding release from inside another
403 // callback) deferred its balancing false to here; emit it now the callback has unwound. When the
404 // primary callback threw, swallow any secondary throw so the original exception is the one that
405 // surfaces to the poller; otherwise let it propagate to the poller's dispatch handler.
406
2/2
✓ Branch 73 → 74 taken 6 times.
✓ Branch 73 → 83 taken 22231 times.
22237 if (emit_deferred)
407 {
408 6 InFlightSlot balancing_slot{this};
409
1/2
✓ Branch 76 → 77 taken 6 times.
✗ Branch 76 → 81 not taken.
6 if (on_state_change)
410 {
411
2/2
✓ Branch 78 → 79 taken 1 time.
✓ Branch 78 → 80 taken 5 times.
6 if (err)
412 {
413 try
414 {
415
1/2
✓ Branch 79 → 81 taken 1 time.
✗ Branch 79 → 106 not taken.
1 on_state_change(false);
416 }
417 catch (...)
418 {
419 }
420 }
421 else
422 {
423
1/2
✓ Branch 80 → 81 taken 5 times.
✗ Branch 80 → 109 not taken.
5 on_state_change(false);
424 }
425 }
426 6 }
427
2/2
✓ Branch 84 → 85 taken 2 times.
✓ Branch 84 → 87 taken 22230 times.
22237 if (err)
428 {
429 4 std::rethrow_exception(err);
430 }
431
4/4
✓ Branch 90 → 91 taken 22233 times.
✓ Branch 90 → 92 taken 4 times.
✓ Branch 94 → 95 taken 22233 times.
✓ Branch 94 → 97 taken 15264 times.
37515 }
432
433 /**
434 * @brief Guard teardown: stops further delivery and synthesizes one balancing false if still held.
435 * @details A control-plane release blocks until any in-flight delivery drains, then emits the balancing
436 * false unlocked. A release reached from inside a callback cannot block (it would deadlock), so it
437 * marks the gate released. If this gate has a delivery in flight, the balancing false is deferred
438 * to that delivery's unwind; otherwise it may run inline. A depth-zero return means the gate is
439 * quiesced, so a release that finds another teardown already claiming the gate waits for that one
440 * to finish instead of returning on the strength of its own no-op.
441 */
442 664 void release()
443 {
444
1/2
✓ Branch 2 → 3 taken 686 times.
✗ Branch 2 → 47 not taken.
664 std::unique_lock<std::mutex> lock(mutex);
445
2/2
✓ Branch 3 → 4 taken 22 times.
✓ Branch 3 → 13 taken 664 times.
686 if (released)
446 {
447 // The unload drain's retire(), or a repeated direct release, already owns this gate's consumer-code
448 // span and may be inside the consumer's balancing edge right now. Returning here would tell this
449 // caller it may destroy the state that callback captured while the callback is still reading it.
450 // Wait the claimant out. A release reached from inside a delivery must not wait (that is the
451 // ordering discipline at the top of this file) and need not: it is not a boundary where a caller
452 // destroys captured state, and the delivery it is nested in is the very thing a waiter would await.
453 // The claim's own thread is excused for the same reason and without needing the marker, which is
454 // what keeps a refused DeliveryScope from turning a self-release into a wait on this very frame.
455
5/6
✓ Branch 5 → 6 taken 12 times.
✓ Branch 5 → 9 taken 11 times.
✓ Branch 7 → 8 taken 12 times.
✗ Branch 7 → 9 not taken.
✓ Branch 10 → 11 taken 12 times.
✓ Branch 10 → 12 taken 11 times.
22 if (!current_thread_in_delivery() && teardown_owner != current_native_thread_id())
456 {
457
5/6
✓ Branch 2 → 3 taken 13 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 3 → 4 taken 12 times.
✓ Branch 3 → 5 taken 1 time.
✓ Branch 11 → 12 taken 12 times.
✗ Branch 11 → 45 not taken.
29 idle_cv.wait(lock, [this] { return !teardown_active && in_flight == 0; });
458 }
459 23 return;
460 }
461 664 released = true;
462 // Claimed before the wait below drops the mutex. Setting it together with `released` is what closes the
463 // window: any later release() now sees a claim rather than a merely-released gate.
464 664 teardown_active = true;
465 664 teardown_owner = current_native_thread_id();
466
2/2
✓ Branch 14 → 15 taken 392 times.
✓ Branch 14 → 20 taken 265 times.
657 if (in_flight > 0)
467 {
468
2/2
✓ Branch 16 → 17 taken 356 times.
✓ Branch 16 → 19 taken 33 times.
392 if (current_thread_in_delivery())
469 {
470 // The in-flight delivery emits the balancing false on its own unwind, inside its own in-flight
471 // slot, so the claim ends here and a waiter tracks that slot instead.
472 356 deferred_final = true;
473 356 teardown_active = false;
474 356 teardown_owner = 0;
475 356 idle_cv.notify_all();
476 361 return;
477 }
478
1/2
✓ Branch 19 → 20 taken 33 times.
✗ Branch 19 → 45 not taken.
99 idle_cv.wait(lock, [this] { return in_flight == 0; });
479 }
480 294 const bool emit_false = forwarded_active;
481 294 forwarded_active = false;
482
6/6
✓ Branch 20 → 21 taken 190 times.
✓ Branch 20 → 23 taken 104 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 188 times.
✓ Branch 25 → 26 taken 106 times.
✓ Branch 25 → 28 taken 188 times.
294 if (!emit_false || !on_state_change)
483 {
484 106 teardown_active = false;
485 106 teardown_owner = 0;
486 106 idle_cv.notify_all();
487 106 return;
488 }
489
1/2
✓ Branch 28 → 29 taken 188 times.
✗ Branch 28 → 45 not taken.
188 lock.unlock();
490 // Emit the balancing false UNWRAPPED: forwarded_active is already cleared, so a throw here cannot
491 // strand a stale true, and BindingGuard's composed teardown relies on the throw propagating (it runs
492 // its consume-suppression clear even when the balancing edge throws). The noexcept facade release
493 // catches it. The balancing edge is mandatory consumer code, so a MandatoryDeliveryScope brackets it:
494 // a nested release from this callback still defers, and it does so even when the TLS depth store is
495 // refused, which is the composition where two threads each running a cross-gate teardown callback
496 // would otherwise both read as control plane and wait on each other's claim.
497 // The claim outlives the call, including a throw out of it, so retire() cannot move and destroy the
498 // callable mid-invocation and a concurrent release() cannot report the gate quiesced.
499 188 TeardownScope teardown_slot{this};
500 188 MandatoryDeliveryScope scope;
501
2/2
✓ Branch 31 → 32 taken 187 times.
✓ Branch 31 → 41 taken 1 time.
188 on_state_change(false);
502
2/2
✓ Branch 36 → 37 taken 187 times.
✓ Branch 36 → 39 taken 493 times.
680 }
503
504 /**
505 * @copydoc BindingGate::retire
506 * @details Takes the owner under the mutex. The false edge and destruction run on this thread after unlock.
507 */
508 24 [[nodiscard]] bool retire(std::chrono::steady_clock::time_point deadline) override
509 {
510 24 GateCallbackSlot<void(bool)>::Owner callback;
511 24 bool emit_false = false;
512 {
513
1/2
✓ Branch 2 → 3 taken 24 times.
✗ Branch 2 → 45 not taken.
24 std::unique_lock<std::mutex> lock(mutex);
514 // A concurrent release() runs its balancing edge unlocked under a teardown claim. Retiring through
515 // that claim would move and destroy the callable the other thread is still invoking, so the claim
516 // is part of the quiescence this deadline is waiting for. A claim this thread holds is the one
517 // exception: waiting for it would be waiting for the frame doing the waiting.
518
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 24 times.
24 if (teardown_owner == current_native_thread_id())
519 {
520 return false;
521 }
522
7/8
✓ Branch 2 → 3 taken 24 times.
✓ Branch 2 → 5 taken 2 times.
✓ Branch 3 → 4 taken 22 times.
✓ Branch 3 → 5 taken 2 times.
✓ Branch 6 → 7 taken 24 times.
✗ Branch 6 → 43 not taken.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 22 times.
50 if (!idle_cv.wait_until(lock, deadline, [this] { return in_flight == 0 && !teardown_active; }))
523 {
524 2 return false;
525 }
526 22 released = true;
527 // Publish inactivity before callback transfer. A retained guard must fail closed during disposal.
528
2/2
✓ Branch 10 → 11 taken 19 times.
✓ Branch 10 → 13 taken 3 times.
22 if (enabled)
529 {
530 19 enabled->store(false, std::memory_order_release);
531 }
532 22 emit_false = forwarded_active;
533 22 forwarded_active = false;
534 22 callback = on_state_change.take();
535
2/2
✓ Branch 17 → 18 taken 19 times.
✓ Branch 17 → 20 taken 3 times.
22 if (callback)
536 {
537 19 teardown_active = true;
538 19 teardown_owner = current_native_thread_id();
539 }
540
2/2
✓ Branch 22 → 23 taken 22 times.
✓ Branch 22 → 26 taken 2 times.
24 }
541
542 // Keep invocation and destruction inside both teardown scopes. A capture destructor can release this
543 // gate or another gate.
544
2/2
✓ Branch 25 → 27 taken 19 times.
✓ Branch 25 → 39 taken 3 times.
22 if (callback)
545 {
546 19 TeardownScope teardown_slot{this};
547 19 MandatoryDeliveryScope scope;
548 19 GateCallbackSlot<void(bool)>::Owner owned = std::move(callback);
549
2/2
✓ Branch 32 → 33 taken 8 times.
✓ Branch 32 → 35 taken 11 times.
19 if (emit_false)
550 {
551
1/2
✓ Branch 34 → 35 taken 8 times.
✗ Branch 34 → 46 not taken.
8 (*owned)(false);
552 }
553 19 }
554 22 return true;
555 24 }
556 };
557
558 /**
559 * @struct PressGate
560 * @brief Per-binding teardown gate that runs down an in-flight press callback before the guard releases.
561 * @details A press has no lingering state to balance, but shares the hold's teardown hazard: the poll thread
562 * can be executing on_press the instant the guard is released. A control-plane release blocks until
563 * on_press has finished, so the caller may destroy state the callback captured the moment release()
564 * returns. A one-shot press that destroys its own guard, or a callback that releases a second
565 * binding's guard, releases from inside the delivery and so cannot block; it marks the gate released
566 * and returns, and the in-flight callback observes released on its own.
567 */
568 struct PressGate : BindingGate
569 {
570 std::mutex mutex;
571 std::condition_variable idle_cv;
572 std::shared_ptr<std::atomic<bool>> enabled;
573 std::shared_ptr<BindingLifecycle> lifecycle;
574 GateCallbackSlot<void()> on_press;
575 bool released = false;
576 int in_flight = 0;
577 // Native thread inside retire()'s callable disposal, which is counted in `in_flight` because it runs
578 // consumer destructors. Serves the same purpose as HoldGate::teardown_owner: the same-gate recursion
579 // guard, naming the one claim a waiter must not wait for.
580 std::uint32_t teardown_owner = 0;
581
582 #if defined(DMK_ENABLE_TEST_SEAMS)
583 // See HoldGate::before_idle_wait_for_test for the test-seam lifetime rule.
584 void (*before_idle_wait_for_test)(void *) noexcept = nullptr;
585 void *before_idle_wait_context_for_test = nullptr;
586 #endif
587
588 /**
589 * @brief Wrapper the poller invokes on each press edge; forwards to the user callback outside the mutex.
590 */
591 18 void deliver()
592 {
593
1/2
✓ Branch 2 → 3 taken 18 times.
✗ Branch 2 → 91 not taken.
18 std::unique_lock<std::mutex> lock(mutex);
594
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 14 times.
18 if (released)
595 {
596 4 return;
597 }
598
6/6
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 11 taken 4 times.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 9 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 13 times.
14 if (enabled && !enabled->load(std::memory_order_acquire))
599 {
600 1 return;
601 }
602
4/6
✓ Branch 15 → 16 taken 5 times.
✓ Branch 15 → 20 taken 8 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 5 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 13 times.
13 if (lifecycle && lifecycle->tombstoned())
603 {
604 return;
605 }
606
1/2
✓ Branch 24 → 25 taken 13 times.
✗ Branch 24 → 48 not taken.
13 if (!current_thread_in_delivery())
607 {
608 #if defined(DMK_ENABLE_TEST_SEAMS)
609
2/2
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 27 taken 11 times.
13 if (before_idle_wait_for_test != nullptr)
610 {
611 2 before_idle_wait_for_test(before_idle_wait_context_for_test);
612 }
613 #endif
614
1/2
✓ Branch 27 → 28 taken 13 times.
✗ Branch 27 → 89 not taken.
27 idle_cv.wait(lock, [this] { return in_flight == 0; });
615
1/2
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 13 times.
13 if (released)
616 {
617 return;
618 }
619 // Recheck enabled after the wait
620 // (proof: GateRaceProbe.PressGateDisableDuringParkedDeliveryEmitsNoStaleCallback).
621
6/6
✓ Branch 31 → 32 taken 9 times.
✓ Branch 31 → 36 taken 4 times.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 8 times.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 37 → 39 taken 12 times.
13 if (enabled && !enabled->load(std::memory_order_acquire))
622 {
623 1 return;
624 }
625
4/6
✓ Branch 40 → 41 taken 5 times.
✓ Branch 40 → 45 taken 7 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 5 times.
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 48 taken 12 times.
12 if (lifecycle && lifecycle->tombstoned())
626 {
627 return;
628 }
629 }
630 // Same admission rule as HoldGate::deliver, and cheaper to undo: a press commits no gate state before
631 // this point, so a refused frame simply drops the edge.
632 12 DeliveryScope scope;
633
2/2
✓ Branch 50 → 51 taken 2 times.
✓ Branch 50 → 52 taken 10 times.
12 if (!scope.admitted())
634 {
635 2 return;
636 }
637 10 ++in_flight;
638
1/2
✓ Branch 52 → 53 taken 10 times.
✗ Branch 52 → 87 not taken.
10 lock.unlock();
639
640 10 std::exception_ptr err;
641 try
642 {
643
1/2
✓ Branch 55 → 56 taken 10 times.
✗ Branch 55 → 57 not taken.
10 if (on_press)
644 {
645
1/2
✓ Branch 56 → 57 taken 10 times.
✗ Branch 56 → 76 not taken.
10 on_press();
646 }
647 }
648 catch (...)
649 {
650 err = std::current_exception();
651 }
652
653
1/2
✓ Branch 57 → 58 taken 10 times.
✗ Branch 57 → 85 not taken.
10 lock.lock();
654 10 --in_flight;
655 10 idle_cv.notify_all();
656
1/2
✓ Branch 59 → 60 taken 10 times.
✗ Branch 59 → 85 not taken.
10 lock.unlock();
657
1/2
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 64 taken 10 times.
10 if (err)
658 {
659 std::rethrow_exception(err);
660 }
661
4/4
✓ Branch 67 → 68 taken 10 times.
✓ Branch 67 → 69 taken 2 times.
✓ Branch 71 → 72 taken 10 times.
✓ Branch 71 → 74 taken 8 times.
20 }
662
663 /**
664 * @brief Guard teardown: marks the gate released and, off any callback, waits out any in-flight on_press.
665 */
666 994 void release()
667 {
668
1/2
✓ Branch 2 → 3 taken 995 times.
✗ Branch 2 → 16 not taken.
994 std::unique_lock<std::mutex> lock(mutex);
669 995 released = true;
670
7/8
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 9 taken 986 times.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 9 taken 7 times.
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 9 not taken.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 993 times.
995 if (in_flight > 0 && !current_thread_in_delivery() && teardown_owner != current_native_thread_id())
671 {
672
1/2
✓ Branch 11 → 12 taken 2 times.
✗ Branch 11 → 14 not taken.
6 idle_cv.wait(lock, [this] { return in_flight == 0; });
673 }
674 995 }
675
676 /**
677 * @copydoc BindingGate::retire
678 * @details A press has no lingering edge to balance, so retirement is the run-down plus handing the
679 * callback to this thread to destroy. Destruction happens unlocked: it runs the consumer's
680 * captured destructors, which must not observe the gate mutex held. It is still counted, because
681 * a concurrent release() promises its caller a quiesced gate and those destructors are consumer
682 * (Logic-DLL) code.
683 */
684 133 [[nodiscard]] bool retire(std::chrono::steady_clock::time_point deadline) override
685 {
686 133 GateCallbackSlot<void()>::Owner callback;
687 {
688
1/2
✓ Branch 2 → 3 taken 133 times.
✗ Branch 2 → 40 not taken.
133 std::unique_lock<std::mutex> lock(mutex);
689 // Retiring from inside this gate's own disposal would wait on the count that disposal holds.
690
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 133 times.
133 if (teardown_owner == current_native_thread_id())
691 {
692 return false;
693 }
694
3/4
✓ Branch 6 → 7 taken 133 times.
✗ Branch 6 → 38 not taken.
✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 9 taken 129 times.
272 if (!idle_cv.wait_until(lock, deadline, [this] { return in_flight == 0; }))
695 {
696 4 return false;
697 }
698 129 released = true;
699 // Publish inactivity before callback transfer, as HoldGate::retire does.
700
2/2
✓ Branch 10 → 11 taken 124 times.
✓ Branch 10 → 13 taken 5 times.
129 if (enabled)
701 {
702 124 enabled->store(false, std::memory_order_release);
703 }
704 129 callback = on_press.take();
705
2/2
✓ Branch 17 → 18 taken 126 times.
✓ Branch 17 → 20 taken 3 times.
129 if (callback)
706 {
707 126 ++in_flight;
708 126 teardown_owner = current_native_thread_id();
709 }
710
2/2
✓ Branch 22 → 23 taken 129 times.
✓ Branch 22 → 26 taken 4 times.
133 }
711
712
2/2
✓ Branch 25 → 27 taken 126 times.
✓ Branch 25 → 34 taken 3 times.
129 if (callback)
713 {
714 // A capture destructor can release a gate, so keep disposal inside mandatory delivery identity.
715 126 MandatoryDeliveryScope scope;
716 126 callback = nullptr;
717
1/2
✓ Branch 29 → 30 taken 126 times.
✗ Branch 29 → 41 not taken.
126 std::lock_guard<std::mutex> exit_lock(mutex);
718 126 --in_flight;
719 126 teardown_owner = 0;
720 126 idle_cv.notify_all();
721 126 }
722 129 return true;
723 133 }
724 };
725 } // namespace detail
726 } // namespace DetourModKit
727
728 #endif // DETOURMODKIT_INTERNAL_INPUT_BINDING_GATE_HPP
729