gvalkov/python-evdev

Incorrect device node found when creating more than one UInput device

KarsMulder opened this issue · 5 comments

Sample code:

import evdev
import evdev.ecodes as e

x = evdev.UInput({e.EV_KEY: [e.KEY_A]})
y = evdev.UInput({e.EV_REL: [e.REL_X]})

print("Device node of the first device:  ", x.device)
print("Device node of the second device: ", y.device)
print("Capabilities of the first device:  ", x.capabilities())
print("Capabilities of the second device: ", y.capabilities())

Output on my system:

Device node of the first device:   device /dev/input/event20, name "py-evdev-uinput", phys "py-evdev-uinput"
Device node of the second device:  device /dev/input/event20, name "py-evdev-uinput", phys "py-evdev-uinput"
Capabilities of the first device:   {0: [0, 1, 21], 1: [30]}
Capabilities of the second device:  {0: [0, 1, 21], 1: [30]}

The problem:
Even though I have created two different UInput devices, but when I try to query their device node using UInput.device, both of them return the same device node. This has consequences beyond just the device node: as you can see, both of them report having the same capabilities, even though they were clearly created with different capabilities.

Evtest confirms that python-evdev did properly create two virtual devices, and it is possible to write events to these devices. The only problem seems to be that python-evdev reports incorrect information back to the programmer.

The cause:
I've looked through the source code, and it seems that the device node is discovered through the following code in uinput.py:

def _find_device(self):
    #:bug: the device node might not be immediately available
    time.sleep(0.1)

    for path in util.list_devices('/dev/input/'):
        d = device.InputDevice(path)
        if d.name == self.name:
            return d

As you can see for yourself, this code looks quite fragile. It does not inherently look up the device node of the created input device, but instead searches for the first input device that has the same name. Since both created input devices have the name "py-evdev-uinput", this code may return either device.

I suppose that this code will probably return the correct node if list_devices returns the device nodes from newest device to oldest device. The function list_devices is implemented using glob.glob, and I do not really see any guarantee that glob returns files in that order, so I suppose that whether your system suffers from this bug depends on the order in which your operating system happens to list the device nodes.

Further information:
My operating system is Arch Linux. Python-evdev has been installed on my system by the OS package manager.

$ python -V
Python 3.11.6
$ pacman -Q python-evdev
python-evdev 1.6.1-2
$ uname -r
6.6.8-zen1-1-zen

On ubuntu 23.04 and the latest python-evdev:

$ python -V         
Python 3.11.6
$ uname -r
6.5.7-060507-generic
Device node of the first device:   device /dev/input/event24, name "py-evdev-uinput", phys "py-evdev-uinput"
Device node of the second device:  device /dev/input/event25, name "py-evdev-uinput", phys "py-evdev-uinput"
Capabilities of the first device:   {0: [0, 1, 21], 1: [30]}
Capabilities of the second device:  {0: [0, 2, 21], 2: [0]}

I have done some experiments, and the modification date of the devnodes is all over the place.

After the first uinput has been created:

/dev/input/event24 1704372615.3808594
/dev/input/event23 1704368766.9839942
...

After the second uinput has been created:

/dev/input/event24 1704372615.5728598
/dev/input/event25 1704372615.4808595
/dev/input/event23 1704368766.9839942
...

However, the name of the devnode seems to be in order. Sorting by name in reverse might just do the trick:

    def _find_device(self):
        #:bug: the device node might not be immediately available
        time.sleep(0.1)

        # ensure the newest device is being used if multiple devices have the same name.
        # This is still subject to race conditions, if another process creates another
        # device in the meantime.
        devices = sorted(
            util.list_devices('/dev/input/'),
            reverse=True
        )

        for path in devices:
            d = device.InputDevice(path)
            if d.name == self.name:
                return d

Thanks for the quick response!

I suppose that that will work well enough in most cases (it at least works well enough for my use case.) I am personally not a fan of a solution with race conditions like this because it could mysteriously break if an user has written two different python-evdev script and configured systemd to start both of them at boot; in that case causing a race condition within a window as large as 0.1s seems to be pretty much guaranteed.

I would want to say "just use libevdev_uinput_get_devnode to do it properly", but it seems that python-evdev does not actually rely on libevdev and instead implements the ioctls itself. The function libevdev_uinput_get_devnode seems to do the following:

On Linux:
Libevdev uses the UI_GET_SYSNAME ioctl to get the system name of the device, e.g. input23.
It then looks at the filenames in the directory /sys/devices/virtual/input/input23 and expects it to contain one directory matching event*, e.g. /sys/devices/virtual/input/input23/event20.
Then it assumes that /dev/input/event20 is the corresponding device node.

On FreeBSD:
Libevdev uses the UI_GET_SYSNAME ioctl to get the system name of the device, e.g. event20, and then assumes that /dev/input/event20 is the corresponding device node.

...

The above relies on UI_GET_SYSNAME being available, which seems to be added to the Linux kernel in 2014. Libevdev contains some more code relying on ctime to work with old kernels. As the libevdev documentation itself says:

On libevdev_uinput_get_devnode:

This function may return NULL. libevdev may have to guess the syspath and the device node. See libevdev_uinput_get_syspath() for details.

On FreeBSD, this function can not return NULL. libudev uses the UI_GET_SYSNAME ioctl to get the device node on this platform and if that fails, the call to libevdev_uinput_create_from_device() fails.

On libevdev_uinput_get_syspath:

This function may return NULL if UI_GET_SYSNAME is not available. In that case, libevdev uses ctime and the device name to guess devices. To avoid false positives, wait at least 1.5s between creating devices that have the same name.

This solution is not quite ideal either since it does rely on quite some assumptions on the user's system and the layout of /sys, but assuming UI_GET_SYSNAME is available, the code is at least immune to race conditions caused by different scripts creating devices simultaneously.

If we were to add a binding to the UI_GET_SYSNAME ioctl in the evdev/uinput.c file, then the rest of the logic can be implemented in pure Python code. If you want, I can work on a pull request to port libevdev's logic to python-evdev?

If you want, I can work on a pull request to port libevdev's logic to python-evdev?

Sure, please feel free to do so. I'll review and test it as good as I can. However, bear in mind that I'm not a c developer, so I won't be able to comment on anything related to memory management, performance and code quality and the likes.

I've gotten around to actually testing the proposed code with a little print for debugging:

    def _find_device(self):
        #:bug: the device node might not be immediately available
        time.sleep(0.1)

        # ensure the newest device is being used if multiple devices have the same name.
        # This is still subject to race conditions, if another process creates another
        # device in the meantime.
        devices = sorted(
            util.list_devices('/dev/input/'),
            reverse=True
        )

        print(devices)

        for path in devices:
            d = device.InputDevice(path)
            if d.name == self.name:
                return d

And the devices that got printed were:

['/dev/input/event9', '/dev/input/event8', '/dev/input/event7', '/dev/input/event6', '/dev/input/event5', '/dev/input/event4', '/dev/input/event3', '/dev/input/event23', '/dev/input/event22', '/dev/input/event21', '/dev/input/event20', '/dev/input/event2', '/dev/input/event19', '/dev/input/event18', '/dev/input/event17', '/dev/input/event16', '/dev/input/event15', '/dev/input/event14', '/dev/input/event13', '/dev/input/event12', '/dev/input/event11', '/dev/input/event10', '/dev/input/event1', '/dev/input/event0']

As you can see, using the default alphabetic sort, we're not getting the correct ordering, because /dev/input/event9 would end up getting checked before /dev/event/10, which leads to those wonderful bugs that have only a one-in-ten chance of occurring.

I added a commit with another sorting algorithm to my pull request.

This issue has been resolved by pull request #206.