GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 89.0% 622 / 0 / 699
Functions: 98.4% 62 / 0 / 63
Branches: 72.3% 438 / 0 / 606

src/input.cpp
Line Branch Exec Source
1 /**
2 * @file input.cpp
3 * @brief Implementation of the public input facade (input.hpp): Input, Scope, BindingGuard, register_combo.
4 *
5 * The facade owns the background poll engine (input_poller.hpp) and the instance-shared interception layer. It explodes
6 * a public ComboBinding into one engine entry per combo (OR logic under a shared name), wraps the user callback behind
7 * a guard-owned cancellation flag, and routes delivery through a guard-owned teardown gate so release can run down any
8 * in-flight callback before returning.
9 */
10
11 #include "DetourModKit/input.hpp"
12 #include "DetourModKit/diagnostics.hpp"
13 #include "DetourModKit/logger.hpp"
14
15 #include "internal/drain_backoff.hpp"
16 #include "internal/input_binding_gate.hpp"
17 #include "internal/input_delivery_scope.hpp"
18 #include "internal/input_poller.hpp"
19 #include "internal/input_test_seams.hpp"
20 #include "internal/lifecycle_context.hpp"
21 #include "internal/lifecycle_reaper.hpp"
22
23 #include <algorithm>
24 #include <atomic>
25 #include <cstdint>
26 #include <functional>
27 #include <limits>
28 #include <memory>
29 #include <mutex>
30 #include <new>
31 #include <string>
32 #include <string_view>
33 #include <system_error>
34 #include <thread>
35 #include <utility>
36 #include <vector>
37
38 namespace DetourModKit
39 {
40 namespace input
41 {
42 struct BindingGuard::Impl
43 {
44 // Shared cancellation flag the binding's callback wrapper gates on; release() clears it so subsequent
45 // events become no-ops.
46 std::shared_ptr<std::atomic<bool>> enabled;
47 // One-shot action run once by release(): runs down the per-binding gate and, for a consume binding, lifts
48 // passthrough suppression.
49 std::function<void()> on_release;
50 std::string name;
51 };
52
53 461 BindingGuard::BindingGuard() noexcept = default;
54
55 2868 BindingGuard::BindingGuard(std::unique_ptr<Impl> impl) noexcept : m_impl(std::move(impl)) {}
56
57 3817 BindingGuard::~BindingGuard() noexcept
58 {
59 3817 release();
60 3817 }
61
62 3854 BindingGuard::BindingGuard(BindingGuard &&other) noexcept : m_impl(std::move(other.m_impl)) {}
63
64 455 BindingGuard &BindingGuard::operator=(BindingGuard &&other) noexcept
65 {
66
2/2
✓ Branch 2 → 3 taken 454 times.
✓ Branch 2 → 7 taken 1 time.
455 if (this != &other)
67 {
68 // Fire this guard's own pending release before adopting the other's binding, so the binding this guard
69 // currently owns is not silently abandoned in a held state.
70 454 release();
71 908 m_impl = std::move(other.m_impl);
72 }
73 455 return *this;
74 }
75
76 4853 void BindingGuard::release() noexcept
77 {
78
2/2
✓ Branch 3 → 4 taken 2844 times.
✓ Branch 3 → 5 taken 2000 times.
4853 if (!m_impl)
79 {
80 2844 return;
81 }
82
2/2
✓ Branch 7 → 8 taken 1402 times.
✓ Branch 7 → 13 taken 583 times.
2000 if (m_impl->enabled)
83 {
84 1402 m_impl->enabled->store(false, std::memory_order_release);
85 1419 m_impl->enabled.reset();
86 }
87 // Run the optional release action exactly once. std::exchange clears the member first so a repeated or
88 // re-entrant release() cannot double-fire it, and the catch keeps this noexcept teardown honest even though
89 // the action may invoke a user-supplied hold callback.
90
2/2
✓ Branch 15 → 16 taken 1398 times.
✓ Branch 15 → 21 taken 581 times.
1990 if (m_impl->on_release)
91 {
92 1398 const std::function<void()> action = std::exchange(m_impl->on_release, nullptr);
93 try
94 {
95
1/2
✓ Branch 18 → 19 taken 1417 times.
✗ Branch 18 → 22 not taken.
1398 action();
96 }
97 catch (...)
98 {
99 (void)log().log_noexcept(
100 LogLevel::Error,
101 "BindingGuard: release action threw; suppressed in noexcept teardown"
102 );
103 }
104 1417 }
105 }
106
107 38 bool BindingGuard::is_active() const noexcept
108 {
109
6/6
✓ Branch 3 → 4 taken 30 times.
✓ Branch 3 → 12 taken 8 times.
✓ Branch 6 → 7 taken 21 times.
✓ Branch 6 → 12 taken 9 times.
✓ Branch 10 → 11 taken 20 times.
✓ Branch 10 → 12 taken 1 time.
38 return m_impl && m_impl->enabled && m_impl->enabled->load(std::memory_order_acquire);
110 }
111
112 5 std::string_view BindingGuard::name() const noexcept
113 {
114
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 6 taken 1 time.
5 return m_impl ? std::string_view{m_impl->name} : std::string_view{};
115 }
116
117 122 void Scope::add(BindingGuard guard)
118 {
119
2/2
✓ Branch 3 → 4 taken 119 times.
✓ Branch 3 → 8 taken 3 times.
122 if (!m_guards)
120 {
121
1/2
✓ Branch 4 → 5 taken 119 times.
✗ Branch 4 → 13 not taken.
119 m_guards = std::make_unique<std::vector<BindingGuard>>();
122 }
123 244 m_guards->push_back(std::move(guard));
124 122 }
125
126 746 void Scope::clear() noexcept
127 {
128
2/2
✓ Branch 3 → 4 taken 517 times.
✓ Branch 3 → 5 taken 229 times.
746 if (!m_guards)
129 {
130 517 return;
131 }
132 // Consumer code can call add(), so detach the current batch before any release.
133 458 std::vector<BindingGuard> batch = std::move(*m_guards);
134 229 m_guards->clear();
135
2/2
✓ Branch 17 → 12 taken 117 times.
✓ Branch 17 → 18 taken 229 times.
346 for (auto it = batch.rbegin(); it != batch.rend(); ++it)
136 {
137 117 it->release();
138 }
139 229 }
140
141 5 void Scope::abandon() noexcept
142 {
143 // Retain the already-allocated container in place. Destroying the guards would destroy consumer callback
144 // captures inside DllMain even if release() itself were skipped, so logical abandonment must bypass both.
145
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 2 times.
5 if (m_guards.release() != nullptr)
146 {
147 3 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Input);
148 }
149 5 }
150
151 1 Scope &Scope::operator=(Scope &&other) noexcept
152 {
153
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 7 not taken.
1 if (this != &other)
154 {
155 1 clear();
156 2 m_guards = std::move(other.m_guards);
157 }
158 1 return *this;
159 }
160
161 struct Input::Impl
162 {
163 // The latch leaves m_impl unchanged, so an admitted facade call retains a stable pointee.
164 std::atomic<bool> m_vetoed_retained{false};
165 mutable std::mutex m_mutex;
166 std::vector<detail::InputBinding> m_pending;
167 std::uint64_t m_start_revision{1};
168 std::shared_ptr<detail::InputPoller> m_poller;
169 // Hot-path queries load a shared_ptr snapshot without m_mutex. The snapshot preserves poller lifetime
170 // across concurrent shutdown. input.hpp documents the shared ownership cost.
171 std::atomic<std::shared_ptr<detail::InputPoller>> m_active{};
172 std::atomic<bool> m_running{false};
173 std::atomic<bool> m_callback_drain_active{false};
174 std::atomic<std::uint32_t> m_admission_commits_inflight{0};
175 // Last-applied / pending engine settings. require_focus is live-mutable via set_require_focus; the gamepad
176 // knobs and poll interval are consumed when start() builds the poller.
177 Settings m_settings{};
178
179 /// Advances the facade state token after a pending binding or start setting changes.
180 1300 void advance_start_revision() noexcept
181 {
182 1300 m_start_revision =
183
1/2
✓ Branch 3 → 4 taken 1300 times.
✗ Branch 3 → 5 not taken.
1300 m_start_revision == (std::numeric_limits<std::uint64_t>::max)() ? 1 : m_start_revision + 1;
184 1300 }
185 // A consume release action holds a weak facade token before it calls set_consume_by_owner. The action
186 // becomes a no-op after facade teardown. No other guard action reaches facade state.
187 std::shared_ptr<char> m_liveness{std::make_shared<char>()};
188 };
189
190 340 void Input::ImplDeleter::operator()(Impl *impl) const noexcept
191 {
192
5/6
✓ Branch 2 → 3 taken 340 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 226 times.
✓ Branch 4 → 6 taken 114 times.
✓ Branch 7 → 8 taken 226 times.
✓ Branch 7 → 11 taken 114 times.
340 if (impl != nullptr && !impl->m_vetoed_retained.load(std::memory_order_acquire))
193 {
194
1/2
✓ Branch 8 → 9 taken 226 times.
✗ Branch 8 → 11 not taken.
226 delete impl;
195 }
196 340 }
197
198 static_assert(sizeof(Input) == sizeof(void *), "Input must retain its pointer-sized public ABI");
199 static_assert(alignof(Input) == alignof(void *), "Input must retain its pointer-aligned public ABI");
200 static_assert(std::atomic<bool>::is_always_lock_free, "Input's loader-lock retention latch must be lock-free");
201
202 #if defined(DMK_ENABLE_TEST_SEAMS)
203 namespace
204 {
205 std::atomic<detail::InputTestSeams::CallbackAdmissionCommitSeam> s_callback_admission_commit_seam{nullptr};
206 // Input members cast this test-only retained-owner identity through void*.
207 std::atomic<void *> s_vetoed_retained_impl{nullptr};
208 // The unlock seam uses the owner identity captured before a test veto.
209 void *s_test_locked_impl = nullptr;
210 } // namespace
211 #endif
212
213 namespace
214 {
215 /**
216 * @brief Counts one admitted registration or start operation until its commit point has passed.
217 */
218 class AdmissionCommitLease
219 {
220 public:
221 1650 AdmissionCommitLease(
222 std::atomic<bool> &drain_active,
223 std::atomic<std::uint32_t> &inflight,
224 bool require_staging_admission = true
225 ) noexcept
226 1650 : m_drain_active(drain_active), m_inflight(inflight)
227 {
228
7/8
✓ Branch 3 → 4 taken 1650 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 1647 times.
✓ Branch 5 → 9 taken 3 times.
✓ Branch 6 → 7 taken 1434 times.
✓ Branch 6 → 10 taken 213 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1646 times.
3083 if (m_drain_active.load(std::memory_order_seq_cst) || detail::input_callback_drain_pending() ||
229
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1433 times.
1434 (require_staging_admission && !detail::input_callback_admission_open()))
230 {
231 3 return;
232 }
233
234 1646 m_inflight.fetch_add(1, std::memory_order_seq_cst);
235
5/8
✓ Branch 16 → 17 taken 1647 times.
✗ Branch 16 → 22 not taken.
✓ Branch 18 → 19 taken 1646 times.
✗ Branch 18 → 22 not taken.
✓ Branch 19 → 20 taken 1433 times.
✓ Branch 19 → 23 taken 213 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 28 taken 1647 times.
3080 if (m_drain_active.load(std::memory_order_seq_cst) || detail::input_callback_drain_pending() ||
236
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 1434 times.
1433 (require_staging_admission && !detail::input_callback_admission_open()))
237 {
238 m_inflight.fetch_sub(1, std::memory_order_seq_cst);
239 return;
240 }
241 1647 m_engaged = true;
242 }
243
244 1650 ~AdmissionCommitLease() noexcept
245 {
246
2/2
✓ Branch 2 → 3 taken 1647 times.
✓ Branch 2 → 6 taken 3 times.
1650 if (m_engaged)
247 {
248 1647 m_inflight.fetch_sub(1, std::memory_order_seq_cst);
249 }
250 1650 }
251
252 AdmissionCommitLease(const AdmissionCommitLease &) = delete;
253 AdmissionCommitLease &operator=(const AdmissionCommitLease &) = delete;
254
255 /// Returns whether this registration may commit.
256 1650 [[nodiscard]] bool engaged() const noexcept { return m_engaged; }
257
258 private:
259 std::atomic<bool> &m_drain_active;
260 std::atomic<std::uint32_t> &m_inflight;
261 bool m_engaged{false};
262 };
263
264 143 [[nodiscard]] bool await_admission_commits(
265 std::atomic<std::uint32_t> &inflight,
266 std::chrono::steady_clock::time_point deadline
267 ) noexcept
268 {
269 143 return detail::drain_until_zero(
270 832 [&inflight]() noexcept { return inflight.load(std::memory_order_seq_cst); },
271 deadline
272 143 );
273 }
274 } // namespace
275
276 342 Input::Input() noexcept : m_impl(create_impl()) {}
277
278 342 Input::ImplOwner Input::create_impl() noexcept
279 {
280 // First-use allocation failure must not escape the noexcept instance() accessor. A null Impl is the inert
281 // state: no thread, no binding storage, and no partially built engine is ever published. It latches for the
282 // process generation because instance() constructs the singleton exactly once.
283 try
284 {
285
4/8
✓ Branch 2 → 3 taken 340 times.
✓ Branch 2 → 12 taken 2 times.
✓ Branch 3 → 4 taken 340 times.
✗ Branch 3 → 9 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 340 times.
✗ Branch 9 → 10 not taken.
✗ Branch 9 → 11 not taken.
342 return ImplOwner{new Impl{}};
286 }
287 2 catch (...)
288 {
289 2 return nullptr;
290 2 }
291 }
292
293 342 Input::~Input() noexcept
294 {
295 // shutdown() owns the B-100 boundary.
296 342 shutdown();
297 342 }
298
299 2324 Input &Input::instance() noexcept
300 {
301 // The constructor is noexcept, so this local static cannot throw out of a noexcept accessor and cannot be
302 // re-entered for a retry. Concurrent first callers serialize on the language's own initialization guard and
303 // all observe the same object, inert or live.
304
4/4
✓ Branch 2 → 3 taken 345 times.
✓ Branch 2 → 8 taken 1979 times.
✓ Branch 4 → 5 taken 342 times.
✓ Branch 4 → 8 taken 3 times.
2324 static Input instance;
305 2324 return instance;
306 }
307
308 1439 Result<BindingGuard> Input::register_combo(ComboBinding binding) noexcept
309 {
310
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 11 taken 1437 times.
1439 if (is_inert())
311 {
312
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 2 times.
2 const ErrorCode code = m_impl ? ErrorCode::ShutdownInProgress : ErrorCode::OutOfMemory;
313 2 return std::unexpected(Error{code, "input::register_combo"});
314 }
315 1437 AdmissionCommitLease registration{m_impl->m_callback_drain_active, m_impl->m_admission_commits_inflight};
316
2/2
✓ Branch 15 → 16 taken 3 times.
✓ Branch 15 → 19 taken 1434 times.
1437 if (!registration.engaged())
317 {
318 3 return std::unexpected(Error{ErrorCode::ShutdownInProgress, "input::register_combo"});
319 }
320
321 try
322 {
323
1/2
✓ Branch 19 → 20 taken 1434 times.
✗ Branch 19 → 239 not taken.
1434 auto enabled = std::make_shared<std::atomic<bool>>(true);
324
1/2
✓ Branch 20 → 21 taken 1434 times.
✗ Branch 20 → 314 not taken.
1434 auto impl = std::make_unique<BindingGuard::Impl>();
325 1434 impl->enabled = enabled;
326
1/2
✓ Branch 24 → 25 taken 1433 times.
✗ Branch 24 → 312 not taken.
1433 impl->name = binding.name;
327
328 // One lifecycle shared by this registration's gate and every exploded engine entry: the gate reads its
329 // tombstone as a resurrection guard, and each entry carries it so a poll-cycle callback staged before a
330 // remove / clear / cardinality-changing rebind is refused at dispatch. Allocated once here so the gate
331 // and entries share one identity. This call also reserves the delivery marker's TLS slot, which has to
332 // happen on a control thread before the wrappers below can be dispatched to.
333
1/2
✓ Branch 25 → 26 taken 1431 times.
✗ Branch 25 → 312 not taken.
1433 auto lifecycle = detail::make_binding_lifecycle();
334
335 1431 const bool is_hold = binding.trigger == Trigger::Hold;
336
337 // Unique identity for this registration, stamped on every exploded engine entry so the guard's teardown
338 // can clear the consume flag by identity rather than by name. A monotonic process-wide counter (never
339 // 0, which is the no-owner sentinel), so it cannot alias a freed binding the way a reused pointer
340 // address can. Relaxed suffices: the id only has to be unique, not ordered against any other state.
341 static std::atomic<std::uint64_t> s_next_consume_owner{1};
342 1431 const std::uint64_t consume_owner = s_next_consume_owner.fetch_add(1, std::memory_order_relaxed);
343
344 // Wrap the user callback behind a per-binding teardown gate that all of the binding's exploded combos
345 // share. The gate's release() is the one-shot action the guard runs on teardown:
346 // - HoldGate reference-counts the shared combos so a multi-combo hold forwards only the aggregate
347 // held/released transitions, and it synthesizes exactly one balancing on_state_change(false) for a
348 // still-held binding without re-entering the callback while it is on the stack.
349 // - PressGate serializes delivery against release() so a caller can destroy state the press callback
350 // captured the instant the guard is released, with no in-flight on_press still running through it.
351 1431 std::function<void()> press_wrapper;
352 1433 std::function<void(bool)> hold_wrapper;
353 1432 std::function<void()> gate_release;
354 // Also handed to every exploded engine entry, so an unload drain can retire the gate directly instead
355 // of only dropping the wrappers that capture it. See InputBinding::gate.
356 1434 std::shared_ptr<detail::BindingGate> binding_gate;
357
2/2
✓ Branch 31 → 32 taken 447 times.
✓ Branch 31 → 52 taken 987 times.
1434 if (is_hold)
358 {
359
1/2
✓ Branch 32 → 33 taken 447 times.
✗ Branch 32 → 251 not taken.
447 auto gate = std::make_shared<detail::HoldGate>();
360 447 gate->enabled = enabled;
361 447 gate->lifecycle = lifecycle;
362
1/2
✓ Branch 41 → 42 taken 447 times.
✗ Branch 41 → 240 not taken.
894 gate->on_state_change = std::move(binding.on_state_change);
363
1/2
✓ Branch 44 → 45 taken 447 times.
✗ Branch 44 → 243 not taken.
1280 hold_wrapper = [gate](bool active) { gate->deliver(active); };
364
1/2
✓ Branch 47 → 48 taken 447 times.
✗ Branch 47 → 246 not taken.
859 gate_release = [gate]() { gate->release(); };
365 447 binding_gate = gate;
366 447 }
367 else
368 {
369
1/2
✓ Branch 52 → 53 taken 985 times.
✗ Branch 52 → 263 not taken.
987 auto gate = std::make_shared<detail::PressGate>();
370 985 gate->enabled = enabled;
371 987 gate->lifecycle = lifecycle;
372
1/2
✓ Branch 61 → 62 taken 986 times.
✗ Branch 61 → 252 not taken.
1972 gate->on_press = std::move(binding.on_press);
373
1/2
✓ Branch 64 → 65 taken 987 times.
✗ Branch 64 → 255 not taken.
991 press_wrapper = [gate]() { gate->deliver(); };
374
1/2
✓ Branch 67 → 68 taken 987 times.
✗ Branch 67 → 258 not taken.
1968 gate_release = [gate]() { gate->release(); };
375 986 binding_gate = gate;
376 986 }
377
378 // Callback disable does not clear consume suppression, which reads InputBinding::consume. Clear by
379 // owner identity because empty names do not enter the name index. The weak token rejects late release.
380 1433 std::function<void()> consume_release;
381
2/2
✓ Branch 73 → 74 taken 113 times.
✓ Branch 73 → 83 taken 1320 times.
1433 if (binding.consume)
382 {
383 113 const std::weak_ptr<char> facade_alive = m_impl->m_liveness;
384 113 Input *const facade = this;
385
1/4
✗ Branch 79 → 80 not taken.
✓ Branch 79 → 81 taken 113 times.
✗ Branch 266 → 267 not taken.
✗ Branch 266 → 268 not taken.
226 consume_release = [facade_alive, facade, consume_owner]()
386 {
387
1/2
✓ Branch 4 → 5 taken 111 times.
✗ Branch 4 → 6 not taken.
111 if (auto keep = facade_alive.lock())
388 {
389 111 facade->set_consume_by_owner(consume_owner, false);
390 111 }
391
1/2
✓ Branch 77 → 78 taken 113 times.
✗ Branch 77 → 264 not taken.
113 };
392 113 }
393
394 // Release the gate before the consume clear. If a Hold edge throws, run the clear before the exception
395 // resumes.
396
2/2
✓ Branch 84 → 85 taken 113 times.
✓ Branch 84 → 97 taken 1320 times.
1433 if (consume_release)
397 {
398 113 impl->on_release =
399
1/4
✗ Branch 94 → 95 not taken.
✓ Branch 94 → 96 taken 113 times.
✗ Branch 275 → 276 not taken.
✗ Branch 275 → 277 not taken.
339 [gate_release = std::move(gate_release), consume_release = std::move(consume_release)]()
400 {
401 try
402 {
403
1/2
✓ Branch 2 → 3 taken 111 times.
✗ Branch 2 → 5 not taken.
111 gate_release();
404 }
405 catch (...)
406 {
407 consume_release();
408 throw;
409 }
410 111 consume_release();
411
1/2
✓ Branch 92 → 93 taken 113 times.
✗ Branch 92 → 273 not taken.
224 };
412 }
413 else
414 {
415 1320 impl->on_release = std::move(gate_release);
416 }
417
418 // Explode the combos into one engine entry per alternative, all sharing the name (OR logic). An empty
419 // list still registers a single inert sentinel so the name is addressable for a later rebind.
420 1442 const auto make_entry = [&](const std::vector<InputCode> &keys,
421 const std::vector<InputCode> &modifiers) -> detail::InputBinding
422 {
423 1442 detail::InputBinding entry;
424
1/2
✓ Branch 3 → 4 taken 1441 times.
✗ Branch 3 → 14 not taken.
1440 entry.name = binding.name;
425
1/2
✓ Branch 4 → 5 taken 1440 times.
✗ Branch 4 → 14 not taken.
1441 entry.keys = keys;
426
1/2
✓ Branch 5 → 6 taken 1440 times.
✗ Branch 5 → 14 not taken.
1440 entry.modifiers = modifiers;
427 1440 entry.trigger = binding.trigger;
428 1440 entry.consume = binding.consume;
429 1440 entry.consume_owner = consume_owner;
430 1440 entry.lifecycle = lifecycle;
431 1442 entry.gate = binding_gate;
432
2/2
✓ Branch 8 → 9 taken 450 times.
✓ Branch 8 → 11 taken 991 times.
1441 if (is_hold)
433 {
434
1/2
✓ Branch 9 → 10 taken 450 times.
✗ Branch 9 → 14 not taken.
450 entry.on_state_change = hold_wrapper;
435 // The gate deduplicates a released(false) with no live held(true), so a tombstoning reshape can
436 // publish this binding's balancing false without racing the state clear the poll loop commits
437 // for the cycle that staged the release.
438 450 entry.release_is_idempotent = true;
439 }
440 else
441 {
442
1/2
✓ Branch 11 → 12 taken 991 times.
✗ Branch 11 → 14 not taken.
991 entry.on_press = press_wrapper;
443 }
444 1441 return entry;
445 };
446
447 1434 std::vector<detail::InputBinding> entries;
448
2/2
✓ Branch 102 → 103 taken 50 times.
✓ Branch 102 → 111 taken 1384 times.
1434 if (binding.combos.empty())
449 {
450
2/4
✓ Branch 105 → 106 taken 50 times.
✗ Branch 105 → 281 not taken.
✓ Branch 106 → 107 taken 50 times.
✗ Branch 106 → 279 not taken.
50 entries.push_back(make_entry({}, {}));
451 }
452 else
453 {
454
1/2
✓ Branch 112 → 113 taken 1384 times.
✗ Branch 112 → 298 not taken.
1384 entries.reserve(binding.combos.size());
455
2/2
✓ Branch 129 → 115 taken 1391 times.
✓ Branch 129 → 130 taken 1384 times.
4159 for (const auto &combo : binding.combos)
456 {
457
2/4
✓ Branch 117 → 118 taken 1391 times.
✗ Branch 117 → 290 not taken.
✓ Branch 118 → 119 taken 1392 times.
✗ Branch 118 → 288 not taken.
1391 entries.push_back(make_entry(combo.keys, combo.modifiers));
458 }
459 }
460
461 #if defined(DMK_ENABLE_TEST_SEAMS)
462
2/2
✓ Branch 132 → 133 taken 2 times.
✓ Branch 132 → 134 taken 1431 times.
1433 if (const detail::InputTestSeams::CallbackAdmissionCommitSeam seam =
463 1434 s_callback_admission_commit_seam.load(std::memory_order_acquire);
464 seam != nullptr)
465 {
466 2 seam();
467 }
468 #endif
469
470 // Register: forward each entry to the live poller, or stage it for the next start(). Forward outside
471 // m_mutex so the poller's exclusive binding lock cannot AB/BA against a caller holding m_mutex.
472 1433 std::shared_ptr<detail::InputPoller> live;
473 {
474
1/2
✓ Branch 135 → 136 taken 1434 times.
✗ Branch 135 → 295 not taken.
1433 std::lock_guard lock(m_impl->m_mutex);
475
2/2
✓ Branch 138 → 139 taken 710 times.
✓ Branch 138 → 141 taken 724 times.
1434 if (m_impl->m_poller)
476 {
477 710 live = m_impl->m_poller;
478 }
479 else
480 {
481 // Stage all-or-nothing. Reserve the whole batch up front so a mid-loop bad_alloc cannot leave a
482 // subset of a multi-combo registration staged (which then goes live half-registered at the
483 // next start()). The reserve is the only allocating step; InputBinding moves are noexcept, so
484 // once capacity is secured the push_backs cannot throw.
485
1/2
✓ Branch 145 → 146 taken 724 times.
✗ Branch 145 → 293 not taken.
724 m_impl->m_pending.reserve(m_impl->m_pending.size() + entries.size());
486
2/2
✓ Branch 163 → 148 taken 731 times.
✓ Branch 163 → 164 taken 724 times.
2179 for (auto &entry : entries)
487 {
488
1/2
✓ Branch 153 → 154 taken 731 times.
✗ Branch 153 → 292 not taken.
1462 m_impl->m_pending.push_back(std::move(entry));
489 }
490 724 m_impl->advance_start_revision();
491 724 return BindingGuard{std::move(impl)};
492 }
493
2/2
✓ Branch 176 → 177 taken 710 times.
✓ Branch 176 → 181 taken 724 times.
1434 }
494
1/2
✗ Branch 180 → 182 not taken.
✓ Branch 180 → 192 taken 710 times.
710 if (!m_impl->m_running.load(std::memory_order_acquire))
495 {
496 // shutdown() flips m_running false (under m_mutex) before it tears the captured poller down, so
497 // observing false here means a concurrent shutdown began after we captured the poller. Return a
498 // valid but inert guard, the same observable outcome as registering after shutdown.
499 enabled->store(false, std::memory_order_release);
500 return BindingGuard{std::move(impl)};
501 }
502
503 // Add multi-combo bindings as one batch. A per-entry append can leave a partially-registered consume
504 // binding behind when a later append runs out of memory, and consume suppression is driven by the
505 // engine entry's consume flag rather than the guard's enabled flag. The single-entry path keeps the
506 // existing append primitive live; the multi-entry batch path either commits every combo or none.
507 2837 const bool added = (entries.size() == 1) ? live->add_binding(std::move(entries.front()))
508
6/6
✓ Branch 193 → 194 taken 709 times.
✓ Branch 193 → 201 taken 1 time.
✓ Branch 207 → 208 taken 1 time.
✓ Branch 207 → 209 taken 709 times.
✓ Branch 209 → 210 taken 709 times.
✓ Branch 209 → 211 taken 1 time.
1420 : live->add_bindings(std::move(entries));
509
1/2
✗ Branch 211 → 212 not taken.
✓ Branch 211 → 217 taken 710 times.
710 if (!added)
510 {
511 enabled->store(false, std::memory_order_release);
512 return std::unexpected(Error{ErrorCode::OutOfMemory, "input::register_combo"});
513 }
514 710 return BindingGuard{std::move(impl)};
515 1434 }
516 catch (...)
517 {
518 return std::unexpected(Error{ErrorCode::OutOfMemory, "input::register_combo"});
519 }
520 1437 }
521
522 215 Result<void> Input::start(Settings settings) noexcept
523 {
524
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 11 taken 213 times.
215 if (is_inert())
525 {
526
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 2 times.
2 const ErrorCode code = m_impl ? ErrorCode::ShutdownInProgress : ErrorCode::OutOfMemory;
527 2 return std::unexpected(Error{code, "input::start"});
528 }
529 AdmissionCommitLease start_admission{
530 213 m_impl->m_callback_drain_active,
531 213 m_impl->m_admission_commits_inflight,
532 false
533 213 };
534
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 19 taken 213 times.
213 if (!start_admission.engaged())
535 {
536 return std::unexpected(Error{ErrorCode::ShutdownInProgress, "input::start"});
537 }
538 #if defined(DMK_ENABLE_TEST_SEAMS)
539
2/2
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 211 times.
213 if (const detail::InputTestSeams::CallbackAdmissionCommitSeam seam =
540 213 s_callback_admission_commit_seam.load(std::memory_order_acquire);
541 seam != nullptr)
542 {
543 2 seam();
544 }
545 #endif
546
547 try
548 {
549
1/2
✓ Branch 23 → 24 taken 213 times.
✗ Branch 23 → 213 not taken.
213 std::unique_lock lock(m_impl->m_mutex);
550
551
5/6
✓ Branch 26 → 27 taken 213 times.
✗ Branch 26 → 29 not taken.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 212 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 35 taken 212 times.
426 if (m_impl->m_callback_drain_active.load(std::memory_order_seq_cst) ||
552 213 detail::input_callback_drain_pending())
553 {
554 1 return std::unexpected(Error{ErrorCode::ShutdownInProgress, "input::start"});
555 }
556
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 40 taken 212 times.
212 if (!detail::open_input_callback_admission())
557 {
558 return std::unexpected(Error{ErrorCode::ShutdownInProgress, "input::start"});
559 }
560
561
2/2
✓ Branch 42 → 43 taken 1 time.
✓ Branch 42 → 46 taken 211 times.
212 if (m_impl->m_poller)
562 {
563
1/2
✓ Branch 44 → 45 taken 1 time.
✗ Branch 44 → 190 not taken.
1 log().debug("input::Input: start() called while already running; no-op.");
564 1 return {};
565 }
566
567 211 m_impl->m_settings = settings;
568 211 m_impl->advance_start_revision();
569
570
2/2
✓ Branch 51 → 52 taken 6 times.
✓ Branch 51 → 53 taken 205 times.
211 if (m_impl->m_pending.empty())
571 {
572 // No bindings to seed the engine with. Preserve the no-op; a later register_combo stages
573 // into pending and a subsequent start() builds the poller.
574 6 return {};
575 }
576
577 // Resolve the wheel backend before building the engine. An ExternalHost selection is validated against
578 // the C ABI here, once, so the poller only ever receives a known-good host. A required host that is
579 // missing or ABI-incompatible fails start() closed (InvalidArg); an optional one downgrades to the
580 // local MessageHook backend so a single-DLL consumer still captures the wheel.
581 205 Input::WheelBackend resolved_backend = settings.wheel_backend;
582 205 const WheelHostTable *resolved_host = nullptr;
583 // Reserved value 0 and every other unknown value are rejected at runtime.
584
2/2
✓ Branch 53 → 54 taken 121 times.
✓ Branch 53 → 58 taken 84 times.
205 if (settings.wheel_backend != Input::WheelBackend::MessageHook &&
585
2/2
✓ Branch 54 → 55 taken 2 times.
✓ Branch 54 → 58 taken 119 times.
121 settings.wheel_backend != Input::WheelBackend::ExternalHost)
586 {
587 2 return std::unexpected(Error{ErrorCode::InvalidArg, "input::start"});
588 }
589
2/2
✓ Branch 58 → 59 taken 9 times.
✓ Branch 58 → 74 taken 194 times.
203 if (settings.wheel_target_thread_id != 0)
590 {
591 // An explicit wheel target must belong to this process and be alive.
592 18 const HANDLE target = OpenThread(
593 THREAD_QUERY_LIMITED_INFORMATION | SYNCHRONIZE,
594 FALSE,
595
1/2
✓ Branch 59 → 60 taken 9 times.
✗ Branch 59 → 211 not taken.
9 settings.wheel_target_thread_id
596 );
597 8 const bool target_valid = target != nullptr &&
598
5/8
✓ Branch 60 → 61 taken 8 times.
✓ Branch 60 → 67 taken 1 time.
✓ Branch 61 → 62 taken 8 times.
✗ Branch 61 → 211 not taken.
✓ Branch 62 → 63 taken 8 times.
✗ Branch 62 → 211 not taken.
✓ Branch 63 → 64 taken 8 times.
✗ Branch 63 → 67 not taken.
17 GetProcessIdOfThread(target) == GetCurrentProcessId() &&
599
2/4
✓ Branch 64 → 65 taken 8 times.
✗ Branch 64 → 211 not taken.
✓ Branch 65 → 66 taken 8 times.
✗ Branch 65 → 67 not taken.
8 WaitForSingleObject(target, 0) != WAIT_OBJECT_0;
600
2/2
✓ Branch 68 → 69 taken 8 times.
✓ Branch 68 → 70 taken 1 time.
9 if (target != nullptr)
601 {
602
1/2
✓ Branch 69 → 70 taken 8 times.
✗ Branch 69 → 211 not taken.
8 CloseHandle(target);
603 }
604
2/2
✓ Branch 70 → 71 taken 1 time.
✓ Branch 70 → 74 taken 8 times.
9 if (!target_valid)
605 {
606 1 return std::unexpected(Error{ErrorCode::InvalidArg, "input::start"});
607 }
608 }
609
2/2
✓ Branch 74 → 75 taken 119 times.
✓ Branch 74 → 100 taken 83 times.
202 if (settings.wheel_backend == Input::WheelBackend::ExternalHost)
610 {
611 119 const WheelHostTable *host = settings.wheel_host;
612 119 constexpr std::uint64_t REQUIRED_CAPABILITIES = DMK_WHEELHOST_CAP_VERTICAL |
613 DMK_WHEELHOST_CAP_HORIZONTAL |
614 DMK_WHEELHOST_CAP_CONSUME | DMK_WHEELHOST_CAP_ROUTE;
615
2/2
✓ Branch 76 → 77 taken 116 times.
✓ Branch 76 → 88 taken 1 time.
117 const bool host_valid = host != nullptr && host->struct_size >= sizeof(WheelHostTable) &&
616
2/2
✓ Branch 77 → 78 taken 115 times.
✓ Branch 77 → 88 taken 1 time.
116 host->abi_version == DMK_WHEELHOST_ABI_VERSION &&
617
2/2
✓ Branch 78 → 79 taken 113 times.
✓ Branch 78 → 88 taken 2 times.
115 (host->capability_bits & REQUIRED_CAPABILITIES) == REQUIRED_CAPABILITIES &&
618
3/4
✓ Branch 79 → 80 taken 112 times.
✓ Branch 79 → 88 taken 1 time.
✓ Branch 80 → 81 taken 112 times.
✗ Branch 80 → 88 not taken.
113 host->host_identity != 0 && host->host_context != nullptr &&
619
2/4
✓ Branch 81 → 82 taken 112 times.
✗ Branch 81 → 88 not taken.
✓ Branch 82 → 83 taken 112 times.
✗ Branch 82 → 88 not taken.
112 host->open_lease != nullptr && host->publish_capture != nullptr &&
620
2/4
✓ Branch 83 → 84 taken 112 times.
✗ Branch 83 → 88 not taken.
✓ Branch 84 → 85 taken 112 times.
✗ Branch 84 → 88 not taken.
112 host->drain_counts != nullptr && host->close_lease != nullptr &&
621
6/6
✓ Branch 75 → 76 taken 117 times.
✓ Branch 75 → 88 taken 2 times.
✓ Branch 85 → 86 taken 111 times.
✓ Branch 85 → 88 taken 1 time.
✓ Branch 86 → 87 taken 110 times.
✓ Branch 86 → 88 taken 1 time.
236 host->route_status != nullptr && host->retarget != nullptr;
622
2/2
✓ Branch 89 → 90 taken 110 times.
✓ Branch 89 → 91 taken 9 times.
119 if (host_valid)
623 {
624 110 resolved_host = host;
625 }
626
2/2
✓ Branch 91 → 92 taken 8 times.
✓ Branch 91 → 97 taken 1 time.
9 else if (settings.wheel_host_required)
627 {
628 // Leave callback admission open, matching the other start() failure paths: the refusal is
629 // retryable once the loader supplies a valid host and the staged bindings remain.
630
1/2
✓ Branch 93 → 94 taken 8 times.
✗ Branch 93 → 191 not taken.
8 log().error(
631 "input::Input: required wheel host is missing or ABI-incompatible; refusing start."
632 );
633 8 return std::unexpected(Error{ErrorCode::InvalidArg, "input::start"});
634 }
635 else
636 {
637
1/2
✓ Branch 98 → 99 taken 1 time.
✗ Branch 98 → 192 not taken.
1 log().warning(
638 "input::Input: optional wheel host unavailable; using the local MessageHook "
639 "backend."
640 );
641 1 resolved_backend = Input::WheelBackend::MessageHook;
642 }
643 }
644
645 194 Logger &logger = log();
646 logger.info(
647 "input::Input: Starting with {} binding(s), poll interval {}ms",
648
1/2
✓ Branch 104 → 105 taken 194 times.
✗ Branch 104 → 193 not taken.
194 m_impl->m_pending.size(),
649 194 settings.poll_interval.count()
650 );
651
2/2
✓ Branch 122 → 108 taken 215 times.
✓ Branch 122 → 123 taken 194 times.
603 for (const auto &binding : m_impl->m_pending)
652 {
653
1/2
✓ Branch 112 → 113 taken 215 times.
✗ Branch 112 → 196 not taken.
215 logger.trace(
654 "input::Input: Registered {} binding \"{}\" with {} key(s)",
655 to_string(binding.trigger),
656 215 binding.name,
657 430 binding.keys.size()
658 );
659 }
660
661 // Seed the engine with a COPY of the staged bindings and clear them only after start() succeeds.
662 // InputPoller::start() throws std::system_error when the poll thread or its module reference cannot be
663 // created, and the poller (sole owner of a moved-in vector) is destroyed during unwind. Moving
664 // m_pending in before that point destroys the staged set with it, so a later retry hits the
665 // empty-pending no-op above and silently loses the bindings. The copy is confined to this cold
666 // path.
667 auto poller = std::make_shared<detail::InputPoller>(
668 194 m_impl->m_pending,
669 settings.poll_interval,
670 settings.require_focus,
671 settings.gamepad_index,
672 settings.trigger_threshold,
673 settings.stick_threshold,
674 resolved_backend,
675 resolved_host,
676 settings.wheel_target_thread_id
677
1/2
✓ Branch 124 → 125 taken 194 times.
✗ Branch 124 → 211 not taken.
194 );
678
2/2
✓ Branch 125 → 126 taken 110 times.
✓ Branch 125 → 165 taken 84 times.
194 if (resolved_backend == Input::WheelBackend::ExternalHost)
679 {
680 110 const std::uint64_t candidate_revision = m_impl->m_start_revision;
681 // The loader supplies this function pointer. B-101 requires the call outside the facade lock.
682
1/2
✓ Branch 127 → 128 taken 110 times.
✗ Branch 127 → 209 not taken.
110 lock.unlock();
683 110 const int32_t host_status = poller->prepare_wheel_source();
684
2/2
✓ Branch 130 → 131 taken 1 time.
✓ Branch 130 → 132 taken 109 times.
110 if (host_status != DMK_WHEELHOST_OK)
685 {
686 1 poller.reset();
687 }
688
1/2
✓ Branch 132 → 133 taken 110 times.
✗ Branch 132 → 209 not taken.
110 lock.lock();
689
3/4
✓ Branch 137 → 138 taken 109 times.
✓ Branch 137 → 145 taken 1 time.
✓ Branch 140 → 141 taken 109 times.
✗ Branch 140 → 145 not taken.
329 if (m_impl->m_poller || m_impl->m_start_revision != candidate_revision ||
690
1/2
✓ Branch 142 → 143 taken 109 times.
✗ Branch 142 → 145 not taken.
218 m_impl->m_callback_drain_active.load(std::memory_order_seq_cst) ||
691
4/6
✓ Branch 135 → 136 taken 110 times.
✗ Branch 135 → 145 not taken.
✗ Branch 144 → 145 not taken.
✓ Branch 144 → 146 taken 109 times.
✓ Branch 147 → 148 taken 1 time.
✓ Branch 147 → 153 taken 109 times.
329 detail::input_callback_drain_pending() || !detail::input_callback_admission_open())
692 {
693
1/2
✓ Branch 148 → 149 taken 1 time.
✗ Branch 148 → 209 not taken.
1 lock.unlock();
694 1 poller.reset();
695 1 return std::unexpected(Error{ErrorCode::ShutdownInProgress, "input::start"});
696 }
697
2/2
✓ Branch 153 → 154 taken 1 time.
✓ Branch 153 → 165 taken 108 times.
109 if (host_status != DMK_WHEELHOST_OK)
698 {
699
1/2
✓ Branch 154 → 155 taken 1 time.
✗ Branch 154 → 158 not taken.
1 if (settings.wheel_host_required)
700 {
701 1 const std::int64_t signed_status = host_status;
702 1 const auto detail =
703 1 static_cast<std::uintptr_t>(signed_status < 0 ? -signed_status : signed_status);
704 1 return std::unexpected(Error{ErrorCode::SystemCallFailed, "input::start", detail});
705 }
706 log().warning(
707 "input::Input: optional wheel host rejected the lease; using the local "
708 "MessageHook backend."
709 );
710 resolved_backend = Input::WheelBackend::MessageHook;
711 resolved_host = nullptr;
712 poller = std::make_shared<detail::InputPoller>(
713 m_impl->m_pending,
714 settings.poll_interval,
715 settings.require_focus,
716 settings.gamepad_index,
717 settings.trigger_threshold,
718 settings.stick_threshold,
719 resolved_backend,
720 resolved_host,
721 settings.wheel_target_thread_id
722 );
723 }
724 }
725 try
726 {
727
1/2
✓ Branch 166 → 167 taken 192 times.
✗ Branch 166 → 202 not taken.
192 poller->start();
728 }
729 catch (...)
730 {
731 lock.unlock();
732 poller.reset();
733 throw;
734 }
735 // Precommit the non-draining fallback before publishing the poller. A clean shutdown clears this
736 // cycle only after the join and rundown; every uncertain path leaves the complete owner reachable
737 // without allocating during teardown.
738 192 poller->retain_owner_for_abandonment(poller);
739 192 m_impl->m_pending.clear();
740 192 m_impl->advance_start_revision();
741 192 m_impl->m_poller = poller;
742 192 m_impl->m_active.store(poller, std::memory_order_release);
743 192 m_impl->m_running.store(true, std::memory_order_release);
744 192 return {};
745 213 }
746 catch (const std::system_error &e)
747 {
748 return std::unexpected(
749 Error{ErrorCode::SystemCallFailed, "input::start", static_cast<std::uintptr_t>(e.code().value())}
750 );
751 }
752 catch (...)
753 {
754 return std::unexpected(Error{ErrorCode::OutOfMemory, "input::start"});
755 }
756 213 }
757
758 874 void Input::shutdown() noexcept
759 {
760
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 870 times.
874 if (is_inert())
761 {
762 122 return;
763 }
764
765 // B-100 requires this gate before any m_mutex access.
766
2/2
✓ Branch 6 → 7 taken 118 times.
✓ Branch 6 → 25 taken 752 times.
870 if (!detail::blocking_teardown_permitted())
767 {
768 118 Impl *const impl = m_impl.get();
769 118 bool active = false;
770
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 118 times.
118 if (!impl->m_vetoed_retained
771 118 .compare_exchange_strong(active, true, std::memory_order_acq_rel, std::memory_order_acquire))
772 {
773 return;
774 }
775 // Stop a running poll loop without a wait: the poller's own gate detaches instead of joining, so the
776 // retained owner keeps a stopped engine rather than a live callback source. Process exit skips the
777 // stop, because the OS already terminated the poll thread and no lock acquisition is safe there.
778
2/2
✓ Branch 13 → 14 taken 117 times.
✓ Branch 13 → 21 taken 1 time.
118 if (detail::lifecycle().loader_context() != detail::LoaderContext::ProcessExit)
779 {
780
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 19 taken 116 times.
117 if (const std::shared_ptr<detail::InputPoller> poller =
781 117 impl->m_active.load(std::memory_order_acquire))
782 {
783 1 poller->shutdown();
784 117 }
785 }
786 118 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Input);
787 #if defined(DMK_ENABLE_TEST_SEAMS)
788 118 s_vetoed_retained_impl.store(impl, std::memory_order_release);
789 #endif
790 118 return;
791 }
792
793 752 std::shared_ptr<detail::InputPoller> local_poller;
794 752 std::vector<detail::InputBinding> retired;
795
796 {
797 752 std::lock_guard lock(m_impl->m_mutex);
798 // Clear the atomic shared_ptr before releasing the poller so a concurrent is_active() caller holds a
799 // valid shared_ptr.
800 752 m_impl->m_active.store(nullptr, std::memory_order_release);
801 752 m_impl->m_running.store(false, std::memory_order_release);
802 1504 local_poller = std::move(m_impl->m_poller);
803
2/2
✓ Branch 39 → 40 taken 83 times.
✓ Branch 39 → 42 taken 669 times.
752 if (!m_impl->m_pending.empty())
804 {
805 83 m_impl->advance_start_revision();
806 }
807 752 retired.swap(m_impl->m_pending);
808 752 }
809 // Drop staged capture owners before poller shutdown.
810 752 retired.clear();
811
812
2/2
✓ Branch 47 → 48 taken 192 times.
✓ Branch 47 → 62 taken 560 times.
752 if (local_poller)
813 {
814 192 local_poller->shutdown();
815
816
2/2
✓ Branch 52 → 53 taken 3 times.
✓ Branch 52 → 62 taken 189 times.
192 if (local_poller->self_retiring())
817 {
818 // shutdown() was reached from a binding callback, so this thread IS the poll thread. Its rundown
819 // (join, detour uninstall, final on_state_change(false)) must happen after the callback returns
820 // and off this thread. Hand the facade's reference to the process-lifetime reaper, which drops it
821 // once shutdown() has joined the body there; ~InputPoller then sees a completed rundown.
822 3 std::shared_ptr<void> owner = std::move(local_poller);
823 3 const auto retire = [](void *raw_owner) noexcept -> bool
824 {
825 3 auto *const poller = static_cast<detail::InputPoller *>(raw_owner);
826 3 poller->shutdown();
827 3 return !poller->requires_abandonment();
828 };
829
1/2
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 3 times.
3 if (!detail::reap_shared_owner(owner, retire))
830 {
831 // The precommitted self-keepalive retains the complete poller when no reaper can accept it.
832 // Stop was already requested, so the loop exits after this callback without losing its state.
833 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Input);
834 }
835 3 }
836 }
837 752 }
838
839 263 bool Input::is_running() const noexcept
840 {
841
4/4
✓ Branch 3 → 4 taken 257 times.
✓ Branch 3 → 8 taken 5 times.
✓ Branch 6 → 7 taken 234 times.
✓ Branch 6 → 8 taken 23 times.
263 return !is_inert() && m_impl->m_running.load(std::memory_order_acquire);
842 }
843
844 56588 std::size_t Input::binding_count() const noexcept
845 {
846
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 57088 times.
56588 if (is_inert())
847 {
848 3 return 0;
849 }
850
851 57088 std::shared_ptr<detail::InputPoller> live_poller;
852 {
853 57088 std::lock_guard lock(m_impl->m_mutex);
854
2/2
✓ Branch 9 → 10 taken 294 times.
✓ Branch 9 → 13 taken 58108 times.
58402 if (!m_impl->m_poller)
855 {
856 294 return m_impl->m_pending.size();
857 }
858 58108 live_poller = m_impl->m_poller;
859
2/2
✓ Branch 17 → 18 taken 58081 times.
✓ Branch 17 → 21 taken 288 times.
58402 }
860 58081 return live_poller->binding_count();
861 58165 }
862
863 57672 bool Input::is_active(std::string_view name) const noexcept
864 {
865 57672 auto active_poller = poller_snapshot();
866
2/2
✓ Branch 4 → 5 taken 57934 times.
✓ Branch 4 → 8 taken 208 times.
58136 return active_poller ? active_poller->is_binding_active(name) : false;
867 57178 }
868
869 20 BindingToken Input::acquire_token(std::string_view name) const noexcept
870 {
871 20 auto active_poller = poller_snapshot();
872
2/2
✓ Branch 4 → 5 taken 15 times.
✓ Branch 4 → 7 taken 5 times.
20 return active_poller ? active_poller->acquire_binding_token(name) : BindingToken{};
873 20 }
874
875 6 bool Input::is_active(const BindingToken &token) const noexcept
876 {
877 6 auto active_poller = poller_snapshot();
878
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 8 taken 1 time.
6 return active_poller ? active_poller->is_binding_active(token) : false;
879 6 }
880
881 32 bool Input::token_current(const BindingToken &token) const noexcept
882 {
883 32 auto active_poller = poller_snapshot();
884
2/2
✓ Branch 4 → 5 taken 29 times.
✓ Branch 4 → 8 taken 3 times.
32 return active_poller ? active_poller->binding_token_current(token) : false;
885 32 }
886
887 3 ConsumeCapacity Input::consume_capacity() const noexcept
888 {
889 3 const auto active_poller = poller_snapshot();
890
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 7 taken 2 times.
3 return active_poller ? active_poller->consume_capacity() : ConsumeCapacity{};
891 3 }
892
893 28 Input::WheelSourceHealth Input::wheel_source_health() const noexcept
894 {
895 28 const auto active_poller = poller_snapshot();
896
2/2
✓ Branch 4 → 5 taken 26 times.
✓ Branch 4 → 7 taken 2 times.
28 return active_poller ? active_poller->wheel_source_health() : WheelSourceHealth::Inactive;
897 28 }
898
899 118583 bool Input::is_inert() const noexcept
900 {
901 118583 const Impl *const impl = m_impl.get();
902
3/4
✓ Branch 3 → 4 taken 119527 times.
✓ Branch 3 → 6 taken 193 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 119986 times.
119720 return impl == nullptr || impl->m_vetoed_retained.load(std::memory_order_acquire);
903 }
904
905 57734 std::shared_ptr<detail::InputPoller> Input::poller_snapshot() const noexcept
906 {
907
2/2
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 57778 times.
57734 return is_inert() ? nullptr : m_impl->m_active.load(std::memory_order_acquire);
908 }
909
910 2059 Result<void> Input::rebind(std::string_view name, KeyComboList combos) noexcept
911 {
912
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 7 taken 2057 times.
2059 if (is_inert())
913 {
914 2 return std::unexpected(Error{ErrorCode::InvalidArg, "input::rebind"});
915 }
916
917 2057 std::shared_ptr<detail::InputPoller> local_poller;
918 2057 std::vector<detail::InputBinding> retired;
919 2057 std::vector<detail::InputBinding> rebuilt;
920
921 try
922 {
923
1/2
✓ Branch 8 → 9 taken 2057 times.
✗ Branch 8 → 234 not taken.
2057 std::unique_lock lock(m_impl->m_mutex);
924
2/2
✓ Branch 11 → 12 taken 2007 times.
✓ Branch 11 → 14 taken 50 times.
2057 if (m_impl->m_poller)
925 {
926 2007 local_poller = m_impl->m_poller;
927 }
928 else
929 {
930 // Apply to pending bindings (the first INI load typically runs before start()).
931 50 std::vector<std::size_t> indices;
932
1/2
✓ Branch 16 → 17 taken 50 times.
✗ Branch 16 → 229 not taken.
50 indices.reserve(m_impl->m_pending.size());
933
2/2
✓ Branch 27 → 18 taken 54 times.
✓ Branch 27 → 28 taken 50 times.
104 for (std::size_t i = 0; i < m_impl->m_pending.size(); ++i)
934 {
935
1/2
✓ Branch 22 → 23 taken 54 times.
✗ Branch 22 → 24 not taken.
54 if (m_impl->m_pending[i].name == name)
936 {
937
1/2
✓ Branch 23 → 24 taken 54 times.
✗ Branch 23 → 209 not taken.
54 indices.push_back(i);
938 }
939 }
940
2/2
✓ Branch 29 → 30 taken 2 times.
✓ Branch 29 → 36 taken 48 times.
50 if (indices.empty())
941 {
942
1/2
✓ Branch 30 → 31 taken 2 times.
✗ Branch 30 → 229 not taken.
2 lock.unlock();
943 2 (void)log()
944 2 .try_log(LogLevel::Debug, "input::Input: rebind(\"{}\") ignored: name not found", name);
945 2 return std::unexpected(Error{ErrorCode::InvalidArg, "input::rebind"});
946 }
947
948
2/2
✓ Branch 38 → 39 taken 34 times.
✓ Branch 38 → 73 taken 14 times.
48 if (indices.size() == combos.size())
949 {
950 // Each replacement copies the entry's gate reference, so the locked overwrite destroys no
951 // consumer callable.
952 34 std::vector<detail::InputBinding> replacements;
953
1/2
✓ Branch 40 → 41 taken 34 times.
✗ Branch 40 → 213 not taken.
34 replacements.reserve(indices.size());
954
2/2
✓ Branch 56 → 42 taken 34 times.
✓ Branch 56 → 57 taken 34 times.
68 for (std::size_t i = 0; i < indices.size(); ++i)
955 {
956
1/2
✓ Branch 45 → 46 taken 34 times.
✗ Branch 45 → 212 not taken.
34 detail::InputBinding binding = m_impl->m_pending[indices[i]];
957
1/2
✓ Branch 47 → 48 taken 34 times.
✗ Branch 47 → 210 not taken.
34 binding.keys = combos[i].keys;
958
1/2
✓ Branch 49 → 50 taken 34 times.
✗ Branch 49 → 210 not taken.
34 binding.modifiers = combos[i].modifiers;
959
1/2
✓ Branch 52 → 53 taken 34 times.
✗ Branch 52 → 210 not taken.
34 replacements.push_back(std::move(binding));
960 34 }
961
2/2
✓ Branch 67 → 58 taken 34 times.
✓ Branch 67 → 68 taken 34 times.
68 for (std::size_t i = 0; i < indices.size(); ++i)
962 {
963 68 m_impl->m_pending[indices[i]] = std::move(replacements[i]);
964 }
965 34 m_impl->advance_start_revision();
966 34 return {};
967 34 }
968
969 // Cardinality change: rebuild the pending list. An empty replacement keeps one inert sentinel so
970 // the name stays addressable for a later non-empty update.
971
1/2
✓ Branch 76 → 77 taken 14 times.
✗ Branch 76 → 229 not taken.
14 detail::InputBinding prototype = m_impl->m_pending[indices.front()];
972
1/2
✓ Branch 79 → 80 taken 14 times.
✗ Branch 79 → 227 not taken.
14 std::sort(indices.begin(), indices.end());
973
974
2/2
✓ Branch 81 → 82 taken 7 times.
✓ Branch 81 → 83 taken 7 times.
14 const std::size_t append_count = combos.empty() ? 1 : combos.size();
975 14 std::vector<detail::InputBinding> appended;
976
1/2
✓ Branch 84 → 85 taken 14 times.
✗ Branch 84 → 225 not taken.
14 appended.reserve(append_count);
977
2/2
✓ Branch 86 → 87 taken 7 times.
✓ Branch 86 → 95 taken 7 times.
14 if (combos.empty())
978 {
979
1/2
✓ Branch 87 → 88 taken 7 times.
✗ Branch 87 → 218 not taken.
7 detail::InputBinding sentinel = prototype;
980 7 sentinel.keys.clear();
981 7 sentinel.modifiers.clear();
982
1/2
✓ Branch 92 → 93 taken 7 times.
✗ Branch 92 → 216 not taken.
7 appended.push_back(std::move(sentinel));
983 7 }
984 else
985 {
986
2/2
✓ Branch 115 → 97 taken 9 times.
✓ Branch 115 → 116 taken 7 times.
23 for (const auto &combo : combos)
987 {
988
1/2
✓ Branch 99 → 100 taken 9 times.
✗ Branch 99 → 221 not taken.
9 detail::InputBinding binding = prototype;
989
1/2
✓ Branch 100 → 101 taken 9 times.
✗ Branch 100 → 219 not taken.
9 binding.keys = combo.keys;
990
1/2
✓ Branch 101 → 102 taken 9 times.
✗ Branch 101 → 219 not taken.
9 binding.modifiers = combo.modifiers;
991
1/2
✓ Branch 104 → 105 taken 9 times.
✗ Branch 104 → 219 not taken.
9 appended.push_back(std::move(binding));
992 9 }
993 }
994
995
1/2
✓ Branch 120 → 121 taken 14 times.
✗ Branch 120 → 225 not taken.
14 rebuilt.reserve(m_impl->m_pending.size() - indices.size() + append_count);
996
1/2
✓ Branch 122 → 123 taken 14 times.
✗ Branch 122 → 225 not taken.
14 retired.reserve(indices.size());
997 14 std::size_t cursor = 0;
998
2/2
✓ Branch 149 → 125 taken 20 times.
✓ Branch 149 → 150 taken 14 times.
48 for (std::size_t skip : indices)
999 {
1000
1/2
✗ Branch 134 → 128 not taken.
✓ Branch 134 → 135 taken 20 times.
20 for (std::size_t i = cursor; i < skip; ++i)
1001 {
1002 rebuilt.push_back(std::move(m_impl->m_pending[i]));
1003 }
1004
1/2
✓ Branch 139 → 140 taken 20 times.
✗ Branch 139 → 223 not taken.
40 retired.push_back(std::move(m_impl->m_pending[skip]));
1005 20 cursor = skip + 1;
1006 }
1007
1/2
✗ Branch 159 → 151 not taken.
✓ Branch 159 → 160 taken 14 times.
14 for (std::size_t i = cursor; i < m_impl->m_pending.size(); ++i)
1008 {
1009 rebuilt.push_back(std::move(m_impl->m_pending[i]));
1010 }
1011
2/2
✓ Branch 176 → 162 taken 16 times.
✓ Branch 176 → 177 taken 14 times.
60 for (auto &binding : appended)
1012 {
1013
1/2
✓ Branch 166 → 167 taken 16 times.
✗ Branch 166 → 224 not taken.
16 rebuilt.push_back(std::move(binding));
1014 }
1015 14 m_impl->m_pending.swap(rebuilt);
1016 14 m_impl->advance_start_revision();
1017 14 return {};
1018 50 }
1019
2/2
✓ Branch 188 → 189 taken 2007 times.
✓ Branch 188 → 193 taken 50 times.
2057 }
1020 catch (...)
1021 {
1022 // rebind is noexcept-friendly via Result; on out-of-memory the pending bindings are left unchanged
1023 // (allocation precedes the move-commit).
1024 (void)log().try_log(LogLevel::Error, "input::Input: out of memory in rebind; bindings unchanged");
1025 return std::unexpected(Error{ErrorCode::OutOfMemory, "input::rebind"});
1026 }
1027
1028 // Forward to the live poller outside m_mutex. Preserve the caller and resource failure classes.
1029
3/4
✓ Branch 192 → 194 taken 2005 times.
✓ Branch 192 → 195 taken 1 time.
✓ Branch 192 → 198 taken 1 time.
✗ Branch 192 → 201 not taken.
2007 switch (local_poller->update_combos(name, combos))
1030 {
1031 2005 case detail::InputPoller::ComboUpdate::Updated:
1032 2005 return {};
1033 1 case detail::InputPoller::ComboUpdate::NameAbsent:
1034 1 return std::unexpected(Error{ErrorCode::InvalidArg, "input::rebind"});
1035 1 case detail::InputPoller::ComboUpdate::ResourceFailure:
1036 1 return std::unexpected(Error{ErrorCode::OutOfMemory, "input::rebind"});
1037 }
1038 return std::unexpected(Error{ErrorCode::InvalidArg, "input::rebind"});
1039 2057 }
1040
1041 17 void Input::set_consume(std::string_view name, bool consume) noexcept
1042 {
1043
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 15 times.
17 if (is_inert())
1044 {
1045 10 return;
1046 }
1047
1048 15 std::shared_ptr<detail::InputPoller> live_poller;
1049
1050 {
1051 15 std::lock_guard lock(m_impl->m_mutex);
1052
2/2
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 12 taken 8 times.
15 if (m_impl->m_poller)
1053 {
1054 7 live_poller = m_impl->m_poller;
1055 }
1056 else
1057 {
1058 8 bool changed = false;
1059
2/2
✓ Branch 34 → 15 taken 8 times.
✓ Branch 34 → 35 taken 8 times.
24 for (auto &binding : m_impl->m_pending)
1060 {
1061
6/6
✓ Branch 19 → 20 taken 7 times.
✓ Branch 19 → 22 taken 1 time.
✓ Branch 20 → 21 taken 4 times.
✓ Branch 20 → 22 taken 3 times.
✓ Branch 23 → 24 taken 4 times.
✓ Branch 23 → 25 taken 4 times.
8 if (binding.name == name && binding.consume != consume)
1062 {
1063 4 binding.consume = consume;
1064 4 changed = true;
1065 }
1066 }
1067
2/2
✓ Branch 35 → 36 taken 4 times.
✓ Branch 35 → 38 taken 4 times.
8 if (changed)
1068 {
1069 4 m_impl->advance_start_revision();
1070 }
1071 8 return;
1072 }
1073
2/2
✓ Branch 41 → 42 taken 7 times.
✓ Branch 41 → 46 taken 8 times.
15 }
1074
1075 // Forward outside m_mutex so the poller's exclusive binding lock cannot deadlock against a caller holding
1076 // m_mutex (matches register_combo).
1077 7 live_poller->set_consume(name, consume);
1078
2/2
✓ Branch 48 → 49 taken 7 times.
✓ Branch 48 → 51 taken 8 times.
15 }
1079
1080 111 void Input::set_consume_by_owner(std::uint64_t owner, bool consume) noexcept
1081 {
1082 // Identity-keyed counterpart to set_consume(name), used by a consume guard's teardown so an empty-name
1083 // binding (absent from the name index) still has its suppression lifted. Mirrors set_consume's live-vs-
1084 // pending routing: clear on the live poller if running, else on the staged bindings for the next start().
1085
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 111 times.
111 if (is_inert())
1086 {
1087 1 return;
1088 }
1089
1090 111 std::shared_ptr<detail::InputPoller> live_poller;
1091
1092 {
1093 111 std::lock_guard lock(m_impl->m_mutex);
1094
2/2
✓ Branch 9 → 10 taken 110 times.
✓ Branch 9 → 12 taken 1 time.
111 if (m_impl->m_poller)
1095 {
1096 110 live_poller = m_impl->m_poller;
1097 }
1098 else
1099 {
1100 1 bool changed = false;
1101
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 32 not taken.
1 if (owner != 0)
1102 {
1103
1/2
✗ Branch 30 → 16 not taken.
✓ Branch 30 → 31 taken 1 time.
2 for (auto &binding : m_impl->m_pending)
1104 {
1105 if (binding.consume_owner == owner && binding.consume != consume)
1106 {
1107 binding.consume = consume;
1108 changed = true;
1109 }
1110 }
1111 }
1112
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 35 taken 1 time.
1 if (changed)
1113 {
1114 m_impl->advance_start_revision();
1115 }
1116 1 return;
1117 }
1118
2/2
✓ Branch 38 → 39 taken 110 times.
✓ Branch 38 → 43 taken 1 time.
111 }
1119
1120 // Forward outside m_mutex so the poller's exclusive binding lock cannot deadlock against a caller holding
1121 // m_mutex (matches register_combo).
1122 110 live_poller->set_consume_by_owner(owner, consume);
1123
2/2
✓ Branch 45 → 46 taken 110 times.
✓ Branch 45 → 48 taken 1 time.
111 }
1124
1125 100 void Input::set_require_focus(bool require_focus) noexcept
1126 {
1127
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 98 times.
100 if (is_inert())
1128 {
1129 2 return;
1130 }
1131
1132 98 std::lock_guard lock(m_impl->m_mutex);
1133
2/2
✓ Branch 8 → 9 taken 18 times.
✓ Branch 8 → 12 taken 80 times.
98 if (m_impl->m_settings.require_focus != require_focus)
1134 {
1135 18 m_impl->m_settings.require_focus = require_focus;
1136 18 m_impl->advance_start_revision();
1137 }
1138
2/2
✓ Branch 14 → 15 taken 7 times.
✓ Branch 14 → 18 taken 91 times.
98 if (m_impl->m_poller)
1139 {
1140 7 m_impl->m_poller->set_require_focus(require_focus);
1141 }
1142 98 }
1143
1144 429 std::size_t Input::remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept
1145 {
1146
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 427 times.
429 if (is_inert())
1147 {
1148 2 return 0;
1149 }
1150
1151 427 std::shared_ptr<detail::InputPoller> live_poller;
1152 427 std::size_t removed_pending = 0;
1153 427 std::vector<detail::InputBinding> retired;
1154 427 std::vector<detail::InputBinding> staged;
1155
1156 try
1157 {
1158
1/2
✓ Branch 6 → 7 taken 427 times.
✗ Branch 6 → 60 not taken.
427 std::lock_guard lock(m_impl->m_mutex);
1159
2/2
✓ Branch 9 → 10 taken 409 times.
✓ Branch 9 → 12 taken 18 times.
427 if (m_impl->m_poller)
1160 {
1161 409 live_poller = m_impl->m_poller;
1162 }
1163 else
1164 {
1165 54 removed_pending = static_cast<std::size_t>(std::ranges::count_if(
1166
1/2
✓ Branch 13 → 14 taken 18 times.
✗ Branch 13 → 58 not taken.
18 m_impl->m_pending,
1167 22 [name](const detail::InputBinding &b) { return b.name == name; }
1168 ));
1169
2/2
✓ Branch 14 → 15 taken 16 times.
✓ Branch 14 → 46 taken 2 times.
18 if (removed_pending != 0)
1170 {
1171 // Before mutation, reserve both batches so allocation failure preserves the staged set.
1172
2/2
✓ Branch 15 → 16 taken 15 times.
✓ Branch 15 → 58 taken 1 time.
16 retired.reserve(removed_pending);
1173
2/2
✓ Branch 18 → 19 taken 14 times.
✓ Branch 18 → 58 taken 1 time.
15 staged.reserve(m_impl->m_pending.size() - removed_pending);
1174
2/2
✓ Branch 41 → 22 taken 18 times.
✓ Branch 41 → 42 taken 14 times.
46 for (detail::InputBinding &entry : m_impl->m_pending)
1175 {
1176
3/4
✓ Branch 26 → 27 taken 14 times.
✓ Branch 26 → 28 taken 4 times.
✓ Branch 31 → 32 taken 18 times.
✗ Branch 31 → 57 not taken.
36 (entry.name == name ? retired : staged).push_back(std::move(entry));
1177 }
1178 14 m_impl->m_pending.swap(staged);
1179 14 m_impl->advance_start_revision();
1180 }
1181 }
1182 427 }
1183 2 catch (...)
1184 {
1185 2 (void)log().try_log(
1186 LogLevel::Error,
1187 "input::Input: out of memory in remove_bindings_by_name. Bindings unchanged"
1188 );
1189 2 return 0;
1190 2 }
1191
1192
2/2
✓ Branch 48 → 49 taken 409 times.
✓ Branch 48 → 51 taken 16 times.
425 if (live_poller)
1193 {
1194 409 return live_poller->remove_bindings_by_name(name, invoke_callbacks);
1195 }
1196 16 return removed_pending;
1197 426 }
1198
1199 128 void Input::clear_bindings(bool invoke_callbacks) noexcept
1200 {
1201
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 126 times.
128 if (is_inert())
1202 {
1203 2 return;
1204 }
1205
1206 126 std::shared_ptr<detail::InputPoller> live_poller;
1207 126 std::vector<detail::InputBinding> retired;
1208
1209 {
1210 126 std::lock_guard lock(m_impl->m_mutex);
1211
2/2
✓ Branch 9 → 10 taken 6 times.
✓ Branch 9 → 12 taken 120 times.
126 if (!m_impl->m_pending.empty())
1212 {
1213 6 m_impl->advance_start_revision();
1214 }
1215 126 retired.swap(m_impl->m_pending);
1216
2/2
✓ Branch 16 → 17 taken 111 times.
✓ Branch 16 → 19 taken 15 times.
126 if (m_impl->m_poller)
1217 {
1218 111 live_poller = m_impl->m_poller;
1219 }
1220 126 }
1221 126 retired.clear();
1222
1223
2/2
✓ Branch 22 → 23 taken 111 times.
✓ Branch 22 → 25 taken 15 times.
126 if (live_poller)
1224 {
1225 111 live_poller->clear_bindings(invoke_callbacks);
1226 }
1227 126 }
1228
1229 142 bool Input::retire_gates_for_unload(
1230 std::span<const std::string_view> binding_names,
1231 bool every_binding,
1232 std::chrono::steady_clock::time_point deadline
1233 ) noexcept
1234 {
1235
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 142 times.
142 if (is_inert())
1236 {
1237 return true;
1238 }
1239
1240 142 std::shared_ptr<detail::InputPoller> live_poller;
1241 142 std::vector<std::shared_ptr<detail::BindingGate>> pending_gates;
1242 142 bool collected = true;
1243
1244 {
1245 142 std::lock_guard lock(m_impl->m_mutex);
1246 142 live_poller = m_impl->m_poller;
1247
2/2
✓ Branch 10 → 11 taken 28 times.
✓ Branch 10 → 39 taken 114 times.
142 if (!live_poller)
1248 {
1249 // Staged but never started: the gates exist and the guards are already handed out, so a pending
1250 // binding's callback outlives removal exactly as a live one does.
1251 try
1252 {
1253
2/2
✓ Branch 37 → 14 taken 13 times.
✓ Branch 37 → 38 taken 28 times.
69 for (const detail::InputBinding &staged : m_impl->m_pending)
1254 {
1255 const bool selected =
1256
5/6
✓ Branch 16 → 17 taken 8 times.
✓ Branch 16 → 19 taken 5 times.
✓ Branch 17 → 18 taken 8 times.
✗ Branch 17 → 87 not taken.
✓ Branch 18 → 19 taken 6 times.
✓ Branch 18 → 20 taken 2 times.
13 every_binding || std::ranges::any_of(
1257 binding_names,
1258 6 [&staged](std::string_view name) { return staged.name == name; }
1259 13 );
1260
5/6
✓ Branch 21 → 22 taken 11 times.
✓ Branch 21 → 25 taken 2 times.
✓ Branch 23 → 24 taken 11 times.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 27 taken 11 times.
✓ Branch 26 → 28 taken 2 times.
13 if (selected && staged.gate)
1261 {
1262
1/2
✓ Branch 27 → 28 taken 11 times.
✗ Branch 27 → 87 not taken.
11 pending_gates.push_back(staged.gate);
1263 }
1264 }
1265 }
1266 catch (...)
1267 {
1268 collected = false;
1269 }
1270 }
1271 142 }
1272
1273
2/2
✓ Branch 41 → 42 taken 114 times.
✓ Branch 41 → 64 taken 28 times.
142 if (live_poller)
1274 {
1275
2/2
✓ Branch 42 → 43 taken 110 times.
✓ Branch 42 → 45 taken 4 times.
114 if (every_binding)
1276 {
1277 110 return live_poller->retire_all_gates(deadline);
1278 }
1279 4 bool retired_all = true;
1280
2/2
✓ Branch 62 → 47 taken 4 times.
✓ Branch 62 → 63 taken 4 times.
12 for (const std::string_view name : binding_names)
1281 {
1282
2/2
✓ Branch 51 → 52 taken 1 time.
✓ Branch 51 → 53 taken 3 times.
4 if (!live_poller->retire_gates_by_name(name, deadline))
1283 {
1284 1 retired_all = false;
1285 }
1286 }
1287 4 return retired_all;
1288 }
1289
1290 // Off m_mutex: a retired hold delivers its balancing edge, and that consumer code may call back into the
1291 // facade. The gates are kept alive by the copies taken above, so a concurrent clear cannot free them here.
1292
2/2
✓ Branch 81 → 66 taken 11 times.
✓ Branch 81 → 82 taken 28 times.
67 for (const auto &gate : pending_gates)
1293 {
1294 try
1295 {
1296
2/4
✓ Branch 69 → 70 taken 11 times.
✗ Branch 69 → 91 not taken.
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 11 times.
11 if (!gate->retire(deadline))
1297 {
1298 collected = false;
1299 }
1300 }
1301 catch (...)
1302 {
1303 // retire() moved the callback out before invoking it, so it is destroyed even on this path and
1304 // only the consumer's balancing edge failed.
1305 }
1306 }
1307 28 return collected;
1308 142 }
1309
1310 19 CallbackDrainStatus Input::prepare_logic_dll_unload(
1311 std::span<const std::string_view> binding_names,
1312 std::chrono::milliseconds timeout
1313 ) noexcept
1314 {
1315
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 19 times.
19 if (detail::current_thread_in_delivery())
1316 {
1317 return CallbackDrainStatus::SelfDelivery;
1318 }
1319
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 19 times.
19 if (is_inert())
1320 {
1321 return CallbackDrainStatus::Drained;
1322 }
1323
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 19 times.
19 if (m_impl->m_callback_drain_active.exchange(true, std::memory_order_seq_cst))
1324 {
1325 return CallbackDrainStatus::InProgress;
1326 }
1327
1328 19 detail::mark_input_callback_drain_pending();
1329 19 const auto deadline = detail::drain_deadline(timeout);
1330
1331 19 CallbackDrainStatus status = CallbackDrainStatus::Drained;
1332 // Both refusals are the same outcome, so they share one branch and short-circuit order keeps the admission
1333 // check first. An unretired gate means either a selected binding was still delivering at the deadline, in
1334 // which case its callback is deliberately left alive because destroying a callable a poll thread is
1335 // executing would free the code out from under it, or the gate handles could not be collected at all under
1336 // memory pressure. Neither outcome has established that the callbacks are gone, so both refuse the unmap.
1337
3/4
✓ Branch 16 → 17 taken 19 times.
✗ Branch 16 → 19 not taken.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 18 times.
38 if (!await_admission_commits(m_impl->m_admission_commits_inflight, deadline) ||
1338
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 18 times.
19 !retire_gates_for_unload(binding_names, false, deadline))
1339 {
1340 1 status = CallbackDrainStatus::TimedOut;
1341 }
1342 else
1343 {
1344 18 bool retire_failed = false;
1345
2/2
✓ Branch 59 → 25 taken 10 times.
✓ Branch 59 → 60 taken 18 times.
46 for (const std::string_view name : binding_names)
1346 {
1347 10 (void)remove_bindings_by_name(name, false);
1348
1349 10 std::shared_ptr<detail::InputPoller> live_poller;
1350 10 bool pending_match = false;
1351 {
1352 10 std::lock_guard lock(m_impl->m_mutex);
1353 30 pending_match = std::ranges::any_of(
1354 10 m_impl->m_pending,
1355 [name](const detail::InputBinding &binding) { return binding.name == name; }
1356 );
1357 10 live_poller = m_impl->m_poller;
1358 10 }
1359
5/8
✓ Branch 35 → 36 taken 10 times.
✗ Branch 35 → 41 not taken.
✓ Branch 37 → 38 taken 3 times.
✓ Branch 37 → 42 taken 7 times.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 3 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 10 times.
10 if (pending_match || (live_poller && live_poller->has_bindings_by_name(name)))
1360 {
1361 retire_failed = true;
1362 break;
1363 }
1364
1/2
✓ Branch 47 → 48 taken 10 times.
✗ Branch 47 → 52 not taken.
10 }
1365
1366
1/2
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 62 taken 18 times.
18 if (retire_failed)
1367 {
1368 status = CallbackDrainStatus::RetireFailed;
1369 }
1370
2/2
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 65 taken 17 times.
18 else if (!detail::await_staged_input_callbacks(deadline))
1371 {
1372 1 status = CallbackDrainStatus::TimedOut;
1373 }
1374 }
1375
1376
2/2
✓ Branch 65 → 66 taken 17 times.
✓ Branch 65 → 67 taken 2 times.
19 if (status == CallbackDrainStatus::Drained)
1377 {
1378 17 detail::resolve_input_callback_drain();
1379 }
1380
1381 19 m_impl->m_callback_drain_active.store(false, std::memory_order_release);
1382 19 return status;
1383 }
1384
1385 124 CallbackDrainStatus Input::prepare_logic_dll_unload_all(std::chrono::milliseconds timeout) noexcept
1386 {
1387
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 124 times.
124 if (detail::current_thread_in_delivery())
1388 {
1389 return CallbackDrainStatus::SelfDelivery;
1390 }
1391
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 124 times.
124 if (is_inert())
1392 {
1393 return CallbackDrainStatus::Drained;
1394 }
1395
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 124 times.
124 if (m_impl->m_callback_drain_active.exchange(true, std::memory_order_seq_cst))
1396 {
1397 return CallbackDrainStatus::InProgress;
1398 }
1399
1400 124 detail::mark_input_callback_drain_pending();
1401 124 const auto deadline = detail::drain_deadline(timeout);
1402
1403 124 CallbackDrainStatus status = CallbackDrainStatus::Drained;
1404 // One branch for both refusals, for the reason given in prepare_logic_dll_unload.
1405
2/2
✓ Branch 16 → 17 taken 123 times.
✓ Branch 16 → 20 taken 1 time.
247 if (!await_admission_commits(m_impl->m_admission_commits_inflight, deadline) ||
1406
4/4
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 21 taken 121 times.
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 24 taken 121 times.
247 !retire_gates_for_unload({}, true, deadline))
1407 {
1408 3 status = CallbackDrainStatus::TimedOut;
1409 }
1410 else
1411 {
1412 121 clear_bindings(false);
1413
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 121 times.
121 if (binding_count() != 0)
1414 {
1415 status = CallbackDrainStatus::RetireFailed;
1416 }
1417
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 121 times.
121 else if (!detail::await_staged_input_callbacks(deadline))
1418 {
1419 status = CallbackDrainStatus::TimedOut;
1420 }
1421 }
1422
1423
2/2
✓ Branch 31 → 32 taken 121 times.
✓ Branch 31 → 33 taken 3 times.
124 if (status == CallbackDrainStatus::Drained)
1424 {
1425 121 detail::resolve_input_callback_drain();
1426 }
1427
1428 124 m_impl->m_callback_drain_active.store(false, std::memory_order_release);
1429 124 return status;
1430 }
1431
1432 // Free-function ergonomics
1433
1434 814 Result<BindingGuard> register_combo(ComboBinding binding) noexcept
1435 {
1436 1628 return Input::instance().register_combo(std::move(binding));
1437 }
1438
1439 1 Scope &scope() noexcept
1440 {
1441 alignas(Scope) static unsigned char storage[sizeof(Scope)];
1442
3/6
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 10 not taken.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
1 static Scope *const process_scope = ::new (static_cast<void *>(storage)) Scope();
1443 1 return *process_scope;
1444 }
1445 } // namespace input
1446 } // namespace DetourModKit
1447
1448 #if defined(DMK_ENABLE_TEST_SEAMS)
1449 namespace DetourModKit::detail
1450 {
1451 // Friend-accessor bodies live beside the facade state they reach. The unnamed-namespace seam objects above are
1452 // reachable through the input namespace by qualified lookup.
1453 21 void InputTestSeams::set_callback_admission_commit_seam_for_test(CallbackAdmissionCommitSeam seam) noexcept
1454 {
1455 21 input::s_callback_admission_commit_seam.store(seam, std::memory_order_release);
1456 21 }
1457
1458 2 void InputTestSeams::lock_facade_mutex_for_test() noexcept
1459 {
1460 2 input::Input::Impl *const impl = input::Input::instance().m_impl.get();
1461 2 input::s_test_locked_impl = impl;
1462 2 impl->m_mutex.lock();
1463 2 }
1464
1465 1 void InputTestSeams::unlock_facade_mutex_for_test() noexcept
1466 {
1467 1 static_cast<input::Input::Impl *>(input::s_test_locked_impl)->m_mutex.unlock();
1468 1 input::s_test_locked_impl = nullptr;
1469 1 }
1470
1471 4 bool InputTestSeams::reclaim_vetoed_impl_for_test() noexcept
1472 {
1473 4 void *const retained = input::s_vetoed_retained_impl.exchange(nullptr, std::memory_order_acq_rel);
1474
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4 times.
4 if (retained == nullptr)
1475 {
1476 return false;
1477 }
1478 4 input::Input &self = input::Input::instance();
1479 4 auto *const impl = static_cast<input::Input::Impl *>(retained);
1480
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 4 times.
4 if (self.m_impl.get() != impl)
1481 {
1482 return false;
1483 }
1484 4 bool vetoed = true;
1485 return impl->m_vetoed_retained
1486 4 .compare_exchange_strong(vetoed, false, std::memory_order_acq_rel, std::memory_order_acquire);
1487 }
1488
1489 8 bool InputTestSeams::adopt_intercept_owner_for_test() noexcept
1490 {
1491 8 input::Input &self = input::Input::instance();
1492 8 std::shared_ptr<InputPoller> live_poller;
1493
1/2
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 11 not taken.
8 if (!self.is_inert())
1494 {
1495 8 std::lock_guard lock(self.m_impl->m_mutex);
1496 8 live_poller = self.m_impl->m_poller;
1497 8 }
1498
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 8 times.
8 if (!live_poller)
1499 {
1500 return false;
1501 }
1502
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 8 times.
8 if (!adopt_owner_for_test(live_poller->intercept_owner_for_test()))
1503 {
1504 return false;
1505 }
1506 8 live_poller->publish_consume_rules_for_test();
1507 8 return true;
1508 8 }
1509 } // namespace DetourModKit::detail
1510 #endif // DMK_ENABLE_TEST_SEAMS
1511