fix(evdev): keep key suppression when /dev/uinput is write-only (#893)

* fix(evdev): keep key suppression when /dev/uinput is write-only

python-evdev opens /dev/uinput O_RDWR, but the kernel only needs write
access for the uinput setup ioctls and event injection. Installs that
still carry the original 80-dotool.rules line (MODE=0620, write-only
for the input group) therefore fail UInput() with EACCES and fall back
to ungrabbed monitoring, where the dictation shortcut is detected but
also reaches the focused application. Retry the clone on an O_WRONLY
descriptor via a small _WriteOnlyUInput subclass so suppression keeps
working there (refs #871).

Also populate _withheld_modifier lazily in _event_is_shortcut: the
AltGr-replay machinery was dead code because nothing created the
per-fd map, so a withheld modifier press was never replayed and
composition (e.g. RightAlt+e) lost its modifier.

* fix(evdev): byte-truncate clone names and cover the write-only path

- _clone_device_name now truncates the encoded name by bytes: the
  kernel limit is 79 bytes, and a long multibyte device name could push
  the " (vocalinux)" suffix past the cut so rescan would not skip the
  clone.
- _WriteOnlyUInput closes the fd explicitly when _prepare_events fails
  before the _uinput calls; the helpers close it themselves on any
  later failure.
- Tests now instantiate the real subclass with mocked OS/native calls
  (open flags, setup sequence, write/write_event/syn ordering, fd
  cleanup, name truncation).

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-01 23:45:49 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 4497bfae6c
commit 96e012d9bd
2 changed files with 307 additions and 13 deletions
+186 -4
View File
@@ -11,11 +11,12 @@ This module tests:
- _monitor_devices() thread
"""
import contextlib
import errno
import os
import threading
import time
from unittest.mock import MagicMock, Mock, mock_open, patch
from unittest.mock import MagicMock, Mock, call, mock_open, patch
import pytest
@@ -25,6 +26,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
MODIFIER_KEY_CODES,
EvdevDeviceHub,
EvdevKeyboardBackend,
_clone_device_name,
device_has_key,
device_has_modifier_key,
ecodes,
@@ -1150,20 +1152,76 @@ class TestEvdevGrabAndForwarding:
device.grab.assert_not_called()
assert backend.devices == []
@patch("vocalinux.ui.keyboard_backends.evdev_backend._WriteOnlyUInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_falls_back_when_uinput_unavailable(self, mock_input_device, mock_uinput):
def test_open_falls_back_when_uinput_unavailable(
self, mock_input_device, mock_uinput, mock_write_only
):
"""Without /dev/uinput the device still works ungrabbed (keys leak)."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
mock_input_device.return_value = device
mock_uinput.side_effect = OSError("uinput denied")
mock_write_only.side_effect = OSError(errno.EACCES, "uinput denied")
backend = EvdevKeyboardBackend()
opened = backend._open_keyboard_device("/dev/input/event0")
assert opened is True
mock_write_only.assert_called_once()
device.grab.assert_not_called()
assert backend._forwarders == {}
assert device in backend.devices
@patch("vocalinux.ui.keyboard_backends.evdev_backend._WriteOnlyUInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_retries_uinput_write_only_on_eacces(
self, mock_input_device, mock_uinput, mock_write_only
):
"""An O_RDWR refusal retries O_WRONLY so the device still gets grabbed.
Installs carrying the original write-only udev rule (MODE=0620)
denied evdev's O_RDWR open of /dev/uinput; a write-only descriptor
satisfies the kernel and keeps key suppression working.
"""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
mock_input_device.return_value = device
mock_uinput.side_effect = OSError("could not open uinput device in write mode")
forwarder = MagicMock()
forwarder.device.path = "/dev/input/event20"
mock_write_only.return_value = forwarder
backend = EvdevKeyboardBackend()
opened = backend._open_keyboard_device("/dev/input/event0")
assert opened is True
mock_write_only.assert_called_once()
device.grab.assert_called_once()
assert backend._forwarders[10] is forwarder
@patch("vocalinux.ui.keyboard_backends.evdev_backend._WriteOnlyUInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_open_does_not_retry_write_only_on_other_oserror(
self, mock_input_device, mock_uinput, mock_write_only
):
"""A UInput failure with a real errno is not a permission retry."""
device = MagicMock()
device.fileno.return_value = 10
device.info.bustype = 0x03
mock_input_device.return_value = device
mock_uinput.side_effect = OSError(errno.EIO, "i/o error")
backend = EvdevKeyboardBackend()
opened = backend._open_keyboard_device("/dev/input/event0")
assert opened is True
mock_write_only.assert_not_called()
device.grab.assert_not_called()
assert backend._forwarders == {}
assert device in backend.devices
@@ -1607,12 +1665,18 @@ class TestEvdevGrabAndForwarding:
assert backend._forwarders == {}
def test_monitor_forwards_survivors_during_syn_dropped(self):
"""Mid-drop: shortcut keys are eaten, surviving other keys forwarded."""
"""Mid-drop: a withheld modifier press replays ahead of other keys.
RIGHTALT surviving with KEY_A reads as AltGr composition, not a
gesture, so the withheld press is replayed before the letter —
the app sees exactly what the user typed.
"""
backend = EvdevKeyboardBackend()
device = MagicMock()
device.fileno.return_value = 10
forwarder = MagicMock()
backend._forwarders = {10: forwarder}
backend._forwarded_held = {10: set()}
backend._dropped_devices = {10}
shortcut_event = self._key_event(100, 1) # KEY_RIGHTALT press
other_event = self._key_event(30, 1) # KEY_A
@@ -1643,7 +1707,33 @@ class TestEvdevGrabAndForwarding:
backend._monitor_devices()
backend._handle_key_event.assert_not_called()
forwarder.write_event.assert_called_once_with(other_event)
assert forwarder.write_event.call_args_list == [
call(shortcut_event),
call(other_event),
]
def test_pure_modifier_press_withheld_lazily(self) -> None:
"""Production path: the withheld map is created on first modifier event.
The AltGr-replay machinery only works if the per-fd map exists; it
used to be populated by nothing in production, so withheld presses
were silently dropped and composition lost its modifier.
"""
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
fd = 10
forwarder = MagicMock()
backend._forwarders[fd] = forwarder
backend._forwarded_held[fd] = set()
press = self._key_event(100, 1)
key_e = self._key_event(18, 1)
assert backend._event_is_shortcut(fd, press) is True
assert 100 in backend._withheld_modifier[fd]
assert backend._event_is_shortcut(fd, key_e) is False
forwarder.write_event.assert_called_once_with(press)
assert 100 in backend._forwarded_held[fd]
def test_pure_modifier_press_stays_consumed(self) -> None:
"""A real PTT hold never reaches the application."""
@@ -1747,6 +1837,98 @@ class TestEvdevGrabAndForwarding:
assert backend._withheld_modifier[fd] == {}
class TestWriteOnlyUInput:
"""The write-only clone drives the real _uinput setup on an O_WRONLY fd."""
@contextlib.contextmanager
def _built_uinput(self, fd: int = 42):
"""Instantiate the real _WriteOnlyUInput with os/evdev calls mocked."""
from vocalinux.ui.keyboard_backends import evdev_backend as eb
device = MagicMock()
with (
patch.object(eb._WriteOnlyUInput, "_verify"),
patch.object(eb.os, "open", return_value=fd) as mock_open,
patch.object(eb._uinput, "set_phys") as set_phys,
patch.object(eb._uinput, "enable") as enable,
patch.object(eb._uinput, "setup") as setup,
patch.object(eb._uinput, "create") as create,
patch.object(eb._WriteOnlyUInput, "_find_device", return_value=device),
patch.object(eb._uinput, "write") as write,
):
uinput = eb._WriteOnlyUInput(events={1: [30]}, name="kbd (vocalinux)", bustype=0x06)
yield uinput, mock_open, set_phys, enable, setup, create, write, device
def test_opens_wronly_and_runs_setup(self) -> None:
"""Constructor opens O_WRONLY and performs the full uinput setup."""
with self._built_uinput(fd=42) as (
uinput,
mock_open,
set_phys,
enable,
setup,
create,
_,
device,
):
mock_open.assert_called_once_with("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
set_phys.assert_called_once_with(42, "py-evdev-uinput")
enable.assert_called_once_with(42, 1, 30)
assert setup.call_args[0][0] == 42
create.assert_called_once_with(42)
assert uinput.fd == 42
assert uinput.device is device
def test_emits_through_uinput_write(self) -> None:
"""write/write_event/syn bypass need_write's O_RDWR check."""
import evdev as real_evdev
with self._built_uinput(fd=42) as (uinput, _, _, _, _, _, write, _):
uinput.write(1, 30, 1)
uinput.write_event(real_evdev.InputEvent(0, 0, 1, 30, 0))
uinput.syn()
assert write.call_args_list == [
call(42, 1, 30, 1),
call(42, 1, 30, 0),
call(42, 0, 0, 0), # EV_SYN / SYN_REPORT
]
def test_failed_event_prep_closes_descriptor(self) -> None:
"""A failure before the _uinput calls still releases our fd."""
from vocalinux.ui.keyboard_backends import evdev_backend as eb
with (
patch.object(eb._WriteOnlyUInput, "_verify"),
patch.object(eb.os, "open", return_value=42),
patch.object(eb.os, "close") as mock_close,
patch.object(
eb._WriteOnlyUInput, "_prepare_events", side_effect=TypeError("bad events")
),
):
with pytest.raises(TypeError):
eb._WriteOnlyUInput(events=None, name="kbd (vocalinux)", bustype=0x06)
mock_close.assert_called_once_with(42)
def test_clone_device_name_caps_encoded_bytes(self) -> None:
"""A long multibyte device name truncates by bytes, keeping the suffix."""
device = MagicMock()
device.name = "\u2328" * 40 # 3 bytes each, 120 bytes total
name = _clone_device_name(device)
assert name.endswith(" (vocalinux)")
assert len(name.encode("utf-8")) <= 79
def test_clone_device_name_keeps_short_names(self) -> None:
"""Ordinary names keep the full base plus suffix."""
device = MagicMock()
device.name = "AT Translated Set 2 keyboard"
assert _clone_device_name(device) == "AT Translated Set 2 keyboard (vocalinux)"
class TestSharedEvdevDeviceLayer:
"""Several backends share one evdev reader instead of competing grabs.