Skip to content

Commit ca89e3a

Browse files
authored
Uinput: Use pipe instead of select() with timeout (#1760)
1 parent abaf5f8 commit ca89e3a

File tree

2 files changed

+82
-33
lines changed

2 files changed

+82
-33
lines changed

news.d/feature/1760.linux.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactor Uinput to use pipe instead of select with timeout

plover/oslayer/linux/keyboardcontrol_uinput.py

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from evdev import UInput, ecodes as e, util, InputDevice, list_devices
22
import threading
3-
from select import select
3+
import os
4+
import selectors
5+
46
from psutil import process_iter
57

68
from plover.output.keyboard import GenericKeyboardEmulation
@@ -412,21 +414,31 @@ def send_key_combination(self, combo):
412414

413415

414416
class KeyboardCapture(Capture):
417+
_selector: selectors.DefaultSelector
418+
_device_thread: threading.Thread | None
419+
# Pipes to signal `_run` thread to stop
420+
_device_thread_read_pipe: int | None
421+
_device_thread_write_pipe: int | None
422+
415423
def __init__(self):
416424
super().__init__()
417-
# This is based on the example from the python-evdev documentation, using the first of the three alternative methods: https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
418425
self._devices = self._get_devices()
419426
self._running = False
420-
self._thread = None
427+
428+
self._selector = selectors.DefaultSelector()
429+
self._device_thread = None
430+
self._device_thread_read_pipe = None
431+
self._device_thread_write_pipe = None
432+
421433
self._res = util.find_ecodes_by_regex(r"KEY_.*")
422434
self._ui = UInput(self._res)
423-
self._suppressed_keys = []
435+
self._suppressed_keys = set()
424436
# The keycodes from evdev, e.g. e.KEY_A refers to the *physical* a, which corresponds with the qwerty layout.
425437

426438
def _get_devices(self):
427439
input_devices = [InputDevice(path) for path in list_devices()]
428440
keyboard_devices = [dev for dev in input_devices if self._filter_devices(dev)]
429-
return {dev.fd: dev for dev in keyboard_devices}
441+
return keyboard_devices
430442

431443
def _filter_devices(self, device):
432444
"""
@@ -451,7 +463,7 @@ def _grab_devices(self):
451463
There is likely a race condition here between checking active keys and
452464
actually grabbing the device, but it appears to work fine.
453465
"""
454-
for device in self._devices.values():
466+
for device in self._devices:
455467
if len(device.active_keys()) > 0:
456468
for _ in device.read_loop():
457469
if len(device.active_keys()) == 0:
@@ -461,56 +473,92 @@ def _grab_devices(self):
461473

462474
def _ungrab_devices(self):
463475
"""Ungrab all devices. Handles all exceptions when ungrabbing."""
464-
for device in self._devices.values():
476+
for device in self._devices:
465477
try:
466478
device.ungrab()
467479
except:
468480
log.debug("failed to ungrab device", exc_info=True)
469481

470482
def start(self):
483+
# Exception handling note: cancel() will eventually be called when the
484+
# machine reconnect button is pressed or when the machine is changed.
485+
# Therefore, cancel() does not need to be called in the except block.
471486
try:
472487
self._grab_devices()
473-
except Exception as e:
488+
self._device_thread_read_pipe, self._device_thread_write_pipe = os.pipe()
489+
self._selector.register(self._device_thread_read_pipe, selectors.EVENT_READ)
490+
for device in self._devices:
491+
self._selector.register(device, selectors.EVENT_READ)
492+
493+
self._device_thread = threading.Thread(target=self._run)
494+
self._device_thread.start()
495+
496+
self._running = True
497+
except Exception:
474498
self._ungrab_devices()
475499
raise
476-
self._running = True
477-
self._thread = threading.Thread(target=self._run)
478-
self._thread.start()
479500

480501
def cancel(self):
502+
# Write some arbitrary data to the pipe to signal the _run thread to stop
503+
if self._device_thread_write_pipe is not None:
504+
try:
505+
os.write(self._device_thread_write_pipe, b"a")
506+
except Exception:
507+
log.warning("failed to write to device thread pipe", exc_info=True)
508+
if self._device_thread is not None:
509+
try:
510+
self._device_thread.join()
511+
except Exception:
512+
log.warning("failed to join device thread", exc_info=True)
513+
self._device_thread = None
514+
try:
515+
self._ungrab_devices()
516+
except Exception:
517+
log.warning("failed to ungrab devices", exc_info=True)
518+
try:
519+
self._selector.close()
520+
except Exception:
521+
log.warning("failed to close selector", exec_info=True)
522+
523+
if self._device_thread_read_pipe is not None:
524+
try:
525+
os.close(self._device_thread_read_pipe)
526+
except Exception:
527+
log.warning("failed to close device thread read pipe", exc_info=True)
528+
self._device_thread_read_pipe = None
529+
if self._device_thread_write_pipe is not None:
530+
try:
531+
os.close(self._device_thread_write_pipe)
532+
except Exception:
533+
log.warning("failed to close device thread write pipe", exc_info=True)
534+
self._device_thread_write_pipe = None
535+
481536
self._running = False
482-
if self._thread is not None:
483-
self._thread.join()
484537

485538
def suppress(self, suppressed_keys=()):
486539
"""
487540
UInput is not capable of suppressing only specific keys. To get around this, non-suppressed keys
488541
are passed through to a UInput device and emulated, while keys in this list get sent to plover.
489542
It does add a little bit of delay, but that is not noticeable.
490543
"""
491-
self._suppressed_keys = suppressed_keys
544+
self._suppressed_keys = set(suppressed_keys)
492545

493546
def _run(self):
494547
try:
495-
while self._running:
496-
"""
497-
The select() call blocks the loop until it gets an input, which meant that the keyboard
498-
had to be pressed once after executing `cancel()`. Now, there is a 1 second delay instead
499-
FIXME: maybe use one of the other options to avoid the timeout
500-
https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
501-
"""
502-
r, _, _ = select(self._devices, [], [], 1)
503-
for fd in r:
504-
for event in self._devices[fd].read():
505-
if event.type == e.EV_KEY:
506-
if event.code in KEYCODE_TO_KEY:
507-
key_name = KEYCODE_TO_KEY[event.code]
508-
if key_name in self._suppressed_keys:
509-
pressed = event.value == 1
510-
(self.key_down if pressed else self.key_up)(
511-
key_name
512-
)
513-
continue # Go to the next iteration, skipping the below code:
548+
while True:
549+
for key, events in self._selector.select():
550+
if key.fd == self._device_thread_read_pipe:
551+
# Stop this thread
552+
return
553+
assert isinstance(key.fileobj, InputDevice)
554+
device: InputDevice = key.fileobj
555+
for event in device.read():
556+
if event.code in KEYCODE_TO_KEY:
557+
key_name = KEYCODE_TO_KEY[event.code]
558+
if key_name in self._suppressed_keys:
559+
pressed = event.value == 1
560+
(self.key_down if pressed else self.key_up)(key_name)
561+
continue # Go to the next iteration, skipping the below code:
514562
self._ui.write(e.EV_KEY, event.code, event.value)
515563
self._ui.syn()
516564
except:

0 commit comments

Comments
 (0)