From 5bab58e96b9c8aee6876d39ef5d633e227360043 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:19:02 -0400 Subject: [PATCH] fix(linux): prevent stuck keys from uinput repeat races --- src/include/libvirtualhid/types.hpp | 5 +- src/platform/linux/uhid_backend.cpp | 62 ++----------------- .../fixtures/linux_backend_test_hooks.hpp | 13 ++-- tests/fixtures/linux_backend_test_hooks.cpp | 34 +++++++--- tests/unit/test_linux_backend.cpp | 21 ++++++- tests/unit/test_linux_consumers.cpp | 44 +++++++++++++ 6 files changed, 107 insertions(+), 72 deletions(-) diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 097d62c..a471d51 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -455,7 +455,10 @@ namespace lvh { DeviceProfile profile; /** - * @brief Held-key repeat interval in milliseconds, or `0` to disable repeat. + * @brief Requested held-key repeat interval in milliseconds, or `0` to disable backend-generated repeat. + * + * The uinput backend ignores this value, including zero, and leaves key + * repeat to the compositor or X server. */ std::uint32_t auto_repeat_interval_ms = 50; diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index ef20d84..bf58868 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -24,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -1596,6 +1595,9 @@ namespace lvh::detail { /** * @brief Backend keyboard backed by one Linux uinput file descriptor. + * + * Emits only caller-submitted key transitions. The compositor or X server + * handles held-key repeat; replaying presses here can re-press released keys. */ class UinputKeyboard final: public BackendKeyboard, private UinputDevice { public: @@ -1608,11 +1610,7 @@ namespace lvh::detail { OperationStatus create(DeviceId id, const CreateKeyboardOptions &options) { device_name_ = options.profile.name; - auto status = create_uinput_device(options.profile, id); - if (status.ok() && options.auto_repeat_interval_ms > 0) { - start_repeat_thread(options.auto_repeat_interval_ms); - } - return status; + return create_uinput_device(options.profile, id); } OperationStatus submit(const KeyboardEvent &event) override { @@ -1620,11 +1618,7 @@ namespace lvh::detail { return OperationStatus::failure(ErrorCode::device_closed, "uinput keyboard is closed"); } - auto status = emit_keyboard_event(event); - if (status.ok()) { - update_pressed_keys(event); - } - return status; + return emit_keyboard_event(event); } OperationStatus type_text(const KeyboardTextEvent &event) override { @@ -1634,7 +1628,6 @@ namespace lvh::detail { } OperationStatus close() override { - stop_repeat_thread(); return close_uinput("uinput keyboard"); } @@ -1655,52 +1648,7 @@ namespace lvh::detail { return sync(); } - void update_pressed_keys(const KeyboardEvent &event) { - std::lock_guard lock {pressed_keys_mutex_}; - if (event.pressed) { - pressed_keys_.insert(event.key_code); - } else { - pressed_keys_.erase(event.key_code); - } - } - - std::vector pressed_keys_snapshot() const { - std::lock_guard lock {pressed_keys_mutex_}; - return {pressed_keys_.begin(), pressed_keys_.end()}; - } - - void start_repeat_thread(std::uint32_t interval_ms) { - repeat_running_ = true; - repeat_thread_ = std::jthread {[this, interval_ms](std::stop_token stop_token) { - while (!stop_token.stop_requested() && repeat_running_) { - std::this_thread::sleep_for(std::chrono::milliseconds {interval_ms}); - if (stop_token.stop_requested() || !repeat_running_ || !is_open()) { - break; - } - - for (const auto key_code : pressed_keys_snapshot()) { - if (stop_token.stop_requested() || !repeat_running_ || !is_open()) { - break; - } - static_cast(emit_keyboard_event({.key_code = key_code, .pressed = true})); - } - } - }}; - } - - void stop_repeat_thread() { - repeat_running_ = false; - if (repeat_thread_.joinable()) { - repeat_thread_.request_stop(); - repeat_thread_.join(); - } - } - std::string device_name_; - std::atomic_bool repeat_running_ = false; - std::jthread repeat_thread_; - mutable std::mutex pressed_keys_mutex_; - std::set pressed_keys_; }; /** diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 0f9d21a..8060c56 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -18,7 +18,7 @@ namespace lvh::detail::test { /** - * @brief Linux input event captured by a pipe-backed backend test. + * @brief Linux input event captured by a uinput backend test. */ struct LinuxInputEventRecord { /** @@ -38,7 +38,7 @@ namespace lvh::detail::test { }; /** - * @brief Result from a pipe-backed uinput submission. + * @brief Result from a captured uinput submission. */ struct LinuxInputSubmissionResult { /** @@ -47,7 +47,7 @@ namespace lvh::detail::test { OperationStatus status; /** - * @brief Events written to the pipe. + * @brief Events emitted by the backend. */ std::vector events; }; @@ -1410,11 +1410,12 @@ namespace lvh::detail::test { OperationStatus linux_uinput_keyboard_close_fake_close_failure(); /** - * @brief Create a fake uinput keyboard with auto repeat enabled. + * @brief Capture uinput events while holding and releasing a key across repeat intervals. * - * @return Close status after the repeat thread runs. + * @param interval_ms Requested keyboard repeat interval in milliseconds. + * @return Operation status and events emitted before closing the keyboard. */ - OperationStatus linux_uinput_keyboard_auto_repeat_fake_success(); + LinuxInputSubmissionResult linux_uinput_keyboard_held_key_events(std::uint32_t interval_ms); /** * @brief Submit a mouse event while fake write fails. diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index dc3a013..4f10d59 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -126,6 +125,8 @@ namespace lvh::detail::test { int fail_write_call = -1; int short_write_call = -1; std::size_t short_write_size = 1; + bool capture_input_events = false; + std::vector written_input_events; bool override_ioctl = false; std::atomic_int ioctl_call_count = 0; int fail_ioctl_call = -1; @@ -235,6 +236,15 @@ std::ptrdiff_t lvh_linux_test_write(int fd, const std::byte *buffer, std::size_t if (lvh::detail::test::active_test_syscalls()->short_write_call == call_count) { return static_cast(lvh::detail::test::active_test_syscalls()->short_write_size); } + if (lvh::detail::test::active_test_syscalls()->capture_input_events && size == sizeof(input_event)) { + input_event event {}; + std::memcpy(&event, buffer, sizeof(event)); + lvh::detail::test::active_test_syscalls()->written_input_events.push_back({ + .type = event.type, + .code = event.code, + .value = event.value, + }); + } return static_cast(size); } return static_cast(::write(fd, buffer, size)); @@ -2831,28 +2841,38 @@ namespace lvh::detail::test { return keyboard.close(); } - OperationStatus linux_uinput_keyboard_auto_repeat_fake_success() { + LinuxInputSubmissionResult linux_uinput_keyboard_held_key_events(std::uint32_t interval_ms) { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.override_ioctl = true; + syscalls.override_libevdev = true; + syscalls.capture_input_events = true; ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; CreateKeyboardOptions options; options.profile = profiles::keyboard(); - options.auto_repeat_interval_ms = 1; + options.auto_repeat_interval_ms = interval_ms; const auto fd = open_test_fd(); if (fd < 0) { - return system_error_status(ErrorCode::backend_failure, "failed to open test file descriptor", errno); + return {system_error_status(ErrorCode::backend_failure, "failed to open test file descriptor", errno), {}}; } UinputKeyboard keyboard {fd}; auto status = keyboard.create(1, options); if (status.ok()) { - status = keyboard.submit({.key_code = 0x41, .pressed = true}); + status = keyboard.submit({.key_code = 0x46, .pressed = true}); } - std::this_thread::sleep_for(std::chrono::milliseconds {10}); - return keyboard.close(); + const auto observation_time = std::chrono::milliseconds {std::max(interval_ms * 2, 20U)}; + std::this_thread::sleep_for(observation_time); + if (status.ok()) { + status = keyboard.submit({.key_code = 0x46, .pressed = false}); + } + std::this_thread::sleep_for(observation_time); + if (const auto close_status = keyboard.close(); status.ok()) { + status = close_status; + } + return {std::move(status), std::move(syscalls.written_input_events)}; } OperationStatus linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index beac95d..93f0d86 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -394,6 +394,26 @@ TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsComposeKey) { } } +TEST_F(LinuxBackendTest, UinputKeyboardEmitsOnlySubmittedKeyTransitions) { + const auto default_interval = lvh::CreateKeyboardOptions {}.auto_repeat_interval_ms; + for (const auto interval_ms : {0U, 1U, default_interval}) { + SCOPED_TRACE(interval_ms); + const auto result = lvh::detail::test::linux_uinput_keyboard_held_key_events(interval_ms); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 4U); + for (std::size_t index = 0; index < 2; ++index) { + const auto &key = result.events[index * 2]; + EXPECT_EQ(key.type, EV_KEY); + EXPECT_EQ(key.code, KEY_F); + EXPECT_EQ(key.value, index == 0 ? 1 : 0); + const auto &sync = result.events[index * 2 + 1]; + EXPECT_EQ(sync.type, EV_SYN); + EXPECT_EQ(sync.code, SYN_REPORT); + EXPECT_EQ(sync.value, 0); + } + } +} + TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { using enum lvh::GamepadButton; using enum lvh::GamepadProfileKind; @@ -1464,7 +1484,6 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc lvh::ErrorCode::backend_failure ); } - EXPECT_TRUE(lvh::detail::test::linux_uinput_keyboard_auto_repeat_fake_success().ok()); EXPECT_EQ( lvh::detail::test::linux_uinput_keyboard_close_fake_close_failure().code(), lvh::ErrorCode::backend_failure diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 2dd083d..d11ba7d 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -1285,6 +1286,49 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) { EXPECT_EQ(libinput_event_keyboard_get_key_state(keyboard_event), LIBINPUT_KEY_STATE_RELEASED); } +TEST_F(LinuxConsumerTest, UinputKeyboardKeepsReleasedKeyUpUnderRapidTransitions) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + lvh::CreateKeyboardOptions options; + options.profile = lvh::profiles::keyboard(); + options.profile.name = unique_device_name("Keyboard Release Stress"); + options.auto_repeat_interval_ms = 1; + auto created = runtime->create_keyboard(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "Keyboard event node was not readable"; + const auto descriptor = ::open(node->c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + ASSERT_GE(descriptor, 0) << errno_message(errno); + ScopeExit close_keyboard {[descriptor, &created]() { + static_cast(created.keyboard->close()); + static_cast(::close(descriptor)); + }}; + ASSERT_EQ(::ioctl(descriptor, EVIOCGRAB, 1), 0) << errno_message(errno); + + std::array keys {}; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {2}; + std::size_t cycles = 0; + do { + ASSERT_TRUE(created.keyboard->press(0x46).ok()); + ASSERT_GE(::ioctl(descriptor, EVIOCGKEY(keys.size()), keys.data()), 0) << errno_message(errno); + ASSERT_NE(keys[KEY_F / 8] & (1U << (KEY_F % 8)), 0U); + ASSERT_TRUE(created.keyboard->release(0x46).ok()); + ASSERT_GE(::ioctl(descriptor, EVIOCGKEY(keys.size()), keys.data()), 0) << errno_message(errno); + ASSERT_EQ(keys[KEY_F / 8] & (1U << (KEY_F % 8)), 0U) + << "KEY_F remained pressed after release on cycle " << cycles; + ++cycles; + } while (std::chrono::steady_clock::now() < deadline); + + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + ASSERT_GE(::ioctl(descriptor, EVIOCGKEY(keys.size()), keys.data()), 0) << errno_message(errno); + EXPECT_EQ(keys[KEY_F / 8] & (1U << (KEY_F % 8)), 0U); + EXPECT_TRUE(created.keyboard->close().ok()); +} + TEST_F(LinuxConsumerTest, LibinputSeesSplitUinputMouseMotionAndButtons) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput"));