GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 84.5% 971 / 0 / 1149
Functions: 100.0% 64 / 0 / 64
Branches: 71.6% 684 / 0 / 955

src/internal/input_poller.cpp
Line Branch Exec Source
1 /**
2 * @file input_poller.cpp
3 * @brief This TU implements the internal input poll engine from input_poller.hpp.
4 *
5 * This engine drives the public input::Input facade. A background poll thread reads keyboard, mouse, gamepad, and
6 * mouse-wheel state. It detects press and hold edges under strict modifier rules and feeds the opt-in interception
7 * layer.
8 */
9
10 #include "input_poller.hpp"
11 #include "drain_backoff.hpp"
12 #include "input_delivery_scope.hpp"
13 #include "input_intercept.hpp"
14 #include "input_key_cache.hpp"
15 #include "lifecycle_context.hpp"
16 #include "platform.hpp"
17
18 #include "DetourModKit/diagnostics.hpp"
19 #include "DetourModKit/logger.hpp"
20
21 #include <windows.h>
22 #include <xinput.h>
23
24 #include <algorithm>
25 #include <array>
26 #include <atomic>
27 #include <cstdint>
28 #include <exception>
29 #include <limits>
30 #include <shared_mutex>
31 #include <system_error>
32 #include <thread>
33 #include <type_traits>
34 #include <unordered_set>
35
36 namespace DetourModKit
37 {
38 namespace detail
39 {
40 namespace
41 {
42 /**
43 * @brief Checks whether a single InputCode is currently pressed.
44 * @param wheel_pulse Per-cycle wheel pulse mask (bit 0 = WheelUp .. bit 3 = WheelRight), latched once
45 * per cycle so repeated reads within a cycle stay consistent.
46 */
47 803 bool is_code_pressed(
48 const InputCode &code,
49 KeyStateCache &key_cache,
50 const XINPUT_STATE &gamepad_state,
51 bool gamepad_connected,
52 int trigger_threshold,
53 int stick_threshold,
54 uint8_t wheel_pulse
55 ) noexcept
56 {
57
3/4
✓ Branch 2 → 3 taken 769 times.
✓ Branch 2 → 9 taken 24 times.
✓ Branch 2 → 13 taken 10 times.
✗ Branch 2 → 29 not taken.
803 switch (code.source)
58 {
59 769 case InputSource::Keyboard:
60 case InputSource::Mouse:
61 // The per-cycle cache makes a VK referenced by many bindings cost one GetAsyncKeyState call per
62 // cycle and gives the whole cycle one coherent sample.
63
3/4
✓ Branch 3 → 4 taken 769 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 753 times.
✓ Branch 5 → 7 taken 16 times.
1538 return code.code != 0 && key_cache.pressed(
64 769 code.code,
65 1531 [](int vk) noexcept
66 {
67 #ifdef DMK_ENABLE_TEST_SEAMS
68
2/2
✓ Branch 3 → 4 taken 756 times.
✓ Branch 3 → 6 taken 6 times.
762 if (g_input_key_state_probe)
69 {
70 756 return g_input_key_state_probe(vk);
71 }
72 #endif
73 6 return (GetAsyncKeyState(vk) & 0x8000) != 0;
74 }
75 769 );
76 24 case InputSource::MouseWheel:
77 {
78 // The wheel has no held state. The poll loop latches each notch into wheel_pulse. WheelCode values
79 // are 1-based and dense, so the direction index is code - WheelCode::Up.
80 24 const int dir = code.code - WheelCode::Up;
81
2/4
✓ Branch 9 → 10 taken 24 times.
✗ Branch 9 → 11 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 24 times.
24 if (dir < 0 || dir > 3)
82 {
83 return false;
84 }
85 24 return (wheel_pulse & (1u << dir)) != 0;
86 }
87 10 case InputSource::Gamepad:
88 {
89
1/2
✓ Branch 13 → 14 taken 10 times.
✗ Branch 13 → 15 not taken.
10 if (!gamepad_connected)
90 {
91 10 return false;
92 }
93 if (code.code < GamepadCode::LeftTrigger)
94 {
95 return (gamepad_state.Gamepad.wButtons & static_cast<WORD>(code.code)) != 0;
96 }
97 switch (code.code)
98 {
99 case GamepadCode::LeftTrigger:
100 return gamepad_state.Gamepad.bLeftTrigger > trigger_threshold;
101 case GamepadCode::RightTrigger:
102 return gamepad_state.Gamepad.bRightTrigger > trigger_threshold;
103 case GamepadCode::LeftStickUp:
104 return gamepad_state.Gamepad.sThumbLY > stick_threshold;
105 case GamepadCode::LeftStickDown:
106 return gamepad_state.Gamepad.sThumbLY < -stick_threshold;
107 case GamepadCode::LeftStickLeft:
108 return gamepad_state.Gamepad.sThumbLX < -stick_threshold;
109 case GamepadCode::LeftStickRight:
110 return gamepad_state.Gamepad.sThumbLX > stick_threshold;
111 case GamepadCode::RightStickUp:
112 return gamepad_state.Gamepad.sThumbRY > stick_threshold;
113 case GamepadCode::RightStickDown:
114 return gamepad_state.Gamepad.sThumbRY < -stick_threshold;
115 case GamepadCode::RightStickLeft:
116 return gamepad_state.Gamepad.sThumbRX < -stick_threshold;
117 case GamepadCode::RightStickRight:
118 return gamepad_state.Gamepad.sThumbRX > stick_threshold;
119 default:
120 return false;
121 }
122 }
123 }
124 return false;
125 }
126
127 /**
128 * @brief Checks if a held input satisfies a required modifier.
129 * @details Returns true when the codes match exactly. It also returns true for keyboard modifiers in the
130 * same family. For example, LShift satisfies generic Shift, and generic Shift satisfies LShift.
131 */
132 1 bool modifier_satisfies(const InputCode &required, const InputCode &held) noexcept
133 {
134
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 5 not taken.
1 if (required == held)
135 {
136 1 return true;
137 }
138 if (required.source != InputSource::Keyboard || held.source != InputSource::Keyboard)
139 {
140 return false;
141 }
142 // Each row groups the generic, left, and right modifier codes.
143 constexpr int families[][3] = {
144 {0x11, 0xA2, 0xA3}, // Ctrl, LCtrl, RCtrl
145 {0x10, 0xA0, 0xA1}, // Shift, LShift, RShift
146 {0x12, 0xA4, 0xA5}, // Alt, LAlt, RAlt
147 };
148 for (const auto &family : families)
149 {
150 bool req_in = false;
151 bool held_in = false;
152 for (int vk : family)
153 {
154 if (required.code == vk)
155 {
156 req_in = true;
157 }
158 if (held.code == vk)
159 {
160 held_in = true;
161 }
162 }
163 if (req_in && held_in)
164 {
165 return true;
166 }
167 }
168 return false;
169 }
170
171 /**
172 * @brief Reports whether any binding uses a gamepad InputCode.
173 * @param bindings Bindings to inspect.
174 * @return true when at least one binding contains a gamepad InputCode.
175 */
176 3488 bool scan_for_gamepad_bindings(const std::vector<InputBinding> &bindings) noexcept
177 {
178
2/2
✓ Branch 47 → 4 taken 49007 times.
✓ Branch 47 → 48 taken 3443 times.
55938 for (const auto &binding : bindings)
179 {
180
2/2
✓ Branch 21 → 8 taken 48676 times.
✓ Branch 21 → 22 taken 48962 times.
146645 for (const auto &key : binding.keys)
181 {
182
2/2
✓ Branch 10 → 11 taken 45 times.
✓ Branch 10 → 12 taken 48631 times.
48676 if (key.source == InputSource::Gamepad)
183 {
184 45 return true;
185 }
186 }
187
2/2
✓ Branch 37 → 24 taken 14 times.
✓ Branch 37 → 38 taken 48962 times.
97938 for (const auto &mod : binding.modifiers)
188 {
189
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 14 times.
14 if (mod.source == InputSource::Gamepad)
190 {
191 return true;
192 }
193 }
194 }
195 3443 return false;
196 }
197
198 /**
199 * @brief Reports whether any binding uses a mouse-wheel trigger.
200 * @details Wheel codes only appear as trigger keys (never modifiers), so modifiers are not scanned.
201 */
202 3488 bool scan_for_wheel_bindings(const std::vector<InputBinding> &bindings) noexcept
203 {
204
2/2
✓ Branch 31 → 4 taken 49177 times.
✓ Branch 31 → 32 taken 3365 times.
56030 for (const auto &binding : bindings)
205 {
206
2/2
✓ Branch 21 → 8 taken 48846 times.
✓ Branch 21 → 22 taken 49054 times.
147077 for (const auto &key : binding.keys)
207 {
208
2/2
✓ Branch 10 → 11 taken 123 times.
✓ Branch 10 → 12 taken 48723 times.
48846 if (key.source == InputSource::MouseWheel)
209 {
210 123 return true;
211 }
212 }
213 }
214 3365 return false;
215 }
216
217 /**
218 * @brief Reports whether any consume binding carries a suppressible gamepad button (gates the XInput hook).
219 * @details Only digital buttons gate it: the detour masks wButtons, so analog codes can never be
220 * cleared. Analog codes do not justify hook installation because the hook cannot mask them.
221 */
222 3488 bool scan_for_consume_gamepad_bindings(const std::vector<InputBinding> &bindings) noexcept
223 {
224
2/2
✓ Branch 36 → 4 taken 49013 times.
✓ Branch 36 → 37 taken 3464 times.
55965 for (const auto &binding : bindings)
225 {
226
2/2
✓ Branch 6 → 7 taken 48877 times.
✓ Branch 6 → 8 taken 136 times.
49013 if (!binding.consume)
227 {
228 48877 continue;
229 }
230
2/2
✓ Branch 25 → 10 taken 136 times.
✓ Branch 25 → 26 taken 112 times.
384 for (const auto &key : binding.keys)
231 {
232
5/6
✓ Branch 12 → 13 taken 26 times.
✓ Branch 12 → 16 taken 110 times.
✓ Branch 13 → 14 taken 26 times.
✗ Branch 13 → 16 not taken.
✓ Branch 14 → 15 taken 24 times.
✓ Branch 14 → 16 taken 2 times.
136 if (key.source == InputSource::Gamepad && key.code > 0 && key.code < GamepadCode::LeftTrigger)
233 {
234 24 return true;
235 }
236 }
237 }
238 3464 return false;
239 }
240
241 /**
242 * @brief Builds the detour-evaluable consume rule list from the current bindings.
243 * @details Rules exist only when every known modifier is a digital gamepad button available in wButtons.
244 * Otherwise, the detour cannot reproduce the poll loop's strict-match decision. The whole list
245 * is dropped. The reactive mask still covers the held-modifier case. A rule
246 * carries the chord's modifier bits, its digital trigger bits to clear, and a forbidden mask
247 * of every other known modifier bit. Exact-duplicate triples are emitted once, so the rule
248 * budget is a budget of distinct chord shapes.
249 */
250 3488 std::vector<GamepadConsumeRule> build_gamepad_consume_rules(
251 const std::vector<InputBinding> &bindings,
252 const std::vector<InputCode> &known_modifiers
253 )
254 {
255 359 const auto is_digital_gamepad = [](const InputCode &code) noexcept
256 {
257
5/6
✓ Branch 2 → 3 taken 238 times.
✓ Branch 2 → 6 taken 121 times.
✓ Branch 3 → 4 taken 238 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 236 times.
✓ Branch 4 → 6 taken 2 times.
359 return code.source == InputSource::Gamepad && code.code > 0 && code.code < GamepadCode::LeftTrigger;
258 };
259
260 3488 uint16_t known_mod_mask = 0;
261
2/2
✓ Branch 19 → 4 taken 62 times.
✓ Branch 19 → 20 taken 3477 times.
7027 for (const auto &mod : known_modifiers)
262 {
263
2/2
✓ Branch 7 → 8 taken 11 times.
✓ Branch 7 → 10 taken 51 times.
62 if (!is_digital_gamepad(mod))
264 {
265 11 return {};
266 }
267 51 known_mod_mask = static_cast<uint16_t>(known_mod_mask | static_cast<uint16_t>(mod.code));
268 }
269
270 3477 std::vector<GamepadConsumeRule> rules;
271
2/2
✓ Branch 83 → 22 taken 49155 times.
✓ Branch 83 → 84 taken 3477 times.
56109 for (const auto &binding : bindings)
272 {
273
2/2
✓ Branch 24 → 25 taken 48858 times.
✓ Branch 24 → 26 taken 297 times.
49155 if (!binding.consume)
274 {
275 48970 continue;
276 }
277 297 uint16_t trigger_mask = 0;
278
2/2
✓ Branch 42 → 28 taken 297 times.
✓ Branch 42 → 43 taken 297 times.
891 for (const auto &key : binding.keys)
279 {
280
2/2
✓ Branch 31 → 32 taken 185 times.
✓ Branch 31 → 33 taken 112 times.
297 if (is_digital_gamepad(key))
281 {
282 185 trigger_mask = static_cast<uint16_t>(trigger_mask | static_cast<uint16_t>(key.code));
283 }
284 }
285
2/2
✓ Branch 43 → 44 taken 112 times.
✓ Branch 43 → 45 taken 185 times.
297 if (trigger_mask == 0)
286 {
287 // No digital gamepad trigger exists to clear. The detour has nothing to mask here.
288 112 continue;
289 }
290 // Every modifier is digital here: the gate above returned an empty list otherwise.
291 185 uint16_t modifier_mask = 0;
292
2/2
✓ Branch 58 → 47 taken 336 times.
✓ Branch 58 → 59 taken 185 times.
706 for (const auto &mod : binding.modifiers)
293 {
294 336 modifier_mask = static_cast<uint16_t>(modifier_mask | static_cast<uint16_t>(mod.code));
295 }
296 185 const uint16_t forbidden_mask =
297 185 static_cast<uint16_t>(known_mod_mask & static_cast<uint16_t>(~modifier_mask));
298
1/2
✓ Branch 61 → 62 taken 185 times.
✗ Branch 61 → 89 not taken.
185 const auto duplicate = std::find_if(
299 rules.begin(),
300 rules.end(),
301 2149 [&](const GamepadConsumeRule &rule) noexcept
302 {
303
3/4
✓ Branch 2 → 3 taken 33 times.
✓ Branch 2 → 6 taken 2116 times.
✓ Branch 3 → 4 taken 33 times.
✗ Branch 3 → 6 not taken.
2182 return rule.modifier_mask == modifier_mask && rule.forbidden_mask == forbidden_mask &&
304
2/2
✓ Branch 4 → 5 taken 32 times.
✓ Branch 4 → 6 taken 1 time.
2182 rule.trigger_mask == trigger_mask;
305 }
306 );
307
2/2
✓ Branch 69 → 70 taken 153 times.
✓ Branch 69 → 72 taken 32 times.
370 if (duplicate == rules.end())
308 {
309
1/2
✓ Branch 70 → 71 taken 153 times.
✗ Branch 70 → 88 not taken.
153 rules.push_back(GamepadConsumeRule{modifier_mask, forbidden_mask, trigger_mask});
310 }
311 }
312 3477 return rules;
313 3477 }
314
315 // The release grace absorbs the modifier-before-trigger release window without a noticeable delay for a
316 // deliberate tap.
317 constexpr uint64_t GAMEPAD_SUPPRESS_GRACE_MS = 80;
318
319 // Process-wide monotonic source for BindingToken generations, so a token minted by one poller can never
320 // alias a different poller's state. The source starts at 1. The value 0 remains reserved for an invalid
321 // token.
322 std::atomic<std::uint64_t> s_next_binding_generation{1};
323
324 /// Draws a unique binding generation with a relaxed operation that does not publish state.
325 5333 std::uint64_t next_binding_generation() noexcept
326 {
327 5333 return s_next_binding_generation.fetch_add(1, std::memory_order_relaxed);
328 }
329
330 /// Ensures @p binding carries a lifecycle control block. Allocates only for directly seeded entries.
331 1219 void ensure_lifecycle(InputBinding &binding)
332 {
333
2/2
✓ Branch 3 → 4 taken 284 times.
✓ Branch 3 → 8 taken 935 times.
1219 if (!binding.lifecycle)
334 {
335
2/2
✓ Branch 4 → 5 taken 280 times.
✓ Branch 4 → 9 taken 4 times.
284 binding.lifecycle = make_binding_lifecycle();
336 }
337 1215 }
338
339 struct BindingRundown
340 {
341 std::shared_ptr<BindingLifecycle> lifecycle;
342 std::uint64_t generation{0};
343 };
344
345 // HoldRelease stores one action per release. Separate containers can diverge when only one copy succeeds,
346 // which leaves dispatch with a name that was never staged. One object prevents that split.
347 struct HoldRelease
348 {
349 std::function<void(bool)> callback;
350 std::string name;
351 };
352
353 2881 void add_rundown(std::vector<BindingRundown> &rundowns, const std::shared_ptr<BindingLifecycle> &lifecycle)
354 {
355
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2881 times.
2881 if (!lifecycle)
356 {
357 return;
358 }
359
1/2
✓ Branch 7 → 8 taken 2881 times.
✗ Branch 7 → 30 not taken.
2881 const auto duplicate = std::find_if(
360 rundowns.begin(),
361 rundowns.end(),
362 337 [&lifecycle](const BindingRundown &rundown) { return rundown.lifecycle == lifecycle; }
363 );
364
2/2
✓ Branch 15 → 16 taken 2547 times.
✓ Branch 15 → 22 taken 334 times.
5762 if (duplicate == rundowns.end())
365 {
366 5094 rundowns.push_back({lifecycle, 0});
367 }
368
2/6
✓ Branch 17 → 18 taken 2547 times.
✗ Branch 17 → 24 not taken.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 2547 times.
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 28 not taken.
5094 }
369
370 2424 void drain_rundowns(const std::vector<BindingRundown> &rundowns) noexcept
371 {
372
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2424 times.
2424 if (current_thread_in_delivery())
373 {
374 return;
375 }
376
2/2
✓ Branch 31 → 7 taken 2427 times.
✓ Branch 31 → 32 taken 2424 times.
7275 for (const auto &rundown : rundowns)
377 {
378 // A tombstoned registration admits nothing further, so wait for every in-flight callback
379 // regardless of parity slot. An advanced live registration still admits new-generation
380 // callbacks, so it drains only the retired slot.
381 2427 DrainBackoff backoff;
382
2/2
✓ Branch 11 → 12 taken 416 times.
✓ Branch 11 → 17 taken 2011 times.
2427 if (rundown.lifecycle->tombstoned())
383 {
384
1/2
✗ Branch 16 → 13 not taken.
✓ Branch 16 → 22 taken 416 times.
416 while (rundown.lifecycle->in_flight_total() != 0)
385 {
386 backoff.pause();
387 }
388 }
389 else
390 {
391
2/2
✓ Branch 21 → 18 taken 65 times.
✓ Branch 21 → 22 taken 2011 times.
2076 while (rundown.lifecycle->in_flight(rundown.generation) != 0)
392 {
393 65 backoff.pause();
394 }
395 }
396 }
397 }
398 } // anonymous namespace
399
400 #ifdef DMK_ENABLE_TEST_SEAMS
401 std::function<bool(int)> g_input_key_state_probe;
402 std::function<void(std::size_t)> g_input_post_stage_probe;
403 std::function<void()> g_input_pre_dispatch_probe;
404 void (*g_input_join_fail_seam)() = nullptr;
405 std::function<void(const std::array<int, 4> &)> g_input_external_wheel_post_drain_probe;
406 #endif
407
408 1727 std::shared_ptr<BindingLifecycle> make_binding_lifecycle()
409 {
410 static std::atomic<bool> s_tls_warned{false};
411
5/6
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 1726 times.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 12 taken 1726 times.
1727 if (!reserve_delivery_scope_tls() && !s_tls_warned.exchange(true, std::memory_order_relaxed))
412 {
413 1 (void)log().try_log(
414 LogLevel::Error,
415 "InputPoller: no TLS slot is available for the input delivery marker; input "
416 "callbacks are refused rather than delivered without per-thread identity."
417 );
418 }
419
2/2
✓ Branch 13 → 14 taken 1720 times.
✓ Branch 13 → 17 taken 4 times.
1727 return std::make_shared<BindingLifecycle>(next_binding_generation());
420 }
421
422 static_assert(
423 std::is_nothrow_move_assignable_v<InputBinding>,
424 "Input reshape commits rely on noexcept InputBinding move assignment"
425 );
426 static_assert(
427 std::is_nothrow_move_constructible_v<InputBinding>,
428 "Input reshape commits rely on noexcept InputBinding move construction"
429 );
430
431 334 InputPoller::InputPoller(
432 std::vector<InputBinding> bindings,
433 std::chrono::milliseconds poll_interval,
434 bool require_focus,
435 int gamepad_index,
436 int trigger_threshold,
437 int stick_threshold,
438 input::Input::WheelBackend wheel_backend,
439 const WheelHostTable *wheel_host,
440 std::uint32_t wheel_target_thread_id
441 334 )
442 668 : m_bindings(std::move(bindings)),
443 668 m_poll_interval(std::clamp(poll_interval, input::MIN_POLL_INTERVAL, input::MAX_POLL_INTERVAL)),
444 334 m_require_focus(require_focus),
445
1/2
✓ Branch 18 → 19 taken 334 times.
✗ Branch 18 → 66 not taken.
334 m_active_states(std::make_unique<std::atomic<uint8_t>[]>(m_bindings.size())),
446
1/2
✓ Branch 19 → 20 taken 334 times.
✗ Branch 19 → 54 not taken.
334 m_gamepad_index(std::clamp(gamepad_index, 0, 3)),
447
1/2
✓ Branch 20 → 21 taken 334 times.
✗ Branch 20 → 56 not taken.
334 m_trigger_threshold(std::clamp(trigger_threshold, 0, 255)),
448
1/2
✓ Branch 21 → 22 taken 334 times.
✗ Branch 21 → 58 not taken.
334 m_stick_threshold(std::clamp(stick_threshold, 0, 32767)), m_intercept_owner(next_intercept_owner()),
449
2/4
✓ Branch 8 → 9 taken 334 times.
✗ Branch 8 → 74 not taken.
✓ Branch 16 → 17 taken 334 times.
✗ Branch 16 → 68 not taken.
1336 m_wheel_backend(wheel_backend), m_wheel_host(wheel_host), m_wheel_target_thread_id(wheel_target_thread_id)
450 {
451
1/2
✓ Branch 35 → 36 taken 334 times.
✗ Branch 35 → 61 not taken.
334 m_name_index.reserve(m_bindings.size());
452 // Stamp a lifecycle on any binding seeded without one, so the poll loop's generation-safety check has
453 // an identity to compare against.
454
2/2
✓ Branch 50 → 38 taken 491 times.
✓ Branch 50 → 51 taken 334 times.
1159 for (auto &binding : m_bindings)
455 {
456
1/2
✓ Branch 40 → 41 taken 491 times.
✗ Branch 40 → 60 not taken.
491 ensure_lifecycle(binding);
457 }
458 // Construction is single-owner, so no lock is held; emit directly after the rebuild.
459 334 DeferredDiagnostics diagnostics;
460 334 recompute_modifier_caches_locked(diagnostics);
461 334 diagnostics.emit();
462 334 }
463
464 // The poll loop drives the wheel through these helpers so it never names a specific backend. MessageHook
465 // installs a local source and reuses the interception layer's owner, epoch, and drain path.
466 // ExternalHost opens one lease on the loader's resident host and drives it through the C ABI. The host table
467 // was validated in Input::start() before this poller was built, so the pointer and its function pointers are
468 // known good here.
469
470 113 int32_t InputPoller::prepare_wheel_source() noexcept
471 {
472
2/4
✓ Branch 2 → 3 taken 113 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 113 times.
113 if (m_wheel_backend != input::Input::WheelBackend::ExternalHost || m_wheel_lease != 0)
473 {
474 return DMK_WHEELHOST_OK;
475 }
476 113 WheelHostLease lease = 0;
477 113 const std::uint64_t generation = m_binding_generation;
478 const int32_t status =
479 113 m_wheel_host->open_lease(m_wheel_host->host_context, m_intercept_owner, generation, &lease);
480
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 112 times.
113 if (status != DMK_WHEELHOST_OK)
481 {
482 1 return status;
483 }
484
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 112 times.
112 if (lease == 0)
485 {
486 return DMK_WHEELHOST_ERR_INVALID;
487 }
488 112 m_wheel_lease = lease;
489 112 m_wheel_lease_generation = generation;
490 112 m_external_lease_active.store(true, std::memory_order_release);
491 112 return DMK_WHEELHOST_OK;
492 }
493
494 54 std::uint32_t InputPoller::resolve_wheel_target() const noexcept
495 {
496
2/2
✓ Branch 2 → 3 taken 31 times.
✓ Branch 2 → 4 taken 23 times.
54 if (m_wheel_target_thread_id != 0)
497 {
498 31 return m_wheel_target_thread_id;
499 }
500 // Automatic discovery: the current foreground window, only when this process owns it. There is no
501 // enumeration fallback; without a process-owned foreground window the route waits and retries.
502 23 const HWND foreground = GetForegroundWindow();
503
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 23 times.
23 if (foreground == nullptr)
504 {
505 return 0;
506 }
507 23 DWORD pid = 0;
508 23 const DWORD thread_id = GetWindowThreadProcessId(foreground, &pid);
509
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 23 times.
23 return pid == GetCurrentProcessId() ? thread_id : 0;
510 }
511
512 281 void InputPoller::note_wheel_host_status(std::int32_t status, const char *operation) noexcept
513 {
514
2/2
✓ Branch 2 → 3 taken 274 times.
✓ Branch 2 → 4 taken 7 times.
281 if (status == DMK_WHEELHOST_OK)
515 {
516 274 return;
517 }
518 // One log line per distinct latched status, so a persistent failure does not spam every cycle but a
519 // changed failure is never silent. Health carries the live state; the latch only gates the log.
520
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 10 taken 5 times.
14 if (m_wheel_host_logged_status.exchange(status, std::memory_order_relaxed) != status)
521 {
522 2 (void)log().try_log(
523 LogLevel::Warning,
524 "InputPoller: wheel host {} failed with status {}; wheel capture stays disabled "
525 "until the route recovers",
526 operation,
527 status
528 );
529 }
530 }
531
532 namespace
533 {
534 /// Projects one host route snapshot onto the public health enum.
535 [[nodiscard]] input::Input::WheelSourceHealth
536 40 derive_external_health(const WheelHostRouteStatus &status) noexcept
537 {
538 using Health = input::Input::WheelSourceHealth;
539 // A blocked cleanup is the most specific fact, so it takes precedence over its transaction.
540
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 38 times.
40 if (status.route_state == DMK_WHEELHOST_ROUTE_CLEANUP_BLOCKED)
541 {
542 2 return Health::CleanupBlocked;
543 }
544
2/2
✓ Branch 4 → 5 taken 11 times.
✓ Branch 4 → 6 taken 27 times.
38 if (status.control_state != DMK_WHEELHOST_CONTROL_IDLE)
545 {
546 11 return Health::Retryable;
547 }
548
2/2
✓ Branch 6 → 7 taken 23 times.
✓ Branch 6 → 11 taken 4 times.
27 if (status.route_state == DMK_WHEELHOST_ROUTE_READY)
549 {
550
2/2
✓ Branch 7 → 8 taken 21 times.
✓ Branch 7 → 9 taken 2 times.
23 return status.capture_armable != 0 ? Health::Ready : Health::Retryable;
551 }
552
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 13 not taken.
4 return status.route_state == DMK_WHEELHOST_ROUTE_RETRYABLE ? Health::Retryable : Health::TargetWait;
553 }
554 } // anonymous namespace
555
556 54 void InputPoller::wheel_source_maintain() noexcept
557 {
558 54 const std::uint32_t desired = resolve_wheel_target();
559
2/2
✓ Branch 3 → 4 taken 18 times.
✓ Branch 3 → 14 taken 36 times.
54 if (m_wheel_backend == input::Input::WheelBackend::MessageHook)
560 {
561 18 const WheelRouteState state = message_hook_route_state();
562 18 const std::uint32_t mounted = message_hook_thread_id();
563
2/2
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 11 taken 8 times.
18 if (state == WheelRouteState::Ready)
564 {
565 // Keep a healthy mounted route through temporary focus loss. Migrate only when discovery
566 // resolves a different thread of this process.
567
2/4
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 10 times.
10 if (desired != 0 && desired != mounted)
568 {
569 (void)install_message_hook(m_intercept_owner, desired);
570 }
571 50 return;
572 }
573
1/2
✓ Branch 11 → 12 taken 8 times.
✗ Branch 11 → 13 not taken.
8 if (desired != 0)
574 {
575 8 (void)install_message_hook(m_intercept_owner, desired);
576 }
577 8 return;
578 }
579
580
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 36 times.
36 if (m_wheel_lease == 0)
581 {
582 return;
583 }
584 40 const auto read_status = [this](WheelHostRouteStatus &out) noexcept
585 {
586 40 out = WheelHostRouteStatus{};
587 40 return m_wheel_host->route_status(
588 40 m_wheel_host->host_context,
589 m_wheel_lease,
590 static_cast<std::uint32_t>(sizeof(out)),
591 &out
592 40 );
593 36 };
594 36 WheelHostRouteStatus status{};
595
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 21 taken 36 times.
36 if (const std::int32_t status_code = read_status(status); status_code != DMK_WHEELHOST_OK)
596 {
597 note_wheel_host_status(status_code, "route_status");
598 m_external_health.store(input::Input::WheelSourceHealth::Retryable, std::memory_order_relaxed);
599 return;
600 }
601 36 m_external_health.store(derive_external_health(status), std::memory_order_relaxed);
602
603
2/2
✓ Branch 23 → 24 taken 10 times.
✓ Branch 23 → 26 taken 26 times.
36 if (status.control_state != DMK_WHEELHOST_CONTROL_IDLE &&
604
2/2
✓ Branch 24 → 25 taken 7 times.
✓ Branch 24 → 26 taken 3 times.
10 status.control_state != DMK_WHEELHOST_CONTROL_RETARGET_PENDING)
605 {
606 7 return;
607 }
608 // A pending retarget requires a retry even when the desired thread still owns the mount.
609 84 const bool needs_retarget = status.control_state == DMK_WHEELHOST_CONTROL_RETARGET_PENDING ||
610
6/6
✓ Branch 26 → 27 taken 26 times.
✓ Branch 26 → 30 taken 3 times.
✓ Branch 27 → 28 taken 23 times.
✓ Branch 27 → 30 taken 3 times.
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 31 taken 19 times.
33 status.route_state != DMK_WHEELHOST_ROUTE_READY ||
611
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 3 times.
4 (desired != 0 && desired != status.mounted_thread_id);
612
2/2
✓ Branch 32 → 33 taken 22 times.
✓ Branch 32 → 34 taken 7 times.
29 if (!needs_retarget)
613 {
614 22 return;
615 }
616 // The retry uses current intent. Without current intent, it preserves the mounted thread.
617
2/2
✓ Branch 34 → 35 taken 4 times.
✓ Branch 34 → 36 taken 3 times.
7 const std::uint32_t target = desired != 0 ? desired : status.mounted_thread_id;
618
1/2
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 7 times.
7 if (target == 0)
619 {
620 return;
621 }
622 const std::int32_t retarget_status =
623 7 m_wheel_host->retarget(m_wheel_host->host_context, m_wheel_lease, target);
624
2/2
✓ Branch 40 → 41 taken 3 times.
✓ Branch 40 → 43 taken 4 times.
7 if (retarget_status == DMK_WHEELHOST_OK)
625 {
626 3 m_external_health.store(input::Input::WheelSourceHealth::Ready, std::memory_order_relaxed);
627 3 return;
628 }
629 4 note_wheel_host_status(retarget_status, "retarget");
630 4 WheelHostRouteStatus failed{};
631
1/2
✓ Branch 45 → 46 taken 4 times.
✗ Branch 45 → 47 not taken.
8 m_external_health.store(
632 8 read_status(failed) == DMK_WHEELHOST_OK ? derive_external_health(failed)
633 : input::Input::WheelSourceHealth::Retryable,
634 std::memory_order_relaxed
635 );
636 }
637
638 26 input::Input::WheelSourceHealth InputPoller::wheel_source_health() const noexcept
639 {
640 using Health = input::Input::WheelSourceHealth;
641
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 26 times.
26 if (!m_has_wheel_bindings.load(std::memory_order_acquire))
642 {
643 return Health::Inactive;
644 }
645
1/2
✓ Branch 5 → 6 taken 26 times.
✗ Branch 5 → 10 not taken.
26 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
646 {
647
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 26 times.
26 if (!m_external_lease_active.load(std::memory_order_acquire))
648 {
649 return Health::Inactive;
650 }
651 26 return m_external_health.load(std::memory_order_relaxed);
652 }
653 const WheelRouteState state = message_hook_route_state();
654 if (state == WheelRouteState::Ready && !intercept_owned_by(m_intercept_owner))
655 {
656 // The layer moved to another owner; this poller's route is effectively waiting.
657 return Health::TargetWait;
658 }
659 switch (state)
660 {
661 case WheelRouteState::Ready:
662 return Health::Ready;
663 case WheelRouteState::Retryable:
664 return Health::Retryable;
665 case WheelRouteState::CleanupBlocked:
666 return Health::CleanupBlocked;
667 case WheelRouteState::TargetWait:
668 break;
669 }
670 return Health::TargetWait;
671 }
672
673 64 std::array<int, 4> InputPoller::wheel_source_take_counts() noexcept
674 {
675
2/2
✓ Branch 2 → 3 taken 36 times.
✓ Branch 2 → 16 taken 28 times.
64 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
676 {
677 36 std::array<int, 4> out{};
678
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 36 times.
36 if (m_wheel_lease == 0)
679 {
680 return out;
681 }
682 36 std::uint32_t counts[DMK_WHEEL_DIRECTIONS] = {0, 0, 0, 0};
683 const std::int32_t status =
684 36 m_wheel_host->drain_counts(m_wheel_host->host_context, m_wheel_lease, counts);
685 36 note_wheel_host_status(status, "drain_counts");
686
1/2
✓ Branch 7 → 8 taken 36 times.
✗ Branch 7 → 14 not taken.
36 if (status == DMK_WHEELHOST_OK)
687 {
688 // The C ABI direction order (Up, Down, Left, Right) matches WheelPulseState indexing exactly.
689
2/2
✓ Branch 13 → 9 taken 144 times.
✓ Branch 13 → 14 taken 36 times.
180 for (int dir = 0; dir < 4; ++dir)
690 {
691 288 out[static_cast<std::size_t>(dir)] = static_cast<int>(
692 144 std::min(counts[dir], static_cast<std::uint32_t>(std::numeric_limits<int>::max()))
693 );
694 }
695 }
696 36 return out;
697 }
698 28 return take_wheel_counts(m_intercept_owner);
699 }
700
701 686 void InputPoller::wheel_source_publish_consume(std::uint8_t direction_mask, bool capture_enabled) noexcept
702 {
703
2/2
✓ Branch 2 → 3 taken 128 times.
✓ Branch 2 → 14 taken 558 times.
686 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
704 {
705
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 128 times.
128 if (m_wheel_lease == 0)
706 {
707 return;
708 }
709 // The WheelDirection consume bits match the C ABI DMK_WHEEL_CONSUME_* bit order exactly. The TTL is
710 // twice the poll interval, matching the local suppression self-heal, so a stalled poller stops the
711 // host swallowing after a bounded delay.
712 const std::uint32_t ttl_ms =
713 128 static_cast<std::uint32_t>(std::clamp<std::int64_t>(m_poll_interval.count() * 2, 1, 60000));
714 128 std::uint32_t capture_flags = 0;
715
2/2
✓ Branch 7 → 8 taken 36 times.
✓ Branch 7 → 11 taken 92 times.
128 if (capture_enabled)
716 {
717 36 capture_flags = DMK_WHEEL_CAPTURE_ENABLED;
718
2/2
✓ Branch 9 → 10 taken 23 times.
✓ Branch 9 → 11 taken 13 times.
36 if (m_require_focus.load(std::memory_order_relaxed))
719 {
720 23 capture_flags |= DMK_WHEEL_CAPTURE_REQUIRE_FOCUS;
721 }
722 }
723 128 const std::int32_t status = m_wheel_host->publish_capture(
724 128 m_wheel_host->host_context,
725 m_wheel_lease,
726 capture_flags,
727 direction_mask,
728 ttl_ms
729 );
730 128 note_wheel_host_status(status, "publish_capture");
731 128 return;
732 }
733 (
734 void
735 558 )publish_wheel_consume(direction_mask, m_require_focus.load(std::memory_order_relaxed), m_intercept_owner);
736 }
737
738 609 void InputPoller::wheel_source_close() noexcept
739 {
740
4/4
✓ Branch 2 → 3 taken 223 times.
✓ Branch 2 → 8 taken 386 times.
✓ Branch 3 → 4 taken 113 times.
✓ Branch 3 → 8 taken 110 times.
609 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost && m_wheel_lease != 0)
741 {
742 113 const int32_t status = m_wheel_host->close_lease(
743 113 m_wheel_host->host_context,
744 m_wheel_lease,
745 113 m_intercept_owner,
746 m_wheel_lease_generation
747 );
748 113 note_wheel_host_status(status, "close_lease");
749 // After a refused close, retain the lease identity for a later shutdown retry.
750
2/2
✓ Branch 6 → 7 taken 110 times.
✓ Branch 6 → 8 taken 3 times.
113 if (status == DMK_WHEELHOST_OK)
751 {
752 110 m_wheel_lease = 0;
753 110 m_wheel_lease_generation = 0;
754 110 m_external_lease_active.store(false, std::memory_order_release);
755 }
756 }
757 609 }
758
759 std::optional<InputPoller::ModifierCaches>
760 3497 InputPoller::build_modifier_caches(const std::vector<InputBinding> &bindings) noexcept
761 {
762 try
763 {
764 3497 ModifierCaches caches;
765 3497 std::unordered_set<InputCode, InputCodeHash> modifier_set;
766
2/2
✓ Branch 29 → 5 taken 49188 times.
✓ Branch 29 → 30 taken 3488 times.
52676 for (size_t i = 0; i < bindings.size(); ++i)
767 {
768
2/2
✓ Branch 7 → 8 taken 49186 times.
✓ Branch 7 → 11 taken 2 times.
49188 if (!bindings[i].name.empty())
769 {
770
4/4
✓ Branch 9 → 10 taken 49179 times.
✓ Branch 9 → 46 taken 7 times.
✓ Branch 10 → 11 taken 49177 times.
✓ Branch 10 → 46 taken 2 times.
49186 caches.name_index[bindings[i].name].push_back(i);
771 }
772
2/2
✓ Branch 26 → 14 taken 356 times.
✓ Branch 26 → 27 taken 49179 times.
98714 for (const auto &mod : bindings[i].modifiers)
773 {
774
1/2
✓ Branch 16 → 17 taken 356 times.
✗ Branch 16 → 44 not taken.
356 modifier_set.insert(mod);
775 }
776 }
777
1/2
✓ Branch 32 → 33 taken 3488 times.
✗ Branch 32 → 48 not taken.
3488 caches.known_modifiers.assign(modifier_set.begin(), modifier_set.end());
778
779 // Built from the same bindings and modifier set as the reactive path so the two never disagree.
780
1/2
✓ Branch 33 → 34 taken 3488 times.
✗ Branch 33 → 47 not taken.
3488 caches.consume_rules = build_gamepad_consume_rules(bindings, caches.known_modifiers);
781 3488 caches.has_gamepad_bindings = scan_for_gamepad_bindings(bindings);
782 3488 caches.has_wheel_bindings = scan_for_wheel_bindings(bindings);
783 3488 caches.has_consume_gamepad_bindings = scan_for_consume_gamepad_bindings(bindings);
784 3488 return caches;
785 3506 }
786 9 catch (...)
787 {
788 9 return std::nullopt;
789 9 }
790 }
791
792 void
793 3488 InputPoller::commit_modifier_caches_locked(ModifierCaches &caches, DeferredDiagnostics &diagnostics) noexcept
794 {
795 // Snapshot wheel ownership before this reshape. The installed detour continues to latch notches across an
796 // unbind -> rebind while the poll loop skips the drain. A stale backlog accumulates in the unowned window.
797 // The no-wheel -> wheel transition below must discard it.
798 3488 const bool had_wheel_bindings = m_has_wheel_bindings.load(std::memory_order_relaxed);
799
800 3488 m_consume_rules.swap(caches.consume_rules);
801 3488 m_name_index.swap(caches.name_index);
802 3488 m_known_modifiers.swap(caches.known_modifiers);
803 3488 m_has_gamepad_bindings.store(caches.has_gamepad_bindings, std::memory_order_relaxed);
804 3488 m_has_consume_gamepad_bindings.store(caches.has_consume_gamepad_bindings, std::memory_order_relaxed);
805
806 // Offer the detour-side consume rule list. A poller that does not hold the layer keeps the rules cached and
807 // does not overwrite the owner's list.
808 3488 publish_consume_rules_locked(diagnostics);
809
810 // The published flag stays false until after this drain. Otherwise, the poll thread can consume a stale
811 // backlog before this thread clears it. A non-owner drains nothing because that backlog belongs to its
812 // owner's window.
813
4/4
✓ Branch 9 → 10 taken 3486 times.
✓ Branch 9 → 15 taken 2 times.
✓ Branch 10 → 11 taken 121 times.
✓ Branch 10 → 15 taken 3365 times.
3488 if (!had_wheel_bindings && caches.has_wheel_bindings)
814 {
815
2/2
✓ Branch 11 → 12 taken 111 times.
✓ Branch 11 → 13 taken 10 times.
121 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
816 {
817 111 m_external_wheel_discard_pending.store(true, std::memory_order_release);
818 }
819 else
820 {
821 10 (void)wheel_source_take_counts();
822 }
823 }
824 // Release pairs with the poll cycle's acquire snapshot and publishes the drain before consumption.
825 3488 m_has_wheel_bindings.store(caches.has_wheel_bindings, std::memory_order_release);
826 3488 }
827
828 void
829 1481 InputPoller::recompute_modifier_caches_locked(DeferredDiagnostics &diagnostics, CacheFailPolicy policy) noexcept
830 {
831 // A caller already changed member state. Advance the generation even when the derived-cache build fails.
832 1481 m_binding_generation = next_binding_generation();
833
834 1481 std::optional<ModifierCaches> caches = build_modifier_caches(m_bindings);
835
2/2
✓ Branch 5 → 6 taken 1477 times.
✓ Branch 5 → 9 taken 4 times.
1481 if (caches)
836 {
837 1477 commit_modifier_caches_locked(*caches, diagnostics);
838 1477 return;
839 }
840
841
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 14 taken 2 times.
4 if (policy == CacheFailPolicy::Retain)
842 {
843 // Keep the lookup caches (the caller changed a flag, not the binding set) but disarm the suppression. A
844 // retained rule list has no independent expiry and masks a revoked chord for the rest of the process.
845 2 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
846 2 m_consume_rules.clear();
847 2 publish_consume_rules_locked(diagnostics);
848 2 diagnostics.cache_rebuild_retained = true;
849 2 return;
850 }
851
852 // Keep every derived cache conservative and index-safe rather than leave a stale name map whose old indices
853 // can address past the new binding array.
854 2 m_name_index.clear();
855 2 m_known_modifiers.clear();
856 2 m_has_gamepad_bindings.store(false, std::memory_order_relaxed);
857 2 m_has_wheel_bindings.store(false, std::memory_order_relaxed);
858 2 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
859 2 m_consume_rules.clear();
860 2 publish_consume_rules_locked(diagnostics);
861 2 diagnostics.cache_rebuild_cleared = true;
862
2/2
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 26 taken 1479 times.
1481 }
863
864 3633 void InputPoller::publish_consume_rules_locked(DeferredDiagnostics &diagnostics) noexcept
865 {
866 const ConsumePublish result =
867 3633 publish_gamepad_consume_rules(m_consume_rules.data(), m_consume_rules.size(), m_intercept_owner);
868
2/2
✓ Branch 5 → 6 taken 3593 times.
✓ Branch 5 → 9 taken 40 times.
3633 if (!result.authorized)
869 {
870 // Not this poller's layer: report zero occupancy and keep the rules cached for the retry.
871 3593 m_consume_rules_unpublished.store(true, std::memory_order_release);
872 3593 record_consume_capacity(0, 0, diagnostics);
873 3593 return;
874 }
875 40 m_consume_rules_unpublished.store(false, std::memory_order_release);
876 40 record_consume_capacity(result.published, m_consume_rules.size() - result.published, diagnostics);
877 }
878
879 #ifdef DMK_ENABLE_TEST_SEAMS
880 16 void InputPoller::publish_consume_rules_for_test() noexcept
881 {
882 16 DeferredDiagnostics diagnostics;
883 {
884 16 std::unique_lock lock(m_bindings_rw_mutex);
885 16 publish_consume_rules_locked(diagnostics);
886 16 }
887 16 diagnostics.emit();
888 16 }
889 #endif
890
891 3633 void InputPoller::record_consume_capacity(
892 std::size_t active,
893 std::size_t rejected,
894 DeferredDiagnostics &diagnostics
895 ) noexcept
896 {
897 3633 m_consume_rules_total.store(active + rejected, std::memory_order_relaxed);
898
2/2
✓ Branch 10 → 11 taken 3631 times.
✓ Branch 10 → 12 taken 2 times.
3633 if (rejected == 0)
899 {
900 3631 return;
901 }
902 // Latch per engine. An unlatched log repeats the same condition on every publish. A process-wide latch
903 // silences a later engine's overflow.
904
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 2 times.
2 if (m_consume_bound_reported.exchange(true, std::memory_order_relaxed))
905 {
906 return;
907 }
908 2 diagnostics.consume_bound = true;
909 2 diagnostics.consume_rejected = rejected;
910 2 diagnostics.consume_total = active + rejected;
911 }
912
913 3746 void InputPoller::DeferredDiagnostics::emit() const noexcept
914 {
915
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 6 taken 3744 times.
3746 if (cache_rebuild_retained)
916 {
917 2 (void)log().try_log(
918 LogLevel::Error,
919 "InputPoller: out of memory rebuilding modifier caches; name lookup is "
920 "retained and gamepad consume suppression is disarmed until the next "
921 "successful rebuild"
922 );
923 }
924
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 10 taken 3744 times.
3746 if (cache_rebuild_cleared)
925 {
926 2 (void)log().try_log(
927 LogLevel::Error,
928 "InputPoller: out of memory rebuilding modifier caches; "
929 "name lookup and input interception disabled until the next successful rebuild"
930 );
931 }
932
2/2
✓ Branch 10 → 11 taken 7 times.
✓ Branch 10 → 14 taken 3739 times.
3746 if (add_binding_oom)
933 {
934 7 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in add_binding; binding not added");
935 }
936
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 18 taken 3745 times.
3746 if (add_bindings_oom)
937 {
938 1 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in add_bindings; bindings not added");
939 }
940
2/2
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 22 taken 3744 times.
3746 if (consume_bound)
941 {
942 4 (void)log().try_log(
943 LogLevel::Warning,
944 "InputPoller: {} of {} gamepad consume chords exceed the interception table; "
945 "they keep the reactive mask but lose same-frame suppression",
946 2 consume_rejected,
947 2 consume_total
948 );
949 }
950 3746 }
951
952 5 input::ConsumeCapacity InputPoller::consume_capacity() const noexcept
953 {
954 5 const std::size_t total = m_consume_rules_total.load(std::memory_order_relaxed);
955 5 const std::size_t active = std::min(total, MAX_GAMEPAD_CONSUME_RULES);
956 5 return input::ConsumeCapacity{MAX_GAMEPAD_CONSUME_RULES, active, total - active};
957 }
958
959 330 InputPoller::~InputPoller() noexcept
960 {
961 330 shutdown();
962 330 }
963
964 277 void InputPoller::start()
965 {
966
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 276 times.
277 if (m_poll_thread.joinable())
967 {
968
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 19 not taken.
1 log().debug("InputPoller: start() called while already running; no-op.");
969 1 return;
970 }
971
972 // Acquire before poll-thread creation because execution can start immediately. Its module reference must
973 // already be part of the count.
974 276 const HMODULE self_ref = acquire_module_ref(diagnostics::ModulePinReason::InputPoller);
975
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 13 taken 276 times.
276 if (self_ref == nullptr)
976 {
977 throw std::system_error(
978 static_cast<int>(GetLastError()),
979 std::system_category(),
980 "InputPoller: acquire_module_ref failed"
981 );
982 }
983
984 276 m_running.store(true, std::memory_order_release);
985 try
986 {
987
2/4
✓ Branch 5 → 6 taken 276 times.
✗ Branch 5 → 8 not taken.
✓ Branch 14 → 15 taken 276 times.
✗ Branch 14 → 22 not taken.
828 m_poll_thread = std::jthread([this](std::stop_token token) { poll_loop(std::move(token)); });
988 }
989 catch (...)
990 {
991 m_running.store(false, std::memory_order_release);
992 release_module_ref(self_ref, diagnostics::ModulePinReason::InputPoller);
993 throw;
994 }
995 276 m_self_ref = self_ref;
996 }
997
998 48 bool InputPoller::is_running() const noexcept
999 {
1000 48 return m_running.load(std::memory_order_acquire);
1001 }
1002
1003 57982 size_t InputPoller::binding_count() const noexcept
1004 {
1005 57982 std::shared_lock lock(m_bindings_rw_mutex);
1006 57985 return m_bindings.size();
1007 57913 }
1008
1009 3 bool InputPoller::has_bindings_by_name(std::string_view name) const noexcept
1010 {
1011 3 std::shared_lock lock(m_bindings_rw_mutex);
1012 3 return m_name_index.contains(name);
1013 3 }
1014
1015 4 std::chrono::milliseconds InputPoller::poll_interval() const noexcept
1016 {
1017 4 return m_poll_interval;
1018 }
1019
1020 2 int InputPoller::gamepad_index() const noexcept
1021 {
1022 2 return m_gamepad_index;
1023 }
1024
1025 8 bool InputPoller::is_binding_active(size_t index) const noexcept
1026 {
1027 // The shared lock keeps the index and array consistent across a reshape. The unique_ptr<atomic[]> ownership
1028 // swap needs synchronization, not the relaxed element load.
1029 8 std::shared_lock lock(m_bindings_rw_mutex);
1030
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 6 times.
8 if (index >= m_bindings.size())
1031 {
1032 2 return false;
1033 }
1034 12 return m_active_states[index].load(std::memory_order_relaxed) != 0;
1035 8 }
1036
1037 63169 bool InputPoller::is_binding_active(std::string_view name) const noexcept
1038 {
1039 63169 std::shared_lock lock(m_bindings_rw_mutex);
1040 63139 const auto it = m_name_index.find(name);
1041
2/2
✓ Branch 6 → 7 taken 62220 times.
✓ Branch 6 → 38 taken 1542 times.
61854 if (it != m_name_index.end())
1042 {
1043
2/2
✓ Branch 36 → 10 taken 80326 times.
✓ Branch 36 → 37 taken 60504 times.
203050 for (const size_t idx : it->second)
1044 {
1045 // The shared lock holds idx in bounds. The explicit check is defense in depth against a future
1046 // reshape that repopulates m_name_index without a corresponding m_active_states resize.
1047
6/6
✓ Branch 13 → 14 taken 79921 times.
✓ Branch 13 → 24 taken 169 times.
✓ Branch 22 → 23 taken 54 times.
✓ Branch 22 → 24 taken 79869 times.
✓ Branch 25 → 26 taken 54 times.
✓ Branch 25 → 27 taken 80038 times.
160249 if (idx < m_bindings.size() && m_active_states[idx].load(std::memory_order_relaxed) != 0)
1048 {
1049 54 return true;
1050 }
1051 }
1052 }
1053 62046 return false;
1054 62100 }
1055
1056 76 input::BindingToken InputPoller::acquire_binding_token(std::string_view name) const noexcept
1057 {
1058 76 input::BindingToken token;
1059 try
1060 {
1061 76 std::shared_lock lock(m_bindings_rw_mutex);
1062
1/2
✓ Branch 3 → 4 taken 76 times.
✗ Branch 3 → 20 not taken.
76 const auto it = m_name_index.find(name);
1063
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 9 taken 71 times.
76 if (it == m_name_index.end())
1064 {
1065 // If the name is unknown, leave the token invalid with generation zero.
1066 5 return token;
1067 }
1068 // Copy the indices first because only this step can throw. Then stamp the generation. An allocation
1069 // failure leaves the token invalid instead of valid but empty.
1070
1/2
✓ Branch 10 → 11 taken 71 times.
✗ Branch 10 → 20 not taken.
71 token.m_indices = it->second;
1071 71 token.m_generation = m_binding_generation;
1072
2/2
✓ Branch 13 → 14 taken 71 times.
✓ Branch 13 → 16 taken 5 times.
76 }
1073 catch (...)
1074 {
1075 // If allocation fails, return an invalid token so the consumer uses the name-based query.
1076 return input::BindingToken{};
1077 }
1078 71 return token;
1079 76 }
1080
1081 13 bool InputPoller::is_binding_active(const input::BindingToken &token) const noexcept
1082 {
1083
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 11 times.
13 if (!token.valid())
1084 {
1085 2 return false;
1086 }
1087 11 std::shared_lock lock(m_bindings_rw_mutex);
1088 // A generation mismatch means the cached indices can refer to different bindings. Fail closed.
1089
2/2
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 8 taken 4 times.
11 if (token.m_generation != m_binding_generation)
1090 {
1091 7 return false;
1092 }
1093
2/2
✓ Branch 36 → 10 taken 5 times.
✓ Branch 36 → 37 taken 4 times.
13 for (const size_t idx : token.m_indices)
1094 {
1095 // The generation match proves idx in bounds. The explicit check is defense in depth against a
1096 // future reshape path that forgets to advance the generation.
1097
3/6
✓ Branch 13 → 14 taken 5 times.
✗ Branch 13 → 24 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 5 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 5 times.
10 if (idx < m_bindings.size() && m_active_states[idx].load(std::memory_order_relaxed) != 0)
1098 {
1099 return true;
1100 }
1101 }
1102 4 return false;
1103 11 }
1104
1105 80 bool InputPoller::binding_token_current(const input::BindingToken &token) const noexcept
1106 {
1107
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 78 times.
80 if (!token.valid())
1108 {
1109 2 return false;
1110 }
1111 78 std::shared_lock lock(m_bindings_rw_mutex);
1112 78 return token.m_generation == m_binding_generation;
1113 78 }
1114
1115 9 void InputPoller::set_require_focus(bool require_focus) noexcept
1116 {
1117 9 m_require_focus.store(require_focus, std::memory_order_relaxed);
1118 9 }
1119
1120 10 void InputPoller::set_consume(std::string_view name, bool consume) noexcept
1121 {
1122 10 DeferredDiagnostics diagnostics;
1123 {
1124 10 std::unique_lock lock(m_bindings_rw_mutex);
1125 10 const auto it = m_name_index.find(name);
1126
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 10 times.
10 if (it == m_name_index.end())
1127 {
1128 return;
1129 }
1130 10 bool changed = false;
1131
2/2
✓ Branch 26 → 11 taken 10 times.
✓ Branch 26 → 27 taken 10 times.
30 for (const size_t idx : it->second)
1132 {
1133
2/2
✓ Branch 14 → 15 taken 8 times.
✓ Branch 14 → 17 taken 2 times.
10 if (m_bindings[idx].consume != consume)
1134 {
1135 8 m_bindings[idx].consume = consume;
1136 8 changed = true;
1137 }
1138 }
1139 // Refresh the interception gates only on a real transition, as set_consume_by_owner does. A redundant
1140 // rebuild advances the generation and makes every live BindingToken stale despite no state change.
1141 // InputTest.BindingTokenStaysCurrentAfterRedundantConsumeSet pins both no-op flag values.
1142
2/2
✓ Branch 27 → 28 taken 8 times.
✓ Branch 27 → 29 taken 2 times.
10 if (changed)
1143 {
1144 8 recompute_modifier_caches_locked(diagnostics, CacheFailPolicy::Retain);
1145 }
1146
1/2
✓ Branch 31 → 32 taken 10 times.
✗ Branch 31 → 35 not taken.
10 }
1147 10 diagnostics.emit();
1148 }
1149
1150 110 void InputPoller::set_consume_by_owner(std::uint64_t owner, bool consume) noexcept
1151 {
1152 // The value 0 is the "no owner" sentinel. Skip the scan so an unstamped call cannot clear all owner-0
1153 // bindings.
1154
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 110 times.
110 if (owner == 0)
1155 {
1156 return;
1157 }
1158 110 DeferredDiagnostics diagnostics;
1159 {
1160 110 std::unique_lock lock(m_bindings_rw_mutex);
1161 110 bool changed = false;
1162
2/2
✓ Branch 21 → 7 taken 9 times.
✓ Branch 21 → 22 taken 110 times.
229 for (auto &binding : m_bindings)
1163 {
1164
3/4
✓ Branch 9 → 10 taken 7 times.
✓ Branch 9 → 12 taken 2 times.
✓ Branch 10 → 11 taken 7 times.
✗ Branch 10 → 12 not taken.
9 if (binding.consume_owner == owner && binding.consume != consume)
1165 {
1166 7 binding.consume = consume;
1167 7 changed = true;
1168 }
1169 }
1170 // Rebuild only on a real transition. A redundant rebuild advances the generation and makes every live
1171 // BindingToken stale despite no state change.
1172
2/2
✓ Branch 22 → 23 taken 7 times.
✓ Branch 22 → 24 taken 103 times.
110 if (changed)
1173 {
1174 7 recompute_modifier_caches_locked(diagnostics, CacheFailPolicy::Retain);
1175 }
1176 110 }
1177 110 diagnostics.emit();
1178 }
1179
1180 619 void InputPoller::shutdown() noexcept
1181 {
1182
2/2
✓ Branch 3 → 4 taken 340 times.
✓ Branch 3 → 9 taken 279 times.
619 if (!m_poll_thread.joinable())
1183 {
1184 // Preserve the keepalive and the open external-host lease after a detach because that thread can
1185 // still read these members. An unstarted or drained poller has no such reader, so close the lease
1186 // and release the precommitted keepalive so it does not remain for the process lifetime.
1187
2/2
✓ Branch 5 → 6 taken 337 times.
✓ Branch 5 → 8 taken 3 times.
340 if (!m_requires_abandonment.load(std::memory_order_acquire))
1188 {
1189 337 wheel_source_close();
1190 337 m_owner_keepalive.reset();
1191 }
1192 340 return;
1193 }
1194
1195 279 m_poll_thread.request_stop();
1196 279 m_cv.notify_all();
1197
1198
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 18 taken 278 times.
279 if (!blocking_teardown_permitted())
1199 {
1200 // No authorization exists to block. A join can deadlock the loader, so detach the thread and leak its
1201 // module reference. The detached thread still executes, so shared binding state and hold-release
1202 // callbacks must not be touched here (mirrors clear_bindings(invoke_callbacks=false)).
1203 1 m_requires_abandonment.store(true, std::memory_order_release);
1204 try
1205 {
1206
1/2
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 35 not taken.
1 m_poll_thread.detach();
1207 }
1208 catch (...)
1209 {
1210 // The abandonment flag pins the keepalive, so the poller (and its jthread member) is never
1211 // destroyed and ~jthread's loader-lock join is never reached.
1212 }
1213 1 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
1214 1 m_running.store(false, std::memory_order_release);
1215 1 return;
1216 }
1217
1218
2/2
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 25 taken 275 times.
278 if (m_poll_thread.get_id() == std::this_thread::get_id())
1219 {
1220 // The poll thread is its own teardown thread after a callback reaches this path. A self-join raises,
1221 // and every later step is unsafe while this thread is inside the body those steps retire. The
1222 // owner hands this poller to the reaper, which re-enters shutdown() off-thread. See self_retiring().
1223 3 m_running.store(false, std::memory_order_release);
1224 3 m_self_retiring.store(true, std::memory_order_release);
1225 3 return;
1226 }
1227
1228 try
1229 {
1230 #if defined(DMK_ENABLE_TEST_SEAMS)
1231
2/2
✓ Branch 25 → 26 taken 3 times.
✓ Branch 25 → 27 taken 272 times.
275 if (auto *seam = g_input_join_fail_seam)
1232 {
1233
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 38 taken 3 times.
3 seam();
1234 }
1235 #endif
1236
1/2
✓ Branch 27 → 28 taken 272 times.
✗ Branch 27 → 38 not taken.
272 m_poll_thread.join();
1237 }
1238 3 catch (...)
1239 {
1240 // Poll-thread completion is now uncertain. Detach it, which also prevents a ~jthread rejoin. Keep the
1241 // module reference and detours because the thread can still read their state.
1242 3 m_requires_abandonment.store(true, std::memory_order_release);
1243 try
1244 {
1245
1/2
✓ Branch 41 → 42 taken 3 times.
✗ Branch 41 → 48 not taken.
3 m_poll_thread.detach();
1246 }
1247 catch (...)
1248 {
1249 }
1250 3 (void)log().try_log(
1251 LogLevel::Error,
1252 "InputPoller: poll-thread join failed; abandoning its module reference and "
1253 "leaving the interception detours installed to stay memory-safe."
1254 );
1255 3 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
1256 3 m_self_ref = nullptr;
1257 3 m_running.store(false, std::memory_order_release);
1258 3 return;
1259 3 }
1260
1261 // The join completed off the loader lock. Drop the reference taken before thread creation. The active
1262 // caller for this teardown still holds its own, so this is never the terminal release.
1263 272 release_module_ref(static_cast<HMODULE>(m_self_ref), diagnostics::ModulePinReason::InputPoller);
1264 272 m_self_ref = nullptr;
1265
1266 // The poll thread is provably stopped here. Release of active holds and dispatch of their
1267 // on_state_change(false) callbacks is race-free.
1268 272 m_running.store(false, std::memory_order_release);
1269
1270 // The poll thread is the sole mask publisher and trampoline reader, so hook teardown now is race-free.
1271 // Skipped on the loader-lock path above: hook removal must not run under the loader lock, so the detours
1272 // stay installed there. The owner id makes a superseded poller's teardown a no-op. Close the external-host
1273 // lease before the local uninstall. A close proves the host holds no pointer from this generation; it is a
1274 // no-op for the local backends. uninstall() then tears down XInput and the local wheel source and revokes
1275 // the layer owner.
1276 272 wheel_source_close();
1277 272 uninstall(m_intercept_owner);
1278
1279 272 release_active_holds();
1280
1281 // This call still has an external owner. Clear the cycle last so that owner destroys the poller only
1282 // after the worker body and every rundown step can no longer touch it.
1283 272 m_owner_keepalive.reset();
1284 }
1285
1286 276 void InputPoller::poll_loop(std::stop_token stop_token)
1287 {
1288 276 const int trigger_thresh = m_trigger_threshold;
1289 276 const int stick_thresh = m_stick_threshold;
1290
1291 276 constexpr auto gamepad_reconnect_interval = std::chrono::seconds{2};
1292 276 bool gamepad_was_connected = false;
1293 276 auto last_gamepad_poll = std::chrono::steady_clock::time_point{};
1294
1295 // Interception state persists across cycles and remains private to the poll thread.
1296 276 WheelPulseState wheel_pulse{};
1297 276 GamepadSuppressState gp_suppress{};
1298
1299 // External-host counts drained in a cycle whose binding generation moved before evaluation. Drained
1300 // notches have no physical equivalent to repeat, so the cycle parks them here and the next drain merges
1301 // them instead of dropping them. Private to the poll thread.
1302 276 std::array<int, 4> external_wheel_carry{};
1303
1304 // This flag tracks whether the previous cycle published live gamepad suppression. The disarm below runs
1305 // exactly once on the arm->disarm transition, which includes removal of the last consume gamepad binding. A
1306 // plain flag gate skips that transition.
1307 276 bool gamepad_suppress_active = false;
1308
1309 // This state remains private to the poll thread. is_code_pressed reads it only when
1310 // gamepad_connected is true, which holds only after a successful poll overwrites it.
1311 276 XINPUT_STATE gamepad_state{};
1312
1313 // The per-cycle keyboard and mouse state cache lives for the poll thread's lifetime. See
1314 // input_key_cache.hpp.
1315 276 KeyStateCache key_cache;
1316
1317 struct PendingCallback
1318 {
1319 // The lease appears first, so destruction occurs after both std::function members and their capture
1320 // managers.
1321 StagedCallbackLease lease;
1322 std::string name;
1323 std::function<void()> on_press;
1324 std::function<void(bool)> on_state_change;
1325 bool hold_value;
1326 // The edge's state transition commits only after the whole pass stages every edge. This deferral
1327 // makes a failed pass leave no edge behind. m_active_states still holds the pre-pass value. The next
1328 // cycle derives this edge again from unchanged physical input.
1329 std::size_t state_index = 0;
1330 std::uint8_t state_value = 0;
1331
1332 469 PendingCallback(
1333 StagedCallbackLease staged_lease,
1334 std::string binding_name,
1335 std::function<void()> press_callback,
1336 std::function<void(bool)> state_callback,
1337 bool next_hold_value,
1338 std::size_t next_state_index,
1339 std::uint8_t next_state_value
1340 )
1341 1407 : lease(std::move(staged_lease)), name(std::move(binding_name)),
1342 1407 on_press(std::move(press_callback)), on_state_change(std::move(state_callback)),
1343 469 hold_value(next_hold_value), state_index(next_state_index), state_value(next_state_value)
1344 {
1345 469 }
1346 };
1347 276 std::vector<PendingCallback> pending;
1348
1349
2/2
✓ Branch 380 → 3 taken 686 times.
✓ Branch 380 → 381 taken 276 times.
962 while (!stop_token.stop_requested())
1350 {
1351 686 pending.clear();
1352 686 key_cache.reset();
1353 const bool process_focused =
1354
3/4
✓ Branch 6 → 7 taken 109 times.
✓ Branch 6 → 9 taken 577 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 109 times.
686 !m_require_focus.load(std::memory_order_relaxed) || is_process_foreground();
1355
1356 // Install the active-input hooks on demand. Each call is idempotent and fails cheaply until its target
1357 // appears. The XInput call runs every cycle, not only while coverage is absent. An installed pair
1358 // can still lose an entry point to a rival writer. A skip based on the published flag hides that loss.
1359
2/2
✓ Branch 12 → 13 taken 10 times.
✓ Branch 12 → 14 taken 676 times.
686 if (m_has_consume_gamepad_bindings.load(std::memory_order_relaxed))
1360 {
1361 10 (void)install_xinput(m_gamepad_index, m_intercept_owner);
1362 }
1363 686 const bool has_wheel_bindings = m_has_wheel_bindings.load(std::memory_order_acquire);
1364
2/2
✓ Branch 15 → 16 taken 54 times.
✓ Branch 15 → 17 taken 632 times.
686 if (has_wheel_bindings)
1365 {
1366 // Route maintenance every cycle: mount an absent route, migrate a moved one, and latch health.
1367 // A ready route on the selected thread is a cheap liveness recheck.
1368 54 wheel_source_maintain();
1369 }
1370
1371 // An install can publish ownership during this cycle. Check it before the rule publication.
1372
6/6
✓ Branch 18 → 19 taken 662 times.
✓ Branch 18 → 22 taken 24 times.
✓ Branch 20 → 21 taken 11 times.
✓ Branch 20 → 22 taken 651 times.
✓ Branch 23 → 24 taken 11 times.
✓ Branch 23 → 29 taken 675 times.
1348 if (m_consume_rules_unpublished.load(std::memory_order_acquire) &&
1373 662 intercept_owned_by(m_intercept_owner))
1374 {
1375 11 DeferredDiagnostics diagnostics;
1376 {
1377
1/2
✓ Branch 24 → 25 taken 11 times.
✗ Branch 24 → 383 not taken.
11 std::unique_lock rules_lock(m_bindings_rw_mutex);
1378 11 publish_consume_rules_locked(diagnostics);
1379 11 }
1380 11 diagnostics.emit();
1381 }
1382
1383 // Accumulate bits that active consume bindings claim this cycle, then publish them after the binding
1384 // loop. A consume binding masks only what it owns. "Ctrl+WheelUp" contributes Up only while Ctrl is
1385 // held. Publication each cycle also disarms the mask after the last binding leaves.
1386 686 uint16_t gamepad_owned = 0;
1387 686 uint8_t wheel_owned = 0;
1388
1389 // Poll gamepad state once per connected cycle. Throttle reconnection attempts on empty slots.
1390 // A read through the saved trampoline gives the poll the true, unmasked controller state.
1391 686 bool gamepad_connected = false;
1392
6/6
✓ Branch 30 → 31 taken 30 times.
✓ Branch 30 → 33 taken 656 times.
✓ Branch 31 → 32 taken 10 times.
✓ Branch 31 → 33 taken 20 times.
✓ Branch 34 → 35 taken 10 times.
✓ Branch 34 → 55 taken 676 times.
686 if (m_has_gamepad_bindings.load(std::memory_order_relaxed) && process_focused)
1393 {
1394 10 const auto now = std::chrono::steady_clock::now();
1395
7/10
✓ Branch 36 → 37 taken 10 times.
✗ Branch 36 → 41 not taken.
✓ Branch 37 → 38 taken 10 times.
✗ Branch 37 → 385 not taken.
✓ Branch 38 → 39 taken 10 times.
✗ Branch 38 → 385 not taken.
✓ Branch 40 → 41 taken 3 times.
✓ Branch 40 → 42 taken 7 times.
✓ Branch 43 → 44 taken 3 times.
✓ Branch 43 → 54 taken 7 times.
10 if (gamepad_was_connected || (now - last_gamepad_poll) >= gamepad_reconnect_interval)
1396 {
1397 3 last_gamepad_poll = now;
1398 // Dereference the saved trampoline only while this poller owns the layer. A non-owner call is
1399 // invisible to the detour in-flight drain. It can traverse memory that owner removal frees.
1400 // Therefore, a non-owner calls XInputGetState. One fresh check suffices because this thread
1401 // cannot lose its own ownership mid-cycle.
1402 const XInputGetStateFn xinput_original =
1403
1/2
✓ Branch 45 → 46 taken 3 times.
✗ Branch 45 → 47 not taken.
3 intercept_owned_by(m_intercept_owner) ? xinput_trampoline() : nullptr;
1404 const DWORD xinput_result =
1405 (xinput_original != nullptr)
1406
2/4
✓ Branch 48 → 49 taken 3 times.
✗ Branch 48 → 51 not taken.
✓ Branch 49 → 50 taken 3 times.
✗ Branch 49 → 387 not taken.
3 ? xinput_original(static_cast<DWORD>(m_gamepad_index), &gamepad_state)
1407 : XInputGetState(static_cast<DWORD>(m_gamepad_index), &gamepad_state);
1408 3 gamepad_was_connected = xinput_result == ERROR_SUCCESS;
1409 }
1410 10 gamepad_connected = gamepad_was_connected;
1411 }
1412
1413 // Stage this cycle's edge callbacks, then dispatch after release of the binding lock so user code can
1414 // re-enter update_combos(). One catch treats the whole pass as a transaction. A failed pass restores
1415 // staged mutations and owes no callback. The next cycle derives the same edges from unchanged physical
1416 // input. A binding without a staged edge commits immediately, so a release and press before the next
1417 // cycle still produces its press.
1418 686 WheelPulseState wheel_pulse_staged = wheel_pulse;
1419
1420 // Arm the swallow mask only for a cycle that also drains the wheel counters. A rebuild failure stops
1421 // the drain while the consume wheel binding stays in m_bindings. An armed mask then swallows every
1422 // notch without delivery. That state cannot lapse on its own.
1423 686 bool wheel_drained = false;
1424 686 std::array<int, 4> external_wheel_counts{};
1425 686 std::uint64_t external_wheel_generation = 0;
1426 686 bool external_wheel_counts_taken = false;
1427
2/2
✓ Branch 55 → 56 taken 128 times.
✓ Branch 55 → 78 taken 558 times.
686 if (m_wheel_backend == input::Input::WheelBackend::ExternalHost)
1428 {
1429 {
1430 128 std::shared_lock generation_lock(m_bindings_rw_mutex);
1431
2/2
✓ Branch 58 → 59 taken 28 times.
✓ Branch 58 → 60 taken 100 times.
128 if (m_has_wheel_bindings.load(std::memory_order_relaxed))
1432 {
1433 28 external_wheel_generation = m_binding_generation;
1434 }
1435 128 }
1436
2/2
✓ Branch 61 → 62 taken 28 times.
✓ Branch 61 → 78 taken 100 times.
128 if (external_wheel_generation != 0)
1437 {
1438
2/2
✓ Branch 63 → 64 taken 8 times.
✓ Branch 63 → 66 taken 20 times.
28 if (m_external_wheel_discard_pending.exchange(false, std::memory_order_acq_rel))
1439 {
1440 // The no-wheel -> wheel transition discards the unowned backlog, parked carry included.
1441 8 external_wheel_carry = {};
1442 8 (void)wheel_source_take_counts();
1443 }
1444 28 external_wheel_counts = wheel_source_take_counts();
1445
2/2
✓ Branch 74 → 68 taken 112 times.
✓ Branch 74 → 75 taken 28 times.
168 for (std::size_t dir = 0; dir < external_wheel_counts.size(); ++dir)
1446 {
1447 112 external_wheel_counts[dir] += std::exchange(external_wheel_carry[dir], 0);
1448 }
1449 28 external_wheel_counts_taken = true;
1450 #ifdef DMK_ENABLE_TEST_SEAMS
1451
2/2
✓ Branch 76 → 77 taken 5 times.
✓ Branch 76 → 78 taken 23 times.
28 if (g_input_external_wheel_post_drain_probe)
1452 {
1453
1/2
✓ Branch 77 → 78 taken 5 times.
✗ Branch 77 → 431 not taken.
5 g_input_external_wheel_post_drain_probe(external_wheel_counts);
1454 }
1455 #endif
1456 }
1457 }
1458 try
1459 {
1460 // Re-reserve to the current binding count before acquisition of the evaluation lock. This keeps
1461 // the growth allocation outside the critical section. The catch still covers the residual race
1462 // where a concurrent add_binding grows the set first.
1463 686 size_t reserve_hint = 0;
1464 {
1465 686 std::shared_lock count_lock(m_bindings_rw_mutex);
1466 686 reserve_hint = m_bindings.size();
1467 686 }
1468
1/2
✓ Branch 81 → 82 taken 686 times.
✗ Branch 81 → 405 not taken.
686 pending.reserve(reserve_hint);
1469
1470 686 std::shared_lock lock(m_bindings_rw_mutex);
1471 686 const size_t count = m_bindings.size();
1472 686 const auto &known_mods = m_known_modifiers;
1473
1474 // Snapshot the accumulated wheel notches into a per-cycle pulse mask so each notch maps to
1475 // exactly one Press edge. The poll drains it while unfocused, so a background notch is discarded.
1476 // The flag read, drain, and m_bindings snapshot share one shared-lock epoch. No reshape can split
1477 // that epoch.
1478 686 uint8_t wheel_pulse_mask = 0;
1479
2/2
✓ Branch 85 → 86 taken 46 times.
✓ Branch 85 → 100 taken 640 times.
686 if (m_has_wheel_bindings.load(std::memory_order_relaxed))
1480 {
1481 46 const bool external_generation_matches =
1482
3/4
✓ Branch 86 → 87 taken 28 times.
✓ Branch 86 → 89 taken 18 times.
✓ Branch 87 → 88 taken 28 times.
✗ Branch 87 → 90 not taken.
74 m_wheel_backend != input::Input::WheelBackend::ExternalHost ||
1483
2/2
✓ Branch 88 → 89 taken 27 times.
✓ Branch 88 → 90 taken 1 time.
28 (external_wheel_counts_taken && m_binding_generation == external_wheel_generation);
1484
2/2
✓ Branch 91 → 92 taken 45 times.
✓ Branch 91 → 98 taken 1 time.
46 if (external_generation_matches)
1485 {
1486 45 const auto taken = m_wheel_backend == input::Input::WheelBackend::ExternalHost
1487
2/2
✓ Branch 92 → 93 taken 27 times.
✓ Branch 92 → 94 taken 18 times.
45 ? external_wheel_counts
1488 45 : wheel_source_take_counts();
1489 45 add_wheel_notches(wheel_pulse, taken);
1490 // Take the rollback point after the drain. Restoration reverses the staged pulse step but
1491 // preserves drained notches, which have no physical equivalent to repeat.
1492 45 wheel_pulse_staged = wheel_pulse;
1493 45 wheel_pulse_mask = step_wheel_pulse(wheel_pulse);
1494 45 wheel_drained = true;
1495 }
1496
1/2
✓ Branch 98 → 99 taken 1 time.
✗ Branch 98 → 100 not taken.
1 else if (external_wheel_counts_taken)
1497 {
1498 // A reshape moved the generation between the drain and this evaluation. Park the drained
1499 // counts so the next cycle's drain merges them.
1500 1 external_wheel_carry = external_wheel_counts;
1501 }
1502 }
1503
1504
2/2
✓ Branch 282 → 101 taken 2691 times.
✓ Branch 282 → 283 taken 683 times.
3374 for (size_t i = 0; i < count; ++i)
1505 {
1506 2691 const auto &binding = m_bindings[i];
1507
2/2
✓ Branch 103 → 104 taken 6 times.
✓ Branch 103 → 105 taken 2685 times.
2691 if (binding.keys.empty())
1508 {
1509 6 continue;
1510 }
1511
1512 2685 bool any_pressed = false;
1513
1514
2/2
✓ Branch 105 → 106 taken 801 times.
✓ Branch 105 → 192 taken 1884 times.
2685 if (process_focused)
1515 {
1516 801 bool modifiers_held = true;
1517
2/2
✓ Branch 122 → 108 taken 8 times.
✓ Branch 122 → 123 taken 794 times.
1603 for (const auto &mod : binding.modifiers)
1518 {
1519
2/2
✓ Branch 111 → 112 taken 7 times.
✓ Branch 111 → 113 taken 1 time.
8 if (!is_code_pressed(
1520 mod,
1521 key_cache,
1522 gamepad_state,
1523 gamepad_connected,
1524 trigger_thresh,
1525 stick_thresh,
1526 wheel_pulse_mask
1527 ))
1528 {
1529 7 modifiers_held = false;
1530 7 break;
1531 }
1532 }
1533
1534
2/2
✓ Branch 123 → 124 taken 794 times.
✓ Branch 123 → 161 taken 7 times.
801 if (modifiers_held)
1535 {
1536 // Enforce an exact modifier set. Reject any known modifier absent from this binding's
1537 // required set when it is held.
1538
2/2
✓ Branch 159 → 126 taken 2 times.
✓ Branch 159 → 160 taken 793 times.
1589 for (const auto &km : known_mods)
1539 {
1540
1/2
✗ Branch 129 → 130 not taken.
✓ Branch 129 → 131 taken 2 times.
2 if (!is_code_pressed(
1541 km,
1542 key_cache,
1543 gamepad_state,
1544 gamepad_connected,
1545 trigger_thresh,
1546 stick_thresh,
1547 wheel_pulse_mask
1548 ))
1549 {
1550 continue;
1551 }
1552 2 bool is_required = false;
1553
2/2
✓ Branch 147 → 133 taken 1 time.
✓ Branch 147 → 148 taken 1 time.
4 for (const auto &mod : binding.modifiers)
1554 {
1555
1/2
✓ Branch 136 → 137 taken 1 time.
✗ Branch 136 → 138 not taken.
1 if (modifier_satisfies(mod, km))
1556 {
1557 1 is_required = true;
1558 1 break;
1559 }
1560 }
1561
2/2
✓ Branch 148 → 149 taken 1 time.
✓ Branch 148 → 150 taken 1 time.
2 if (!is_required)
1562 {
1563 1 modifiers_held = false;
1564 1 break;
1565 }
1566 }
1567 }
1568
1569
2/2
✓ Branch 161 → 162 taken 793 times.
✓ Branch 161 → 192 taken 8 times.
801 if (modifiers_held)
1570 {
1571
2/2
✓ Branch 190 → 164 taken 793 times.
✓ Branch 190 → 191 taken 38 times.
1624 for (const auto &key : binding.keys)
1572 {
1573 793 const bool key_pressed = is_code_pressed(
1574 key,
1575 key_cache,
1576 gamepad_state,
1577 gamepad_connected,
1578 trigger_thresh,
1579 stick_thresh,
1580 wheel_pulse_mask
1581 );
1582
1583 // Pre-arm the consume bit while the modifiers are held, before the trigger is
1584 // pressed. The mask trails physical state by one cycle. A claim only on a pressed
1585 // trigger leaks its initial edge to the faster game poll.
1586 // A mask for a still-up bit is a no-op, and the consume-until-release latch still
1587 // trails the trigger.
1588
5/6
✓ Branch 167 → 168 taken 13 times.
✓ Branch 167 → 172 taken 780 times.
✓ Branch 168 → 169 taken 3 times.
✓ Branch 168 → 172 taken 10 times.
✓ Branch 169 → 170 taken 3 times.
✗ Branch 169 → 172 not taken.
793 if (binding.consume && key.source == InputSource::Gamepad && key.code > 0 &&
1589
1/2
✓ Branch 170 → 171 taken 3 times.
✗ Branch 170 → 172 not taken.
3 key.code < GamepadCode::LeftTrigger)
1590 {
1591 3 gamepad_owned =
1592 3 static_cast<uint16_t>(gamepad_owned | static_cast<uint16_t>(key.code));
1593 }
1594
1595 // Pre-arm the wheel-consume bit while the modifiers are held. The queue hook
1596 // decides whether to swallow as soon as a message arrives. The mask must reflect
1597 // "modifiers currently satisfied", not the derived wheel_pulse_mask. This mirrors
1598 // the gamepad pre-arm above.
1599
4/4
✓ Branch 172 → 173 taken 13 times.
✓ Branch 172 → 177 taken 780 times.
✓ Branch 173 → 174 taken 10 times.
✓ Branch 173 → 177 taken 3 times.
793 if (binding.consume && key.source == InputSource::MouseWheel &&
1600
2/4
✓ Branch 174 → 175 taken 10 times.
✗ Branch 174 → 177 not taken.
✓ Branch 175 → 176 taken 10 times.
✗ Branch 175 → 177 not taken.
10 key.code >= WheelCode::Up && key.code <= WheelCode::Right)
1601 {
1602 10 wheel_owned = static_cast<uint8_t>(
1603 10 wheel_owned | static_cast<uint8_t>(1u << (key.code - WheelCode::Up))
1604 );
1605 }
1606
1607 // Activation still keys off the real press: a non-consume binding fires on the
1608 // first pressed key and stops. A consume binding continues its scan so the
1609 // pre-arm above sees every owned bit.
1610
2/2
✓ Branch 177 → 178 taken 38 times.
✓ Branch 177 → 179 taken 755 times.
793 if (!key_pressed)
1611 {
1612 38 continue;
1613 }
1614 755 any_pressed = true;
1615
1/2
✓ Branch 179 → 180 taken 755 times.
✗ Branch 179 → 181 not taken.
755 if (!binding.consume)
1616 {
1617 755 break;
1618 }
1619 }
1620 }
1621 }
1622
1623 2685 const bool was_active = m_active_states[i].load(std::memory_order_relaxed) != 0;
1624
2/2
✓ Branch 200 → 201 taken 755 times.
✓ Branch 200 → 202 taken 1930 times.
2685 const std::uint8_t next_state = any_pressed ? 1 : 0;
1625
1626
2/3
✓ Branch 203 → 204 taken 1925 times.
✓ Branch 203 → 242 taken 760 times.
✗ Branch 203 → 279 not taken.
2685 switch (binding.trigger)
1627 {
1628 1925 case input::Trigger::Press:
1629 {
1630
8/8
✓ Branch 204 → 205 taken 27 times.
✓ Branch 204 → 209 taken 1898 times.
✓ Branch 205 → 206 taken 22 times.
✓ Branch 205 → 209 taken 5 times.
✓ Branch 207 → 208 taken 21 times.
✓ Branch 207 → 209 taken 1 time.
✓ Branch 210 → 211 taken 21 times.
✓ Branch 210 → 232 taken 1904 times.
1925 if (any_pressed && !was_active && binding.on_press)
1631 {
1632 const std::uint64_t generation =
1633
1/2
✓ Branch 212 → 213 taken 21 times.
✗ Branch 212 → 215 not taken.
21 binding.lifecycle ? binding.lifecycle->generation() : 0;
1634 21 StagedCallbackLease lease{binding.lifecycle, generation};
1635
1/2
✗ Branch 220 → 221 not taken.
✓ Branch 220 → 222 taken 21 times.
21 if (!lease.engaged())
1636 {
1637 continue;
1638 }
1639 21 pending.emplace_back(
1640
2/2
✓ Branch 225 → 226 taken 18 times.
✓ Branch 225 → 388 taken 3 times.
21 std::move(lease),
1641 21 binding.name,
1642 21 binding.on_press,
1643 24 std::function<void(bool)>{},
1644 24 false,
1645 i,
1646 next_state
1647 );
1648 18 break;
1649
1/2
✗ Branch 229 → 230 not taken.
✓ Branch 229 → 231 taken 18 times.
21 }
1650 1904 m_active_states[i].store(next_state, std::memory_order_relaxed);
1651 1904 break;
1652 }
1653 760 case input::Trigger::Hold:
1654 {
1655
5/6
✓ Branch 242 → 243 taken 451 times.
✓ Branch 242 → 246 taken 309 times.
✓ Branch 244 → 245 taken 451 times.
✗ Branch 244 → 246 not taken.
✓ Branch 247 → 248 taken 451 times.
✓ Branch 247 → 269 taken 309 times.
760 if (any_pressed != was_active && binding.on_state_change)
1656 {
1657 const std::uint64_t generation =
1658
1/2
✓ Branch 249 → 250 taken 451 times.
✗ Branch 249 → 252 not taken.
451 binding.lifecycle ? binding.lifecycle->generation() : 0;
1659 451 StagedCallbackLease lease{binding.lifecycle, generation};
1660
1/2
✗ Branch 257 → 258 not taken.
✓ Branch 257 → 259 taken 451 times.
451 if (!lease.engaged())
1661 {
1662 continue;
1663 }
1664 451 pending.emplace_back(
1665
1/2
✓ Branch 262 → 263 taken 451 times.
✗ Branch 262 → 395 not taken.
451 std::move(lease),
1666 451 binding.name,
1667 451 std::function<void()>{},
1668 451 binding.on_state_change,
1669 any_pressed,
1670 i,
1671 next_state
1672 );
1673 451 break;
1674
1/2
✗ Branch 266 → 267 not taken.
✓ Branch 266 → 268 taken 451 times.
451 }
1675 309 m_active_states[i].store(next_state, std::memory_order_relaxed);
1676 309 break;
1677 }
1678 }
1679 }
1680
1681 // Commit: atomic stores only, still under the shared lock, so this cannot fail.
1682
2/2
✓ Branch 305 → 285 taken 468 times.
✓ Branch 305 → 306 taken 683 times.
1834 for (const auto &staged : pending)
1683 {
1684 468 m_active_states[staged.state_index].store(staged.state_value, std::memory_order_relaxed);
1685 }
1686 686 }
1687 3 catch (...)
1688 {
1689 // Roll back every source that a staged edge needs. Return the drained notches to the backlog. Drop
1690 // the partial consume masks so suppression disarms wholly.
1691 3 pending.clear();
1692 3 wheel_pulse = wheel_pulse_staged;
1693 3 gamepad_owned = 0;
1694 3 wheel_owned = 0;
1695 3 (void)log().try_log(
1696 LogLevel::Error,
1697 "InputPoller: failed staging poll-cycle callbacks; cycle rolled back"
1698 );
1699
1/2
✓ Branch 411 → 308 taken 3 times.
✗ Branch 411 → 431 not taken.
3 }
1700
1701 // Publish the gamepad suppression mask. The consume-until-release latch keeps a trigger masked until
1702 // release plus a grace window. Modifier release first cannot leak a bare trigger.
1703
5/8
✓ Branch 309 → 310 taken 10 times.
✓ Branch 309 → 313 taken 676 times.
✓ Branch 310 → 311 taken 10 times.
✗ Branch 310 → 313 not taken.
✗ Branch 311 → 312 not taken.
✓ Branch 311 → 313 taken 10 times.
✗ Branch 314 → 315 not taken.
✓ Branch 314 → 320 taken 686 times.
686 if (m_has_consume_gamepad_bindings.load(std::memory_order_relaxed) && process_focused &&
1704 gamepad_connected)
1705 {
1706 const uint16_t suppress = step_gamepad_suppress(
1707 gp_suppress,
1708 gamepad_owned,
1709 gamepad_state.Gamepad.wButtons,
1710 GetTickCount64(),
1711 GAMEPAD_SUPPRESS_GRACE_MS
1712 );
1713 (void)publish_gamepad_suppress(suppress, m_intercept_owner);
1714 // The rule list and its TTL survive focus changes, so the detour needs this explicit gate to
1715 // stop suppression after the mod enters the background.
1716 (void)set_gamepad_rule_suppress_enabled(true, m_intercept_owner);
1717 gamepad_suppress_active = true;
1718 }
1719
1/2
✗ Branch 320 → 321 not taken.
✓ Branch 320 → 324 taken 686 times.
686 else if (gamepad_suppress_active)
1720 {
1721 // On exit from armed state due to focus loss, disconnect, or removal of the last consume binding,
1722 // disarm once. The game regains the buttons next cycle instead of after the TTL lapses. Publication
1723 // only on this edge keeps the idle path free of a per-cycle clock read.
1724 gp_suppress = GamepadSuppressState{};
1725 (void)publish_gamepad_suppress(0, m_intercept_owner);
1726 (void)set_gamepad_rule_suppress_enabled(false, m_intercept_owner);
1727 gamepad_suppress_active = false;
1728 }
1729
1730 // Publish the per-direction wheel-swallow mask every cycle. Tie it to the drain instead of wheel_owned
1731 // alone. The mask cannot outlive the loop's ability to deliver the notches it swallows. capture_enabled
1732 // stays true while wheel bindings exist so the external host keeps counting between drains; the local
1733 // backends ignore that argument because ownership drives their capture state.
1734
2/2
✓ Branch 324 → 325 taken 45 times.
✓ Branch 324 → 326 taken 641 times.
686 wheel_source_publish_consume(wheel_drained ? wheel_owned : 0, has_wheel_bindings);
1735
1736 #ifdef DMK_ENABLE_TEST_SEAMS
1737 // Between the stage pass and dispatch, a test reshapes the binding set here. The check below refuses a
1738 // staged callback after this reshape advances its generation or tombstones its binding.
1739
2/2
✓ Branch 329 → 330 taken 26 times.
✓ Branch 329 → 332 taken 660 times.
686 if (g_input_post_stage_probe)
1740 {
1741
1/2
✓ Branch 331 → 332 taken 26 times.
✗ Branch 331 → 431 not taken.
26 g_input_post_stage_probe(pending.size());
1742 }
1743 #endif
1744
1745
2/2
✓ Branch 371 → 334 taken 468 times.
✓ Branch 371 → 372 taken 686 times.
1840 for (auto &callback : pending)
1746 {
1747 // A terminal hold-release (false) edge is admitted even across a generation advance: it only
1748 // ends a held state. A dropped edge strands the consumer in its held state. A press or held(true)
1749 // edge is still refused once its generation advanced or its registration was tombstoned.
1750 const bool admit_across_generation =
1751
4/4
✓ Branch 337 → 338 taken 451 times.
✓ Branch 337 → 340 taken 17 times.
✓ Branch 338 → 339 taken 3 times.
✓ Branch 338 → 340 taken 448 times.
468 static_cast<bool>(callback.on_state_change) && !callback.hold_value;
1752 const BindingInvocation invocation{
1753 callback.lease.lifecycle(),
1754 callback.lease.generation(),
1755 admit_across_generation
1756 468 };
1757
2/2
✓ Branch 345 → 346 taken 6 times.
✓ Branch 345 → 347 taken 462 times.
468 if (!invocation.admitted())
1758 {
1759 6 continue;
1760 }
1761 #ifdef DMK_ENABLE_TEST_SEAMS
1762
2/2
✓ Branch 348 → 349 taken 1 time.
✓ Branch 348 → 350 taken 461 times.
462 if (g_input_pre_dispatch_probe)
1763 {
1764
1/2
✓ Branch 349 → 350 taken 1 time.
✗ Branch 349 → 422 not taken.
1 g_input_pre_dispatch_probe();
1765 }
1766 #endif
1767 try
1768 {
1769
2/2
✓ Branch 351 → 352 taken 13 times.
✓ Branch 351 → 353 taken 449 times.
462 if (callback.on_press)
1770 {
1771
1/2
✓ Branch 352 → 356 taken 13 times.
✗ Branch 352 → 412 not taken.
13 callback.on_press();
1772 }
1773
1/2
✓ Branch 354 → 355 taken 449 times.
✗ Branch 354 → 356 not taken.
449 else if (callback.on_state_change)
1774 {
1775
2/2
✓ Branch 355 → 356 taken 422 times.
✓ Branch 355 → 412 taken 27 times.
449 callback.on_state_change(callback.hold_value);
1776 }
1777 }
1778
1/2
✓ Branch 412 → 413 taken 27 times.
✗ Branch 412 → 418 not taken.
27 catch (const std::exception &e)
1779 {
1780 27 (void)log().try_log(
1781 LogLevel::Error,
1782 "InputPoller: Exception in callback \"{}\": {}",
1783 27 callback.name,
1784 27 e.what()
1785 );
1786 27 }
1787 catch (...)
1788 {
1789 (void)log().try_log(
1790 LogLevel::Error,
1791 "InputPoller: Unknown exception in callback \"{}\"",
1792 callback.name
1793 );
1794 }
1795
2/2
✓ Branch 358 → 359 taken 462 times.
✓ Branch 358 → 361 taken 6 times.
468 }
1796
1797 // Destroy staged callable copies before the poll wait. A teardown then observes lease completion
1798 // immediately instead of one poll interval later.
1799 686 pending.clear();
1800
1801
1/2
✓ Branch 373 → 374 taken 686 times.
✗ Branch 373 → 431 not taken.
686 std::unique_lock lock(m_cv_mutex);
1802
1/2
✓ Branch 375 → 376 taken 686 times.
✗ Branch 375 → 426 not taken.
686 m_cv.wait_for(
1803 lock,
1804 stop_token,
1805 686 m_poll_interval,
1806 1330 [&stop_token]() { return stop_token.stop_requested(); }
1807 );
1808 686 }
1809 276 }
1810
1811 InputPoller::ComboUpdate
1812 2032 InputPoller::update_combos(std::string_view name, const input::KeyComboList &combos) noexcept
1813 {
1814 2032 std::vector<HoldRelease> hold_releases;
1815 2032 std::vector<BindingRundown> rundowns;
1816 2032 std::vector<InputBinding> staged_bindings;
1817 2032 DeferredDiagnostics diagnostics;
1818
1819 try
1820 {
1821
1/2
✓ Branch 2 → 3 taken 2032 times.
✗ Branch 2 → 386 not taken.
2032 std::unique_lock lock(m_bindings_rw_mutex);
1822
1/2
✓ Branch 3 → 4 taken 2032 times.
✗ Branch 3 → 384 not taken.
2032 const auto it = m_name_index.find(name);
1823
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 11 taken 2030 times.
2032 if (it == m_name_index.end())
1824 {
1825 // Release the writer lock before log output under the deferred-log convention.
1826
1/2
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 384 not taken.
2 lock.unlock();
1827 2 (void)log()
1828 2 .try_log(LogLevel::Debug, "InputPoller: update_combos(\"{}\") ignored: name not found", name);
1829 2 return ComboUpdate::NameAbsent;
1830 }
1831
1832
2/2
✓ Branch 12 → 13 taken 2028 times.
✓ Branch 12 → 384 taken 2 times.
2030 std::vector<size_t> indices = it->second;
1833
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2028 times.
2028 if (indices.empty())
1834 {
1835 return ComboUpdate::NameAbsent;
1836 }
1837
1838 // Stage every fallible binding and cache allocation before any member-state commit.
1839
2/2
✓ Branch 18 → 19 taken 1339 times.
✓ Branch 18 → 82 taken 689 times.
2028 if (indices.size() == combos.size())
1840 {
1841
1/2
✓ Branch 19 → 20 taken 1339 times.
✗ Branch 19 → 355 not taken.
1339 staged_bindings = m_bindings;
1842
2/2
✓ Branch 30 → 21 taken 1339 times.
✓ Branch 30 → 31 taken 1339 times.
2678 for (size_t i = 0; i < indices.size(); ++i)
1843 {
1844 1339 const size_t idx = indices[i];
1845
1/2
✓ Branch 24 → 25 taken 1339 times.
✗ Branch 24 → 355 not taken.
1339 staged_bindings[idx].keys = combos[i].keys;
1846
1/2
✓ Branch 27 → 28 taken 1339 times.
✗ Branch 27 → 355 not taken.
1339 staged_bindings[idx].modifiers = combos[i].modifiers;
1847 }
1848
1849 1339 std::optional<ModifierCaches> caches = build_modifier_caches(staged_bindings);
1850
1/2
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 38 taken 1339 times.
1339 if (!caches)
1851 {
1852 lock.unlock();
1853 (void)log().try_log(
1854 LogLevel::Error,
1855 "InputPoller: out of memory in update_combos; combos unchanged"
1856 );
1857 return ComboUpdate::ResourceFailure;
1858 }
1859
1860
1/2
✓ Branch 39 → 40 taken 1339 times.
✗ Branch 39 → 353 not taken.
1339 rundowns.reserve(indices.size());
1861
2/2
✓ Branch 55 → 42 taken 1339 times.
✓ Branch 55 → 56 taken 1339 times.
4017 for (size_t idx : indices)
1862 {
1863
1/2
✓ Branch 45 → 46 taken 1339 times.
✗ Branch 45 → 352 not taken.
1339 add_rundown(rundowns, m_bindings[idx].lifecycle);
1864 }
1865
1866 1339 m_bindings.swap(staged_bindings);
1867
2/2
✓ Branch 72 → 59 taken 1339 times.
✓ Branch 72 → 73 taken 1339 times.
4017 for (auto &rundown : rundowns)
1868 {
1869 1339 rundown.generation = rundown.lifecycle->advance_generation();
1870 }
1871 1339 m_binding_generation = next_binding_generation();
1872 1339 commit_modifier_caches_locked(*caches, diagnostics);
1873
1/2
✓ Branch 76 → 77 taken 1339 times.
✗ Branch 76 → 353 not taken.
1339 lock.unlock();
1874 1339 diagnostics.emit();
1875 1339 drain_rundowns(rundowns);
1876 1339 return ComboUpdate::Updated;
1877 1339 }
1878
1879 // A cardinality change rebuilds the bindings vector and the parallel m_active_states array. The
1880 // prototype keeps callback identity, mode, and name stable across the rebuild. Its lifecycle is
1881 // held apart so the retained registration receives a new generation instead of a tombstone.
1882
2/2
✓ Branch 84 → 85 taken 687 times.
✓ Branch 84 → 382 taken 2 times.
689 InputBinding prototype = m_bindings[indices.front()];
1883 687 const std::shared_ptr<BindingLifecycle> prototype_lifecycle = prototype.lifecycle;
1884
1/2
✓ Branch 88 → 89 taken 687 times.
✗ Branch 88 → 378 not taken.
687 std::sort(indices.begin(), indices.end());
1885
1886
2/2
✓ Branch 90 → 91 taken 333 times.
✓ Branch 90 → 92 taken 354 times.
687 const size_t append_count = combos.empty() ? 1 : combos.size();
1887 687 const size_t new_size = m_bindings.size() - indices.size() + append_count;
1888
1889 // An empty replacement yields one inert sentinel, so the name stays addressable across a bound ->
1890 // unbound -> bound INI reload cycle.
1891 687 std::vector<InputBinding> appended;
1892
2/2
✓ Branch 95 → 96 taken 686 times.
✓ Branch 95 → 376 taken 1 time.
687 appended.reserve(append_count);
1893
2/2
✓ Branch 97 → 98 taken 333 times.
✓ Branch 97 → 106 taken 353 times.
686 if (combos.empty())
1894 {
1895
1/2
✓ Branch 98 → 99 taken 333 times.
✗ Branch 98 → 358 not taken.
333 InputBinding sentinel = prototype;
1896 333 sentinel.keys.clear();
1897 333 sentinel.modifiers.clear();
1898
1/2
✓ Branch 103 → 104 taken 333 times.
✗ Branch 103 → 356 not taken.
333 appended.push_back(std::move(sentinel));
1899 333 }
1900 else
1901 {
1902
2/2
✓ Branch 126 → 108 taken 701 times.
✓ Branch 126 → 127 taken 349 times.
1403 for (const auto &combo : combos)
1903 {
1904
2/2
✓ Branch 110 → 111 taken 697 times.
✓ Branch 110 → 361 taken 4 times.
701 InputBinding binding = prototype;
1905
1/2
✓ Branch 111 → 112 taken 697 times.
✗ Branch 111 → 359 not taken.
697 binding.keys = combo.keys;
1906
1/2
✓ Branch 112 → 113 taken 697 times.
✗ Branch 112 → 359 not taken.
697 binding.modifiers = combo.modifiers;
1907
1/2
✓ Branch 115 → 116 taken 697 times.
✗ Branch 115 → 359 not taken.
697 appended.push_back(std::move(binding));
1908 697 }
1909 }
1910
1911
2/2
✓ Branch 128 → 129 taken 681 times.
✓ Branch 128 → 376 taken 1 time.
682 staged_bindings.reserve(new_size);
1912 681 std::vector<uint8_t> rebuilt_states;
1913
2/2
✓ Branch 129 → 130 taken 680 times.
✓ Branch 129 → 374 taken 1 time.
681 rebuilt_states.reserve(new_size);
1914
2/2
✓ Branch 130 → 131 taken 679 times.
✓ Branch 130 → 374 taken 1 time.
680 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_size);
1915
1916 // Capture release callbacks for held entries that this update drops. Otherwise, a Hold consumer remains
1917 // held forever after its entry vanishes. The same-name NON-prototype tombstone rejects its staged
1918 // release. Therefore, a gate-backed hold always synthesizes the compensatory false, as remove and clear
1919 // do. The gate deduplicates, so an unheld drop is a no-op and the prototype's already-admitted release
1920 // is not doubled.
1921
2/2
✓ Branch 132 → 133 taken 678 times.
✓ Branch 132 → 372 taken 1 time.
679 hold_releases.reserve(indices.size());
1922
2/2
✓ Branch 168 → 135 taken 1014 times.
✓ Branch 168 → 169 taken 678 times.
2370 for (size_t idx : indices)
1923 {
1924
5/6
✓ Branch 138 → 139 taken 4 times.
✓ Branch 138 → 154 taken 1010 times.
✓ Branch 141 → 142 taken 4 times.
✗ Branch 141 → 154 not taken.
✓ Branch 155 → 156 taken 4 times.
✓ Branch 155 → 159 taken 1010 times.
1018 if (m_bindings[idx].trigger == input::Trigger::Hold && m_bindings[idx].on_state_change &&
1925
1/2
✗ Branch 143 → 144 not taken.
✓ Branch 143 → 153 taken 4 times.
4 (m_bindings[idx].release_is_idempotent ||
1926 m_active_states[idx].load(std::memory_order_relaxed) != 0))
1927 {
1928
1/2
✓ Branch 158 → 159 taken 4 times.
✗ Branch 158 → 363 not taken.
4 hold_releases.emplace_back(m_bindings[idx].on_state_change, m_bindings[idx].name);
1929 }
1930 }
1931
1932
2/2
✓ Branch 170 → 171 taken 677 times.
✓ Branch 170 → 372 taken 1 time.
678 rundowns.reserve(indices.size());
1933
2/2
✓ Branch 186 → 173 taken 1013 times.
✓ Branch 186 → 187 taken 677 times.
2367 for (size_t idx : indices)
1934 {
1935
1/2
✓ Branch 176 → 177 taken 1013 times.
✗ Branch 176 → 364 not taken.
1013 add_rundown(rundowns, m_bindings[idx].lifecycle);
1936 }
1937
1938 // Stage retained entries and their prior atomic states. A held binding stays active after the commit.
1939 677 size_t cursor = 0;
1940
2/2
✓ Branch 214 → 189 taken 1013 times.
✓ Branch 214 → 215 taken 677 times.
2367 for (size_t skip : indices)
1941 {
1942
1/2
✗ Branch 204 → 192 not taken.
✓ Branch 204 → 205 taken 1013 times.
1013 for (size_t i = cursor; i < skip; ++i)
1943 {
1944 rebuilt_states.push_back(m_active_states[i].load(std::memory_order_relaxed));
1945 staged_bindings.push_back(m_bindings[i]);
1946 }
1947 1013 cursor = skip + 1;
1948 }
1949
1/2
✗ Branch 229 → 216 not taken.
✓ Branch 229 → 230 taken 677 times.
677 for (size_t i = cursor; i < m_bindings.size(); ++i)
1950 {
1951 rebuilt_states.push_back(m_active_states[i].load(std::memory_order_relaxed));
1952 staged_bindings.push_back(m_bindings[i]);
1953 }
1954
2/2
✓ Branch 247 → 232 taken 1018 times.
✓ Branch 247 → 248 taken 677 times.
3390 for (auto &binding : appended)
1955 {
1956
1/2
✓ Branch 236 → 237 taken 1018 times.
✗ Branch 236 → 369 not taken.
1018 staged_bindings.push_back(std::move(binding));
1957
1/2
✓ Branch 237 → 238 taken 1018 times.
✗ Branch 237 → 368 not taken.
1018 rebuilt_states.push_back(0);
1958 }
1959
1960 677 std::optional<ModifierCaches> caches = build_modifier_caches(staged_bindings);
1961
2/2
✓ Branch 250 → 251 taken 5 times.
✓ Branch 250 → 255 taken 672 times.
677 if (!caches)
1962 {
1963
1/2
✓ Branch 251 → 252 taken 5 times.
✗ Branch 251 → 370 not taken.
5 lock.unlock();
1964 5 (void)log().try_log(
1965 LogLevel::Error,
1966 "InputPoller: out of memory in update_combos; combos unchanged"
1967 );
1968 5 return ComboUpdate::ResourceFailure;
1969 }
1970
1971
2/2
✓ Branch 268 → 256 taken 1008 times.
✓ Branch 268 → 269 taken 672 times.
1680 for (size_t i = 0; i < rebuilt_states.size(); ++i)
1972 {
1973 1008 new_states[i].store(rebuilt_states[i], std::memory_order_relaxed);
1974 }
1975
1976 672 m_bindings.swap(staged_bindings);
1977 672 m_active_states = std::move(new_states);
1978
2/2
✓ Branch 292 → 275 taken 674 times.
✓ Branch 292 → 293 taken 672 times.
2018 for (auto &rundown : rundowns)
1979 {
1980 1348 rundown.generation = rundown.lifecycle == prototype_lifecycle
1981
2/2
✓ Branch 278 → 279 taken 672 times.
✓ Branch 278 → 281 taken 2 times.
674 ? rundown.lifecycle->advance_generation()
1982 2 : rundown.lifecycle->tombstone();
1983 }
1984 672 m_binding_generation = next_binding_generation();
1985 672 commit_modifier_caches_locked(*caches, diagnostics);
1986
16/16
✓ Branch 298 → 299 taken 672 times.
✓ Branch 298 → 300 taken 5 times.
✓ Branch 302 → 303 taken 672 times.
✓ Branch 302 → 304 taken 5 times.
✓ Branch 306 → 307 taken 672 times.
✓ Branch 306 → 308 taken 5 times.
✓ Branch 310 → 311 taken 672 times.
✓ Branch 310 → 312 taken 5 times.
✓ Branch 314 → 315 taken 672 times.
✓ Branch 314 → 316 taken 5 times.
✓ Branch 318 → 319 taken 672 times.
✓ Branch 318 → 320 taken 5 times.
✓ Branch 322 → 323 taken 672 times.
✓ Branch 322 → 324 taken 1344 times.
✓ Branch 326 → 327 taken 672 times.
✓ Branch 326 → 332 taken 1346 times.
3454 }
1987 14 catch (...)
1988 {
1989 // The transaction allocates before its member-state commit, so the prior state remains intact.
1990 14 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory in update_combos; combos unchanged");
1991 14 return ComboUpdate::ResourceFailure;
1992 14 }
1993
1994 672 diagnostics.emit();
1995 672 drain_rundowns(rundowns);
1996
1997 // Fire the captured release callbacks outside the writer lock. This path runs from a user-driven INI
1998 // reshape, never a DllMain detach, so synchronous dispatch is safe.
1999
2/2
✓ Branch 345 → 333 taken 4 times.
✓ Branch 345 → 346 taken 672 times.
1348 for (auto &[callback, binding_name] : hold_releases)
2000 {
2001 try
2002 {
2003
1/2
✓ Branch 335 → 336 taken 4 times.
✗ Branch 335 → 392 not taken.
4 callback(false);
2004 }
2005 catch (const std::exception &e)
2006 {
2007 (void)log().try_log(
2008 LogLevel::Error,
2009 "InputPoller: Exception in hold release callback \"{}\": {}",
2010 binding_name,
2011 e.what()
2012 );
2013 }
2014 catch (...)
2015 {
2016 (void)log().try_log(
2017 LogLevel::Error,
2018 "InputPoller: Unknown exception in hold release callback \"{}\"",
2019 binding_name
2020 );
2021 }
2022 }
2023
2024 672 return ComboUpdate::Updated;
2025 2032 }
2026
2027 723 bool InputPoller::add_binding(InputBinding binding) noexcept
2028 {
2029 723 DeferredDiagnostics diagnostics;
2030 723 bool added = false;
2031 {
2032 723 std::unique_lock lock(m_bindings_rw_mutex);
2033
2034 723 const size_t old_count = m_bindings.size();
2035 723 const size_t new_count = old_count + 1;
2036
2037 try
2038 {
2039
2/2
✓ Branch 4 → 5 taken 720 times.
✓ Branch 4 → 50 taken 3 times.
723 ensure_lifecycle(binding);
2040
2041 // Build the replacement state array before mutation of m_bindings so an allocation failure leaves
2042 // both at their prior equal sizes. A mismatch causes an out-of-bounds poll read. Seed each retained
2043 // slot from the current value so a held binding does not flicker inactive.
2044
2/2
✓ Branch 5 → 6 taken 718 times.
✓ Branch 5 → 50 taken 2 times.
720 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_count);
2045
2/2
✓ Branch 25 → 7 taken 45371 times.
✓ Branch 25 → 26 taken 718 times.
46089 for (size_t i = 0; i < old_count; ++i)
2046 {
2047 45371 new_states[i].store(
2048 90742 m_active_states[i].load(std::memory_order_relaxed),
2049 std::memory_order_relaxed
2050 );
2051 }
2052 718 new_states[old_count].store(0, std::memory_order_relaxed);
2053
2054 // push_back has the strong guarantee, so a reallocation failure leaves m_bindings unchanged and
2055 // simply discards the new_states array.
2056
2/2
✓ Branch 37 → 38 taken 716 times.
✓ Branch 37 → 48 taken 2 times.
1436 m_bindings.push_back(std::move(binding));
2057 716 m_active_states = std::move(new_states);
2058 716 recompute_modifier_caches_locked(diagnostics);
2059 716 added = true;
2060 718 }
2061 7 catch (...)
2062 {
2063 // Drop the binding and leave the poller unchanged. The false return lets the facade surface
2064 // the failure.
2065 7 diagnostics.add_binding_oom = true;
2066 7 }
2067 723 }
2068 723 diagnostics.emit();
2069 723 return added;
2070 }
2071
2072 3 bool InputPoller::add_bindings(std::vector<InputBinding> bindings) noexcept
2073 {
2074
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 3 times.
3 if (bindings.empty())
2075 {
2076 return true;
2077 }
2078
2079 3 DeferredDiagnostics diagnostics;
2080 3 bool added = false;
2081 {
2082 3 std::unique_lock lock(m_bindings_rw_mutex);
2083
2084 3 const size_t old_count = m_bindings.size();
2085 3 const size_t append_count = bindings.size();
2086 3 const size_t new_count = old_count + append_count;
2087
2088 try
2089 {
2090
2/2
✓ Branch 22 → 10 taken 5 times.
✓ Branch 22 → 23 taken 2 times.
10 for (auto &binding : bindings)
2091 {
2092
2/2
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 80 taken 1 time.
5 ensure_lifecycle(binding);
2093 }
2094
2095 // Allocate every replacement container before mutation of the live engine. Preserve an atomic
2096 // multi-combo registration under OOM.
2097
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 85 not taken.
2 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(new_count);
2098 2 std::vector<InputBinding> rebuilt;
2099
1/2
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 81 not taken.
2 rebuilt.reserve(new_count);
2100
2101
2/2
✓ Branch 48 → 26 taken 2 times.
✓ Branch 48 → 49 taken 2 times.
4 for (size_t i = 0; i < old_count; ++i)
2102 {
2103 2 new_states[i].store(
2104 4 m_active_states[i].load(std::memory_order_relaxed),
2105 std::memory_order_relaxed
2106 );
2107
1/2
✓ Branch 46 → 47 taken 2 times.
✗ Branch 46 → 81 not taken.
4 rebuilt.push_back(std::move(m_bindings[i]));
2108 }
2109
2/2
✓ Branch 64 → 50 taken 4 times.
✓ Branch 64 → 65 taken 2 times.
6 for (size_t i = 0; i < append_count; ++i)
2110 {
2111 4 new_states[old_count + i].store(0, std::memory_order_relaxed);
2112
1/2
✓ Branch 62 → 63 taken 4 times.
✗ Branch 62 → 81 not taken.
8 rebuilt.push_back(std::move(bindings[i]));
2113 }
2114
2115 2 m_bindings = std::move(rebuilt);
2116 2 m_active_states = std::move(new_states);
2117 2 recompute_modifier_caches_locked(diagnostics);
2118 2 added = true;
2119 2 }
2120 1 catch (...)
2121 {
2122 // All allocation precedes any move from m_bindings, so the live poller remains unchanged.
2123 1 diagnostics.add_bindings_oom = true;
2124 1 }
2125 3 }
2126 3 diagnostics.emit();
2127 3 return added;
2128 }
2129
2130 415 size_t InputPoller::remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept
2131 {
2132 415 std::vector<HoldRelease> hold_releases;
2133 415 std::vector<BindingRundown> rundowns;
2134 415 std::vector<InputBinding> retired;
2135 415 std::vector<InputBinding> staged;
2136 415 size_t removed = 0;
2137 415 DeferredDiagnostics diagnostics;
2138
2139 try
2140 {
2141
1/2
✓ Branch 2 → 3 taken 415 times.
✗ Branch 2 → 217 not taken.
415 std::unique_lock lock(m_bindings_rw_mutex);
2142
1/2
✓ Branch 3 → 4 taken 415 times.
✗ Branch 3 → 215 not taken.
415 const auto it = m_name_index.find(name);
2143
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 414 times.
415 if (it == m_name_index.end())
2144 {
2145 1 return 0;
2146 }
2147
2148
1/2
✓ Branch 9 → 10 taken 414 times.
✗ Branch 9 → 215 not taken.
414 std::vector<size_t> indices = it->second;
2149
1/2
✓ Branch 12 → 13 taken 414 times.
✗ Branch 12 → 213 not taken.
414 std::sort(indices.begin(), indices.end());
2150
2151 // Capture release callbacks before erasure, then dispatch them after unlock. Logic-DLL retirement
2152 // passes invoke_callbacks=false because the callbacks reside in a module near removal. Always capture a
2153 // gate-backed hold because the tombstone refuses any staged release. The m_active_states gate strands a
2154 // consumer whose release is staged but not dispatched. The gate swallows an unbalanced released(false).
2155 // A raw callback keeps the m_active_states gate.
2156
2/2
✓ Branch 13 → 14 taken 409 times.
✓ Branch 13 → 53 taken 5 times.
414 if (invoke_callbacks)
2157 {
2158
1/2
✓ Branch 15 → 16 taken 409 times.
✗ Branch 15 → 213 not taken.
409 hold_releases.reserve(indices.size());
2159
2/2
✓ Branch 51 → 18 taken 409 times.
✓ Branch 51 → 52 taken 409 times.
1227 for (size_t idx : indices)
2160 {
2161
5/6
✓ Branch 21 → 22 taken 403 times.
✓ Branch 21 → 37 taken 6 times.
✓ Branch 24 → 25 taken 403 times.
✗ Branch 24 → 37 not taken.
✓ Branch 38 → 39 taken 403 times.
✓ Branch 38 → 42 taken 6 times.
812 if (m_bindings[idx].trigger == input::Trigger::Hold && m_bindings[idx].on_state_change &&
2162
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 36 taken 403 times.
403 (m_bindings[idx].release_is_idempotent ||
2163 m_active_states[idx].load(std::memory_order_relaxed) != 0))
2164 {
2165
1/2
✓ Branch 41 → 42 taken 403 times.
✗ Branch 41 → 199 not taken.
403 hold_releases.emplace_back(m_bindings[idx].on_state_change, m_bindings[idx].name);
2166 }
2167 }
2168 }
2169
2170 // A flat skip-mask lets every retained binding inherit its prior atomic state, so a held binding
2171 // does not briefly report inactive after the reshape.
2172
1/2
✓ Branch 56 → 57 taken 414 times.
✗ Branch 56 → 200 not taken.
414 std::vector<bool> drop(m_bindings.size(), false);
2173
2/2
✓ Branch 73 → 60 taken 414 times.
✓ Branch 73 → 74 taken 414 times.
1242 for (size_t idx : indices)
2174 {
2175 414 drop[idx] = true;
2176 }
2177 414 const size_t survivor_count = m_bindings.size() - indices.size();
2178 414 std::vector<uint8_t> carried;
2179
1/2
✓ Branch 76 → 77 taken 414 times.
✗ Branch 76 → 209 not taken.
414 carried.reserve(survivor_count);
2180
2/2
✓ Branch 93 → 78 taken 620 times.
✓ Branch 93 → 94 taken 414 times.
1034 for (size_t i = 0; i < m_bindings.size(); ++i)
2181 {
2182
2/2
✓ Branch 80 → 81 taken 206 times.
✓ Branch 80 → 91 taken 414 times.
620 if (!drop[i])
2183 {
2184
1/2
✓ Branch 89 → 90 taken 206 times.
✗ Branch 89 → 204 not taken.
412 carried.push_back(m_active_states[i].load(std::memory_order_relaxed));
2185 }
2186 }
2187
2188 // Allocate the replacement state array before erasure, so an allocation failure leaves m_bindings
2189 // and m_active_states at their prior equal sizes.
2190
1/2
✓ Branch 94 → 95 taken 414 times.
✗ Branch 94 → 209 not taken.
414 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(survivor_count);
2191
2/2
✓ Branch 108 → 96 taken 206 times.
✓ Branch 108 → 109 taken 414 times.
620 for (size_t i = 0; i < carried.size(); ++i)
2192 {
2193 206 new_states[i].store(carried[i], std::memory_order_relaxed);
2194 }
2195
1/2
✓ Branch 110 → 111 taken 414 times.
✗ Branch 110 → 207 not taken.
414 retired.reserve(indices.size());
2196
1/2
✓ Branch 111 → 112 taken 414 times.
✗ Branch 111 → 207 not taken.
414 staged.reserve(survivor_count);
2197
2198
1/2
✓ Branch 113 → 114 taken 414 times.
✗ Branch 113 → 207 not taken.
414 rundowns.reserve(indices.size());
2199
2/2
✓ Branch 129 → 116 taken 414 times.
✓ Branch 129 → 130 taken 414 times.
1242 for (size_t idx : indices)
2200 {
2201
1/2
✓ Branch 119 → 120 taken 414 times.
✗ Branch 119 → 205 not taken.
414 add_rundown(rundowns, m_bindings[idx].lifecycle);
2202 }
2203
2/2
✓ Branch 145 → 132 taken 414 times.
✓ Branch 145 → 146 taken 414 times.
1242 for (auto &rundown : rundowns)
2204 {
2205 414 rundown.generation = rundown.lifecycle->tombstone();
2206 }
2207
2208 // Partition into reserved batches so dropped entries outlive the writer lock.
2209
2/2
✓ Branch 158 → 147 taken 620 times.
✓ Branch 158 → 159 taken 414 times.
1034 for (size_t i = 0; i < m_bindings.size(); ++i)
2210 {
2211
3/4
✓ Branch 149 → 150 taken 414 times.
✓ Branch 149 → 151 taken 206 times.
✓ Branch 155 → 156 taken 620 times.
✗ Branch 155 → 206 not taken.
1240 (drop[i] ? retired : staged).push_back(std::move(m_bindings[i]));
2212 }
2213 414 m_bindings.swap(staged);
2214 414 m_active_states = std::move(new_states);
2215 414 removed = indices.size();
2216
2217 414 recompute_modifier_caches_locked(diagnostics);
2218
2/2
✓ Branch 171 → 172 taken 414 times.
✓ Branch 171 → 175 taken 1 time.
415 }
2219 catch (...)
2220 {
2221 // Allocation precedes erasure, so the poller is left unchanged and no callbacks fire. The stack
2222 // unwind already released the writer lock, so this emission is off the lock.
2223 (void)log().try_log(
2224 LogLevel::Error,
2225 "InputPoller: out of memory in remove_bindings_by_name; bindings unchanged"
2226 );
2227 return 0;
2228 }
2229
2230 414 diagnostics.emit();
2231
2232 // invoke_callbacks == false means the caller owns the wait. The loader-lock abandon path must not block.
2233 // The typed unload drain bounds the wait on its own deadline. The tombstone is already published, so an
2234 // in-flight callback is abandoned rather than waited on. Normal removal drains.
2235
2/2
✓ Branch 174 → 176 taken 409 times.
✓ Branch 174 → 177 taken 5 times.
414 if (invoke_callbacks)
2236 {
2237 409 drain_rundowns(rundowns);
2238 }
2239
2240
2/2
✓ Branch 191 → 179 taken 403 times.
✓ Branch 191 → 192 taken 409 times.
1226 for (auto &[callback, binding_name] : hold_releases)
2241 {
2242 try
2243 {
2244
1/2
✓ Branch 181 → 182 taken 397 times.
✗ Branch 181 → 223 not taken.
403 callback(false);
2245 }
2246 catch (const std::exception &e)
2247 {
2248 (void)log().try_log(
2249 LogLevel::Error,
2250 "InputPoller: Exception in hold release callback \"{}\": {}",
2251 binding_name,
2252 e.what()
2253 );
2254 }
2255 catch (...)
2256 {
2257 (void)log().try_log(
2258 LogLevel::Error,
2259 "InputPoller: Unknown exception in hold release callback \"{}\"",
2260 binding_name
2261 );
2262 }
2263 }
2264
2265 409 return removed;
2266 410 }
2267
2268 4 bool InputPoller::retire_gates_by_name(
2269 std::string_view name,
2270 std::chrono::steady_clock::time_point deadline
2271 ) noexcept
2272 {
2273 4 std::vector<std::shared_ptr<BindingGate>> gates;
2274 try
2275 {
2276 4 std::shared_lock lock(m_bindings_rw_mutex);
2277
1/2
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 41 not taken.
4 const auto it = m_name_index.find(name);
2278
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 4 times.
4 if (it == m_name_index.end())
2279 {
2280 return true;
2281 }
2282
1/2
✓ Branch 10 → 11 taken 4 times.
✗ Branch 10 → 41 not taken.
4 gates.reserve(it->second.size());
2283
2/2
✓ Branch 30 → 14 taken 4 times.
✓ Branch 30 → 31 taken 4 times.
12 for (const size_t idx : it->second)
2284 {
2285
1/2
✓ Branch 18 → 19 taken 4 times.
✗ Branch 18 → 21 not taken.
4 if (m_bindings[idx].gate)
2286 {
2287
1/2
✓ Branch 20 → 21 taken 4 times.
✗ Branch 20 → 40 not taken.
4 gates.push_back(m_bindings[idx].gate);
2288 }
2289 }
2290
1/2
✓ Branch 33 → 34 taken 4 times.
✗ Branch 33 → 36 not taken.
4 }
2291 catch (...)
2292 {
2293 // Handle collection exhausted memory. Failure is the only truthful result because retirement did not
2294 // occur. The drain must not tell its caller that the callbacks are gone.
2295 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory collecting gates for retirement");
2296 return false;
2297 }
2298
2299 4 return retire_collected_gates(gates, deadline);
2300 4 }
2301
2302 110 bool InputPoller::retire_all_gates(std::chrono::steady_clock::time_point deadline) noexcept
2303 {
2304 110 std::vector<std::shared_ptr<BindingGate>> gates;
2305 try
2306 {
2307 110 std::shared_lock lock(m_bindings_rw_mutex);
2308
1/2
✓ Branch 4 → 5 taken 110 times.
✗ Branch 4 → 28 not taken.
110 gates.reserve(m_bindings.size());
2309
2/2
✓ Branch 21 → 7 taken 110 times.
✓ Branch 21 → 22 taken 110 times.
330 for (const auto &binding : m_bindings)
2310 {
2311
1/2
✓ Branch 10 → 11 taken 110 times.
✗ Branch 10 → 12 not taken.
110 if (binding.gate)
2312 {
2313
1/2
✓ Branch 11 → 12 taken 110 times.
✗ Branch 11 → 27 not taken.
110 gates.push_back(binding.gate);
2314 }
2315 }
2316 110 }
2317 catch (...)
2318 {
2319 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory collecting gates for retirement");
2320 return false;
2321 }
2322
2323 110 return retire_collected_gates(gates, deadline);
2324 110 }
2325
2326 114 bool InputPoller::retire_collected_gates(
2327 const std::vector<std::shared_ptr<BindingGate>> &gates,
2328 std::chrono::steady_clock::time_point deadline
2329 ) noexcept
2330 {
2331 // Off the binding lock: retire() waits out an in-flight delivery, and the poll thread takes the same
2332 // lock to dispatch. Exploded combos share one gate. retire() is idempotent on the repeat.
2333 114 bool retired_all = true;
2334
2/2
✓ Branch 19 → 4 taken 114 times.
✓ Branch 19 → 20 taken 114 times.
342 for (const auto &gate : gates)
2335 {
2336 try
2337 {
2338
3/4
✓ Branch 7 → 8 taken 114 times.
✗ Branch 7 → 22 not taken.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 111 times.
114 if (!gate->retire(deadline))
2339 {
2340 3 retired_all = false;
2341 }
2342 }
2343 catch (const std::exception &e)
2344 {
2345 // The callback is destroyed regardless (retire() moved it out first), so retirement itself
2346 // succeeded and only the consumer's edge failed.
2347 (void)log().try_log(
2348 LogLevel::Error,
2349 "InputPoller: Exception in retired hold release callback: {}",
2350 e.what()
2351 );
2352 }
2353 catch (...)
2354 {
2355 (void)log().try_log(
2356 LogLevel::Error,
2357 "InputPoller: Unknown exception in retired hold release callback"
2358 );
2359 }
2360 }
2361 114 return retired_all;
2362 }
2363
2364 114 void InputPoller::clear_bindings(bool invoke_callbacks) noexcept
2365 {
2366 114 std::vector<HoldRelease> hold_releases;
2367 114 std::vector<BindingRundown> rundowns;
2368 114 std::vector<InputBinding> retired;
2369 114 DeferredDiagnostics diagnostics;
2370
2371 try
2372 {
2373
1/2
✓ Branch 2 → 3 taken 114 times.
✗ Branch 2 → 116 not taken.
114 std::unique_lock lock(m_bindings_rw_mutex);
2374 // Skip release-callback capture during Logic-DLL retirement: the callbacks live in a module that
2375 // can start module removal. A gate-backed hold is captured unconditionally. See
2376 // remove_bindings_by_name.
2377
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 30 taken 110 times.
114 if (invoke_callbacks)
2378 {
2379
2/2
✓ Branch 29 → 5 taken 5 times.
✓ Branch 29 → 30 taken 4 times.
9 for (size_t i = 0; i < m_bindings.size(); ++i)
2380 {
2381
2/6
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 22 taken 5 times.
✗ Branch 9 → 10 not taken.
✗ Branch 9 → 22 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 27 taken 5 times.
5 if (m_bindings[i].trigger == input::Trigger::Hold && m_bindings[i].on_state_change &&
2382 (m_bindings[i].release_is_idempotent ||
2383 m_active_states[i].load(std::memory_order_relaxed) != 0))
2384 {
2385 hold_releases.emplace_back(m_bindings[i].on_state_change, m_bindings[i].name);
2386 }
2387 }
2388 }
2389
2390 // Allocate the empty replacement before the clear so an allocation failure leaves the poller
2391 // untouched. Nothing below allocates.
2392
1/2
✓ Branch 30 → 31 taken 114 times.
✗ Branch 30 → 114 not taken.
114 auto new_states = std::make_unique<std::atomic<uint8_t>[]>(0);
2393
2394
1/2
✓ Branch 32 → 33 taken 114 times.
✗ Branch 32 → 112 not taken.
114 rundowns.reserve(m_bindings.size());
2395
2/2
✓ Branch 47 → 35 taken 115 times.
✓ Branch 47 → 48 taken 114 times.
343 for (const auto &binding : m_bindings)
2396 {
2397
1/2
✓ Branch 37 → 38 taken 115 times.
✗ Branch 37 → 111 not taken.
115 add_rundown(rundowns, binding.lifecycle);
2398 }
2399
2/2
✓ Branch 63 → 50 taken 115 times.
✓ Branch 63 → 64 taken 114 times.
343 for (auto &rundown : rundowns)
2400 {
2401 115 rundown.generation = rundown.lifecycle->tombstone();
2402 }
2403
2404 114 retired.swap(m_bindings);
2405 114 m_name_index.clear();
2406 114 m_known_modifiers.clear();
2407 // clear_bindings does not route through recompute_modifier_caches_locked, so advance the generation
2408 // here so live BindingTokens fail closed once the binding set is empty.
2409 114 m_binding_generation = next_binding_generation();
2410 114 m_has_gamepad_bindings.store(false, std::memory_order_relaxed);
2411 114 m_has_wheel_bindings.store(false, std::memory_order_relaxed);
2412 114 m_has_consume_gamepad_bindings.store(false, std::memory_order_relaxed);
2413 114 m_consume_rules.clear();
2414 114 publish_consume_rules_locked(diagnostics);
2415 114 m_active_states = std::move(new_states);
2416 114 }
2417 catch (...)
2418 {
2419 // The stack unwind already released the writer lock, so this emission is off the lock.
2420 (void)log().try_log(
2421 LogLevel::Error,
2422 "InputPoller: out of memory in clear_bindings; bindings unchanged"
2423 );
2424 return;
2425 }
2426
2427 114 diagnostics.emit();
2428
2429 // invoke_callbacks == false abandons in-flight callbacks (see remove_bindings_by_name). A normal clear
2430 // drains.
2431
2/2
✓ Branch 79 → 80 taken 4 times.
✓ Branch 79 → 81 taken 110 times.
114 if (invoke_callbacks)
2432 {
2433 4 drain_rundowns(rundowns);
2434 }
2435
2436
1/2
✗ Branch 95 → 83 not taken.
✓ Branch 95 → 96 taken 114 times.
228 for (auto &[callback, name] : hold_releases)
2437 {
2438 try
2439 {
2440 callback(false);
2441 }
2442 catch (const std::exception &e)
2443 {
2444 (void)log().try_log(
2445 LogLevel::Error,
2446 "InputPoller: Exception in hold release callback \"{}\": {}",
2447 name,
2448 e.what()
2449 );
2450 }
2451 catch (...)
2452 {
2453 (void)log().try_log(
2454 LogLevel::Error,
2455 "InputPoller: Unknown exception in hold release callback \"{}\"",
2456 name
2457 );
2458 }
2459 }
2460
3/6
✓ Branch 98 → 99 taken 114 times.
✗ Branch 98 → 100 not taken.
✓ Branch 102 → 103 taken 114 times.
✗ Branch 102 → 104 not taken.
✓ Branch 106 → 107 taken 114 times.
✗ Branch 106 → 109 not taken.
114 }
2461
2462 272 void InputPoller::release_active_holds() noexcept
2463 {
2464 // Snapshot under the writer lock, then dispatch after its release. The facade can still forward a
2465 // control-plane add_binding onto this poller because it captured a shared_ptr before shutdown() moved it
2466 // out. An unlocked read of these containers races that reshape. The collect-then-fire pattern matches
2467 // remove_bindings_by_name.
2468 272 std::vector<HoldRelease> hold_releases;
2469
2470 272 bool staging_failed = false;
2471 try
2472 {
2473
1/2
✓ Branch 2 → 3 taken 272 times.
✗ Branch 2 → 110 not taken.
272 std::unique_lock lock(m_bindings_rw_mutex);
2474 272 std::size_t release_count = 0;
2475
2/2
✓ Branch 23 → 4 taken 490 times.
✓ Branch 23 → 24 taken 272 times.
762 for (std::size_t i = 0; i < m_bindings.size(); ++i)
2476 {
2477 490 const auto &binding = m_bindings[i];
2478 490 if (m_active_states[i].load(std::memory_order_relaxed) != 0 &&
2479
7/8
✓ Branch 13 → 14 taken 45 times.
✓ Branch 13 → 18 taken 445 times.
✓ Branch 14 → 15 taken 38 times.
✓ Branch 14 → 18 taken 7 times.
✓ Branch 16 → 17 taken 38 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 38 times.
✓ Branch 19 → 21 taken 452 times.
490 binding.trigger == input::Trigger::Hold && binding.on_state_change)
2480 {
2481 38 ++release_count;
2482 }
2483 }
2484 // Allocate before any bit clears, so the staged release commit cannot fail.
2485
2/2
✓ Branch 24 → 25 taken 271 times.
✓ Branch 24 → 108 taken 1 time.
272 hold_releases.reserve(release_count);
2486
2/2
✓ Branch 78 → 26 taken 489 times.
✓ Branch 78 → 79 taken 271 times.
760 for (size_t i = 0; i < m_bindings.size(); ++i)
2487 {
2488
2/2
✓ Branch 34 → 35 taken 445 times.
✓ Branch 34 → 36 taken 44 times.
978 if (m_active_states[i].load(std::memory_order_relaxed) == 0)
2489 {
2490 452 continue;
2491 }
2492 44 const auto &binding = m_bindings[i];
2493
5/6
✓ Branch 37 → 38 taken 37 times.
✓ Branch 37 → 40 taken 7 times.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 37 times.
✓ Branch 42 → 43 taken 7 times.
✓ Branch 42 → 53 taken 37 times.
44 if (binding.trigger != input::Trigger::Hold || !binding.on_state_change)
2494 {
2495 7 m_active_states[i].store(0, std::memory_order_relaxed);
2496 7 continue;
2497 }
2498
2499 37 HoldRelease staged;
2500 37 bool have_callback = false;
2501 try
2502 {
2503
1/2
✓ Branch 54 → 55 taken 37 times.
✗ Branch 54 → 102 not taken.
37 staged.callback = binding.on_state_change;
2504 37 have_callback = true;
2505 // Copy the callback first. A name-copy failure costs only its label. A callback-copy failure
2506 // costs the consumer its compensatory edge.
2507
2/2
✓ Branch 55 → 56 taken 35 times.
✓ Branch 55 → 102 taken 2 times.
37 staged.name = binding.name;
2508 }
2509 2 catch (...)
2510 {
2511 2 staging_failed = true;
2512
1/2
✓ Branch 104 → 56 taken 2 times.
✗ Branch 104 → 105 not taken.
2 }
2513
1/2
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 58 taken 37 times.
37 if (!have_callback)
2514 {
2515 // Nothing to deliver, so leave the bit set rather than advertise a release that never happened.
2516 continue;
2517 }
2518
1/2
✓ Branch 60 → 61 taken 37 times.
✗ Branch 60 → 105 not taken.
37 hold_releases.push_back(std::move(staged));
2519 // Cleared only now: the bit is the sole record that this binding is held, and a release is
2520 // guaranteed staged from here.
2521 37 m_active_states[i].store(0, std::memory_order_relaxed);
2522
1/2
✓ Branch 72 → 73 taken 37 times.
✗ Branch 72 → 75 not taken.
37 }
2523 272 }
2524 1 catch (...)
2525 {
2526 1 staging_failed = true;
2527 1 }
2528
2529
2/2
✓ Branch 81 → 82 taken 3 times.
✓ Branch 81 → 85 taken 269 times.
272 if (staging_failed)
2530 {
2531 // Report and continue. Every staged release still runs below.
2532 3 (void)log().try_log(LogLevel::Error, "InputPoller: out of memory staging hold-release callbacks");
2533 }
2534
2535
2/2
✓ Branch 99 → 87 taken 37 times.
✓ Branch 99 → 100 taken 272 times.
581 for (auto &[callback, name] : hold_releases)
2536 {
2537 try
2538 {
2539
2/2
✓ Branch 89 → 90 taken 11 times.
✓ Branch 89 → 114 taken 26 times.
37 callback(false);
2540 }
2541
1/2
✓ Branch 114 → 115 taken 26 times.
✗ Branch 114 → 120 not taken.
26 catch (const std::exception &e)
2542 {
2543 26 (void)log().try_log(
2544 LogLevel::Error,
2545 "InputPoller: Exception in hold release callback \"{}\": {}",
2546 26 name,
2547 26 e.what()
2548 );
2549 26 }
2550 catch (...)
2551 {
2552 (void)log().try_log(
2553 LogLevel::Error,
2554 "InputPoller: Unknown exception in hold release callback \"{}\"",
2555 name
2556 );
2557 }
2558 }
2559 272 }
2560
2561 109 bool InputPoller::is_process_foreground() const noexcept
2562 {
2563 109 HWND foreground = GetForegroundWindow();
2564
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 109 times.
109 if (!foreground)
2565 {
2566 return false;
2567 }
2568 109 DWORD foreground_pid = 0;
2569 109 GetWindowThreadProcessId(foreground, &foreground_pid);
2570 109 return foreground_pid == GetCurrentProcessId();
2571 }
2572 } // namespace detail
2573 } // namespace DetourModKit
2574