./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
681 / 740
92.0%
Branches:
475 / 602
78.9%
Functions:
75 / 75
100.0%

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