From c7fd21c92d74d769fd61253207e20d24a2eeb1e0 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Sat, 21 May 2022 20:07:45 +0200 Subject: [PATCH 01/14] move daemon from GLib.MainLoop to asyncio --- bin/input-remapper-service | 1 - inputremapper/daemon.py | 53 ++++++++++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/bin/input-remapper-service b/bin/input-remapper-service index e8f4d07e1..769c40b95 100755 --- a/bin/input-remapper-service +++ b/bin/input-remapper-service @@ -50,5 +50,4 @@ if __name__ == '__main__': log_info('input-remapper-service') daemon = Daemon() - daemon.publish() daemon.run() diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 653395db9..e96f366bf 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -32,8 +32,9 @@ from pathlib import PurePath from typing import Protocol, Dict, Optional -import gi +import ravel from pydbus import SystemBus +import gi gi.require_version("GLib", "2.0") from gi.repository import GLib @@ -50,6 +51,7 @@ BUS_NAME = "inputremapper.Control" +PATH = "/inputremapper/Control" # timeout in seconds, see # https://github.com/LEW21/pydbus/blob/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/pydbus/proxy.py BUS_TIMEOUT = 10 @@ -145,6 +147,7 @@ def hello(self, out: str) -> str: ... +@ravel.interface(ravel.INTERFACE.SERVER, name=BUS_NAME) class Daemon: """Starts injecting keycodes based on the configuration. @@ -265,20 +268,22 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: return interface - def publish(self): - """Make the dbus interface available.""" - bus = SystemBus() - try: - bus.publish(BUS_NAME, self) - except RuntimeError as error: - logger.error("Is the service already running? (%s)", str(error)) - sys.exit(9) - def run(self): - """Start the daemons loop. Blocks until the daemon stops.""" - loop = GLib.MainLoop() - logger.debug("Running daemon") - loop.run() + """Start the event loop and publish the daemon. + Blocks until the daemon stops.""" + loop = asyncio.get_event_loop() + bus = ravel.system_bus() + bus.attach_asyncio(loop) + reply = bus.request_name( + bus_name=BUS_NAME, flags=ravel.DBUS.NAME_FLAG_DO_NOT_QUEUE + ) + if reply == ravel.DBUS.REQUEST_NAME_REPLY_PRIMARY_OWNER: + bus.register(path=PATH, fallback=False, interface=self) + logger.debug("Running daemon") + loop.run_forever() + else: + logger.error("Is the service already running? (%i)", reply) + sys.exit(9) def refresh(self, group_key: Optional[str] = None): """Refresh groups if the specified group is unknown. @@ -304,6 +309,7 @@ def refresh(self, group_key: Optional[str] = None): groups.refresh() self.refreshed_devices_at = now + @ravel.method(in_signature="s", out_signature="", arg_keys=["group_key"]) def stop_injecting(self, group_key: str): """Stop injecting the preset mappings for a single device.""" if self.injectors.get(group_key) is None: @@ -316,11 +322,18 @@ def stop_injecting(self, group_key: str): self.injectors[group_key].stop_injecting() self.autoload_history.forget(group_key) + @ravel.method(in_signature="s", out_signature="i", arg_keys=["group_key"]) def get_state(self, group_key: str) -> InjectorState: """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else InjectorState.UNKNOWN + @ravel.method( + name="set_config_dir", + in_signature="s", + out_signature="", + arg_keys=["config_dir"], + ) @remove_timeout def set_config_dir(self, config_dir: str): """All future operations will use this config dir. @@ -382,6 +395,12 @@ def _autoload(self, group_key: str): self.start_injecting(group.key, preset) self.autoload_history.remember(group.key, preset) + @ravel.method( + name="autoload_single", + in_signature="s", + out_signature="", + arg_keys=["group_key"], + ) @remove_timeout def autoload_single(self, group_key: str): """Inject the configured autoload preset for the device. @@ -409,6 +428,7 @@ def autoload_single(self, group_key: str): self._autoload(group_key) + @ravel.method(name="autoload", in_signature="", out_signature="") @remove_timeout def autoload(self): """Load all autoloaded presets for the current config_dir. @@ -433,6 +453,9 @@ def autoload(self): for group_key, _ in autoload_presets: self._autoload(group_key) + @ravel.method( + in_signature="ss", out_signature="b", arg_keys=["group_key", "preset"] + ) def start_injecting(self, group_key: str, preset: str) -> bool: """Start injecting the preset for the device. @@ -523,12 +546,14 @@ def start_injecting(self, group_key: str, preset: str) -> bool: return True + @ravel.method(in_signature="", out_signature="") def stop_all(self): """Stop all injections.""" logger.info("Stopping all injections") for group_key in list(self.injectors.keys()): self.stop_injecting(group_key) + @ravel.method(in_signature="s", out_signature="s") def hello(self, out: str): """Used for tests.""" logger.info('Received "%s" from client', out) From fc96693c3d874afaaf3e403836299f48872a5f61 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Sun, 22 May 2022 16:21:20 +0200 Subject: [PATCH 02/14] Why? :-( --- inputremapper/daemon.py | 58 +++++++++++++++++++---------- inputremapper/groups.py | 1 + inputremapper/injection/injector.py | 14 +++---- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index e96f366bf..02a128687 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -32,8 +32,10 @@ from pathlib import PurePath from typing import Protocol, Dict, Optional +import dbussy import ravel from pydbus import SystemBus + import gi gi.require_version("GLib", "2.0") @@ -51,7 +53,9 @@ BUS_NAME = "inputremapper.Control" -PATH = "/inputremapper/Control" +PATH_NAME = "/inputremapper/Control" +INTERFACE_NAME = "inputremapper.Control" + # timeout in seconds, see # https://github.com/LEW21/pydbus/blob/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/pydbus/proxy.py BUS_TIMEOUT = 10 @@ -224,11 +228,13 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: fallback If true, starts the daemon via pkexec if it cannot connect. """ - bus = SystemBus() + # bus = SystemBus() try: - interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) + interface = ravel.system_bus()[BUS_NAME][PATH_NAME].get_interface( + INTERFACE_NAME + ) logger.info("Connected to the service") - except GLib.GError as error: + except dbussy.DBusError as error: if not fallback: logger.error("Service not running? %s", error) return None @@ -251,9 +257,11 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: # try a few times if the service was just started for attempt in range(3): try: - interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) + interface = ravel.system_bus()[BUS_NAME][PATH_NAME].get_interface( + INTERFACE_NAME + ) break - except GLib.GError as error: + except dbussy.DBusError as error: logger.debug("Attempt %d to reach the service failed:", attempt + 1) logger.debug('"%s"', error) time.sleep(0.2) @@ -264,7 +272,7 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: if USER != "root": config_path = get_config_path() logger.debug('Telling service about "%s"', config_path) - interface.set_config_dir(get_config_path(), timeout=2) + interface.set_config_dir(get_config_path()) return interface @@ -278,14 +286,14 @@ def run(self): bus_name=BUS_NAME, flags=ravel.DBUS.NAME_FLAG_DO_NOT_QUEUE ) if reply == ravel.DBUS.REQUEST_NAME_REPLY_PRIMARY_OWNER: - bus.register(path=PATH, fallback=False, interface=self) + bus.register(path=PATH_NAME, fallback=False, interface=self) logger.debug("Running daemon") loop.run_forever() else: logger.error("Is the service already running? (%i)", reply) sys.exit(9) - def refresh(self, group_key: Optional[str] = None): + async def refresh(self, group_key: Optional[str] = None): """Refresh groups if the specified group is unknown. Parameters @@ -298,14 +306,14 @@ def refresh(self, group_key: Optional[str] = None): logger.debug("Refreshing because last info is too old") # it may take a little bit of time until devices are visible after # changes - time.sleep(0.1) + await asyncio.sleep(0.1) groups.refresh() self.refreshed_devices_at = now return if not groups.find(key=group_key): logger.debug('Refreshing because "%s" is unknown', group_key) - time.sleep(0.1) + await asyncio.sleep(0.1) groups.refresh() self.refreshed_devices_at = now @@ -454,9 +462,14 @@ def autoload(self): self._autoload(group_key) @ravel.method( - in_signature="ss", out_signature="b", arg_keys=["group_key", "preset"] + in_signature="ss", + out_signature="b", + arg_keys=["group_key", "preset"], + set_result_keyword="ret_func", ) - def start_injecting(self, group_key: str, preset: str) -> bool: + async def start_injecting( + self, group_key: str, preset: str, ret_func=lambda _: None + ) -> bool: """Start injecting the preset for the device. Returns True on success. If an injection is already ongoing for @@ -471,20 +484,22 @@ def start_injecting(self, group_key: str, preset: str) -> bool: """ logger.info('Request to start injecting for "%s"', group_key) - self.refresh(group_key) + await self.refresh(group_key) if self.config_dir is None: logger.error( "Request to start an injectoin before a user told the service about " "their session using set_config_dir", ) - return False + ret_func((False,)) + #return False group = groups.find(key=group_key) if group is None: logger.error('Could not find group "%s"', group_key) - return False + ret_func((False,)) + #return False preset_path = PurePath( self.config_dir, @@ -523,7 +538,8 @@ def start_injecting(self, group_key: str, preset: str) -> bool: preset.load() except FileNotFoundError as error: logger.error(str(error)) - return False + ret_func((False,)) + #return False for mapping in preset: # only create those uinputs that are required to avoid @@ -537,14 +553,16 @@ def start_injecting(self, group_key: str, preset: str) -> bool: try: injector = Injector(group, preset) - injector.start() + asyncio.create_task(injector.run()) self.injectors[group.key] = injector except OSError: # I think this will never happen, probably leftover from # some earlier version - return False + ret_func((False,)) + #return False - return True + ret_func((True,)) + #return True @ravel.method(in_signature="", out_signature="") def stop_all(self): diff --git a/inputremapper/groups.py b/inputremapper/groups.py index 43a7715bd..702efa3ca 100644 --- a/inputremapper/groups.py +++ b/inputremapper/groups.py @@ -459,6 +459,7 @@ def refresh(self): result is cached. Use refresh_groups if you need up to date devices. """ + # todo: make sure this is non blocking or can be awaited pipe = multiprocessing.Pipe() _FindGroups(pipe[1]).start() # block until groups are available diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index e4ce29fc3..ae8f0a791 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -101,7 +101,7 @@ def inactive(self) -> bool: return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB] -class Injector(multiprocessing.Process): +class Injector: """Initializes, starts and stops injections. Is a process to make it non-blocking for the rest of the code and to @@ -382,7 +382,7 @@ def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: raise e return forward_to - def run(self) -> None: + async def run(self) -> None: """The injection worker that keeps injecting until terminated. Stuff is non-blocking by using asyncio in order to do multiple things @@ -397,10 +397,10 @@ def run(self) -> None: # that sleeps on iterations (joystick_to_mouse) in one process causes # another injection process to screw up reading from the grabbed # device. - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - self._devices = self.group.get_devices() + # loop = asyncio.new_event_loop() + # asyncio.set_event_loop(loop) + + self._devices = self.group.get_devices() # InputConfigs may not contain the origin_hash information, this will try to make a # good guess if the origin_hash information is missing or invalid. @@ -445,7 +445,7 @@ def run(self) -> None: self._msg_pipe[0].send(InjectorState.RUNNING) try: - loop.run_until_complete(asyncio.gather(*coroutines)) + await asyncio.gather(*coroutines) except RuntimeError as error: # the loop might have been stopped via a `CLOSE` message, # which causes the error message below. This is expected behavior From 83102831e960dc6251c863f817572311a90e0c85 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Sun, 22 May 2022 16:51:11 +0200 Subject: [PATCH 03/14] pain --- bin/input-remapper-control | 4 ++-- inputremapper/daemon.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bin/input-remapper-control b/bin/input-remapper-control index 606cf1c12..f4114ee31 100755 --- a/bin/input-remapper-control +++ b/bin/input-remapper-control @@ -131,11 +131,11 @@ def communicate(options, daemon): logger.info('Autoloading all') # timeout is not documented, for more info see # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py - daemon.autoload(timeout=10) + daemon.autoload() else: group = require_group() logger.info('Asking daemon to autoload for %s', options.device) - daemon.autoload_single(group.key, timeout=2) + daemon.autoload_single(group.key) if options.command == START: group = require_group() diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 02a128687..af3eb3204 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -363,7 +363,7 @@ def set_config_dir(self, config_dir: str): self.config_dir = config_dir global_config.load_config(config_path) - def _autoload(self, group_key: str): + async def _autoload(self, group_key: str): """Check if autoloading is a good idea, and if so do it. Parameters @@ -371,7 +371,7 @@ def _autoload(self, group_key: str): group_key unique identifier used by the groups object """ - self.refresh(group_key) + await self.refresh(group_key) group = groups.find(key=group_key) if group is None: @@ -400,7 +400,7 @@ def _autoload(self, group_key: str): ) return - self.start_injecting(group.key, preset) + await self.start_injecting(group.key, preset) self.autoload_history.remember(group.key, preset) @ravel.method( @@ -410,7 +410,7 @@ def _autoload(self, group_key: str): arg_keys=["group_key"], ) @remove_timeout - def autoload_single(self, group_key: str): + async def autoload_single(self, group_key: str): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. @@ -434,11 +434,11 @@ def autoload_single(self, group_key: str): ) return - self._autoload(group_key) + await self._autoload(group_key) @ravel.method(name="autoload", in_signature="", out_signature="") - @remove_timeout - def autoload(self): + # @remove_timeout + async def autoload(self): """Load all autoloaded presets for the current config_dir. If the preset is already being injected, it won't autoload it again. @@ -459,7 +459,7 @@ def autoload(self): return for group_key, _ in autoload_presets: - self._autoload(group_key) + await self._autoload(group_key) @ravel.method( in_signature="ss", From ef3fc58827dd6ec3bd19a56f98c909a4cc9cb511 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Sun, 22 May 2022 20:28:53 +0200 Subject: [PATCH 04/14] dbus-next implementation --- inputremapper/daemon.py | 105 +++++++++++----------------- inputremapper/injection/injector.py | 5 -- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index af3eb3204..1c557e78a 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -32,8 +32,8 @@ from pathlib import PurePath from typing import Protocol, Dict, Optional -import dbussy -import ravel +from dbus_next.aio import MessageBus +from dbus_next import BusType, service, RequestNameReply from pydbus import SystemBus import gi @@ -200,6 +200,7 @@ class Daemon: def __init__(self): """Constructs the daemon.""" logger.debug("Creating daemon") + super().__init__(INTERFACE_NAME) self.injectors: Dict[str, Injector] = {} self.config_dir = None @@ -228,13 +229,11 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: fallback If true, starts the daemon via pkexec if it cannot connect. """ - # bus = SystemBus() + bus = SystemBus() try: - interface = ravel.system_bus()[BUS_NAME][PATH_NAME].get_interface( - INTERFACE_NAME - ) + interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) logger.info("Connected to the service") - except dbussy.DBusError as error: + except GLib.Error as error: if not fallback: logger.error("Service not running? %s", error) return None @@ -257,11 +256,9 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: # try a few times if the service was just started for attempt in range(3): try: - interface = ravel.system_bus()[BUS_NAME][PATH_NAME].get_interface( - INTERFACE_NAME - ) + interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) break - except dbussy.DBusError as error: + except GLib.Error as error: logger.debug("Attempt %d to reach the service failed:", attempt + 1) logger.debug('"%s"', error) time.sleep(0.2) @@ -280,18 +277,17 @@ def run(self): """Start the event loop and publish the daemon. Blocks until the daemon stops.""" loop = asyncio.get_event_loop() - bus = ravel.system_bus() - bus.attach_asyncio(loop) - reply = bus.request_name( - bus_name=BUS_NAME, flags=ravel.DBUS.NAME_FLAG_DO_NOT_QUEUE - ) - if reply == ravel.DBUS.REQUEST_NAME_REPLY_PRIMARY_OWNER: - bus.register(path=PATH_NAME, fallback=False, interface=self) - logger.debug("Running daemon") - loop.run_forever() - else: - logger.error("Is the service already running? (%i)", reply) - sys.exit(9) + + async def task(): + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + bus.export(path=PATH_NAME, interface=self) + if RequestNameReply.PRIMARY_OWNER != await bus.request_name(BUS_NAME): + logger.error("Is the service already running?") + sys.exit(9) + + loop.run_until_complete(task()) + logger.debug("Running daemon") + loop.run_forever() async def refresh(self, group_key: Optional[str] = None): """Refresh groups if the specified group is unknown. @@ -317,8 +313,8 @@ async def refresh(self, group_key: Optional[str] = None): groups.refresh() self.refreshed_devices_at = now - @ravel.method(in_signature="s", out_signature="", arg_keys=["group_key"]) - def stop_injecting(self, group_key: str): + @service.method() + def stop_injecting(self, group_key: 's'): """Stop injecting the preset mappings for a single device.""" if self.injectors.get(group_key) is None: logger.debug( @@ -330,20 +326,14 @@ def stop_injecting(self, group_key: str): self.injectors[group_key].stop_injecting() self.autoload_history.forget(group_key) - @ravel.method(in_signature="s", out_signature="i", arg_keys=["group_key"]) - def get_state(self, group_key: str) -> InjectorState: + @service.method() + def get_state(self, group_key: 's') -> 's': """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else InjectorState.UNKNOWN - @ravel.method( - name="set_config_dir", - in_signature="s", - out_signature="", - arg_keys=["config_dir"], - ) - @remove_timeout - def set_config_dir(self, config_dir: str): + @service.method() + def set_config_dir(self, config_dir: 's'): """All future operations will use this config dir. Existing injections (possibly of the previous user) will be kept @@ -403,14 +393,8 @@ async def _autoload(self, group_key: str): await self.start_injecting(group.key, preset) self.autoload_history.remember(group.key, preset) - @ravel.method( - name="autoload_single", - in_signature="s", - out_signature="", - arg_keys=["group_key"], - ) - @remove_timeout - async def autoload_single(self, group_key: str): + @service.method() + async def autoload_single(self, group_key: 's'): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. @@ -436,8 +420,7 @@ async def autoload_single(self, group_key: str): await self._autoload(group_key) - @ravel.method(name="autoload", in_signature="", out_signature="") - # @remove_timeout + @service.method() async def autoload(self): """Load all autoloaded presets for the current config_dir. @@ -461,15 +444,10 @@ async def autoload(self): for group_key, _ in autoload_presets: await self._autoload(group_key) - @ravel.method( - in_signature="ss", - out_signature="b", - arg_keys=["group_key", "preset"], - set_result_keyword="ret_func", - ) + @service.method() async def start_injecting( - self, group_key: str, preset: str, ret_func=lambda _: None - ) -> bool: + self, group_key: 's', preset: 's' + ) -> 'b': """Start injecting the preset for the device. Returns True on success. If an injection is already ongoing for @@ -491,15 +469,13 @@ async def start_injecting( "Request to start an injectoin before a user told the service about " "their session using set_config_dir", ) - ret_func((False,)) - #return False + return False group = groups.find(key=group_key) if group is None: logger.error('Could not find group "%s"', group_key) - ret_func((False,)) - #return False + return False preset_path = PurePath( self.config_dir, @@ -538,8 +514,7 @@ async def start_injecting( preset.load() except FileNotFoundError as error: logger.error(str(error)) - ret_func((False,)) - #return False + return False for mapping in preset: # only create those uinputs that are required to avoid @@ -558,21 +533,19 @@ async def start_injecting( except OSError: # I think this will never happen, probably leftover from # some earlier version - ret_func((False,)) - #return False + return False - ret_func((True,)) - #return True + return True - @ravel.method(in_signature="", out_signature="") + @service.method() def stop_all(self): """Stop all injections.""" logger.info("Stopping all injections") for group_key in list(self.injectors.keys()): self.stop_injecting(group_key) - @ravel.method(in_signature="s", out_signature="s") - def hello(self, out: str): + @service.method() + def hello(self, out: 's') -> 's': """Used for tests.""" logger.info('Received "%s" from client', out) return out diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index ae8f0a791..e22e9b5e8 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -346,11 +346,6 @@ async def _msg_listener(self) -> None: # give the event pipeline some time to reset devices # before shutting the loop down await asyncio.sleep(0.1) - - # stop the event loop and cause the process to reach its end - # cleanly. Using .terminate prevents coverage from working. - loop.stop() - self._msg_pipe[0].send(InjectorState.STOPPED) return def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: From 2cb81b5d88bdce29ff1f33c70929ea6cfb6f1ee5 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Mon, 23 May 2022 09:44:50 +0200 Subject: [PATCH 05/14] missing return from interface methods --- tests/unit/test_daemon.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index fa1559cb3..edd40f31b 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -53,7 +53,7 @@ dbus_get = type(SystemBus()).get -class TestDaemon(unittest.TestCase): +class TestDaemon(unittest.IsolatedAsyncioTestCase): new_fixture_path = "/dev/input/event9876" def setUp(self): @@ -432,7 +432,7 @@ def test_autoload(self): preset.save() # no autoloading is configured yet - self.daemon._autoload(group_key) + await self.daemon._autoload(group_key) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) @@ -448,8 +448,8 @@ def test_autoload(self): injector = daemon.injectors[group_key] self.assertEqual(len_before + 1, len_after) - # calling duplicate get_autoload does nothing - self.daemon._autoload(group_key) + # calling duplicate autoload does nothing + await self.daemon._autoload(group_key) self.assertEqual( daemon.autoload_history._autoload_history[group_key][1], preset_name ) @@ -457,18 +457,18 @@ def test_autoload(self): self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) # explicit start_injecting clears the autoload history - self.daemon.start_injecting(group_key, preset_name) + await self.daemon.start_injecting(group_key, preset_name) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) # calling autoload for (yet) unknown devices does nothing len_before = len(self.daemon.autoload_history._autoload_history) - self.daemon._autoload("unknown-key-1234") + await self.daemon._autoload("unknown-key-1234") len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual(len_before, len_after) # autoloading input-remapper devices does nothing len_before = len(self.daemon.autoload_history._autoload_history) - self.daemon.autoload_single("Bar Device") + await self.daemon.autoload_single("Bar Device") len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual(len_before, len_after) From 1ed7d89e0fff306c4664a4671da0ce1d0877d6bd Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Fri, 16 Dec 2022 21:40:45 +0100 Subject: [PATCH 06/14] fixed rebase issues --- inputremapper/daemon.py | 24 +++++++++++------------- inputremapper/injection/injector.py | 13 ++++++++----- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 1c557e78a..945bb27e3 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -22,15 +22,16 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/examples/clientserver # noqa pylint: disable=line-too-long """ - - +import asyncio import atexit +import functools import json import os import sys import time +import typing from pathlib import PurePath -from typing import Protocol, Dict, Optional +from typing import Protocol, Dict, Optional, Callable from dbus_next.aio import MessageBus from dbus_next import BusType, service, RequestNameReply @@ -151,8 +152,7 @@ def hello(self, out: str) -> str: ... -@ravel.interface(ravel.INTERFACE.SERVER, name=BUS_NAME) -class Daemon: +class Daemon(service.ServiceInterface): """Starts injecting keycodes based on the configuration. Can be talked to either over dbus or by instantiating it. @@ -314,7 +314,7 @@ async def refresh(self, group_key: Optional[str] = None): self.refreshed_devices_at = now @service.method() - def stop_injecting(self, group_key: 's'): + def stop_injecting(self, group_key: "s"): """Stop injecting the preset mappings for a single device.""" if self.injectors.get(group_key) is None: logger.debug( @@ -327,13 +327,13 @@ def stop_injecting(self, group_key: 's'): self.autoload_history.forget(group_key) @service.method() - def get_state(self, group_key: 's') -> 's': + def get_state(self, group_key: "s") -> "s": """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else InjectorState.UNKNOWN @service.method() - def set_config_dir(self, config_dir: 's'): + def set_config_dir(self, config_dir: "s"): """All future operations will use this config dir. Existing injections (possibly of the previous user) will be kept @@ -394,7 +394,7 @@ async def _autoload(self, group_key: str): self.autoload_history.remember(group.key, preset) @service.method() - async def autoload_single(self, group_key: 's'): + async def autoload_single(self, group_key: "s"): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. @@ -445,9 +445,7 @@ async def autoload(self): await self._autoload(group_key) @service.method() - async def start_injecting( - self, group_key: 's', preset: 's' - ) -> 'b': + async def start_injecting(self, group_key: "s", preset: "s") -> "b": """Start injecting the preset for the device. Returns True on success. If an injection is already ongoing for @@ -545,7 +543,7 @@ def stop_all(self): self.stop_injecting(group_key) @service.method() - def hello(self, out: 's') -> 's': + def hello(self, out: "s") -> "s": """Used for tests.""" logger.info('Received "%s" from client', out) return out diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index e22e9b5e8..4707a70a7 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -112,6 +112,7 @@ class Injector: group: _Group preset: Preset context: Optional[Context] + _alive: bool _devices: List[evdev.InputDevice] _state: InjectorState _msg_pipe: Tuple[Connection, Connection] @@ -129,6 +130,7 @@ def __init__(self, group: _Group, preset: Preset) -> None: the device group """ self.group = group + self._alive = False self._state = InjectorState.UNKNOWN # used to interact with the parts of this class that are running within @@ -140,8 +142,6 @@ def __init__(self, group: _Group, preset: Preset) -> None: self._event_readers = [] - super().__init__(name=group.key) - """Functions to interact with the running process.""" def get_state(self) -> InjectorState: @@ -155,7 +155,7 @@ def get_state(self) -> InjectorState: state = self._msg_pipe[1].recv() # figure out what is going on step by step - alive = self.is_alive() + alive = self._alive # if `self.start()` has been called started = state != InjectorState.UNKNOWN or alive @@ -386,6 +386,7 @@ async def run(self) -> None: Use this function as starting point in a process. It creates the loops needed to read and map events and keeps running them. """ + self._alive = True logger.info('Starting injecting the preset for "%s"', self.group.key) # create a new event loop, because somehow running an infinite loop @@ -394,8 +395,7 @@ async def run(self) -> None: # device. # loop = asyncio.new_event_loop() # asyncio.set_event_loop(loop) - - self._devices = self.group.get_devices() + self._devices = self.group.get_devices() # InputConfigs may not contain the origin_hash information, this will try to make a # good guess if the origin_hash information is missing or invalid. @@ -463,3 +463,6 @@ async def run(self) -> None: except OSError as error: # it might have disappeared logger.debug("OSError for ungrab on %s: %s", source.path, str(error)) + + self._msg_pipe[0].send(InjectorState.STOPPED) + self._alive = False From 51a24b1c69920d2568bb40c49d0e4fdaad134134 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Thu, 22 Dec 2022 12:14:27 +0100 Subject: [PATCH 07/14] Fixed non-returning method decorator https://github.com/altdesktop/python-dbus-next/issues/119 --- inputremapper/daemon.py | 67 +++++++++++------------------ inputremapper/injection/injector.py | 17 +++++++- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 945bb27e3..31d127322 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -25,13 +25,15 @@ import asyncio import atexit import functools +import inspect import json import os import sys import time +import tracemalloc import typing from pathlib import PurePath -from typing import Protocol, Dict, Optional, Callable +from typing import Protocol, Dict, Optional from dbus_next.aio import MessageBus from dbus_next import BusType, service, RequestNameReply @@ -52,6 +54,7 @@ from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import global_uinputs +tracemalloc.start() BUS_NAME = "inputremapper.Control" PATH_NAME = "/inputremapper/Control" @@ -152,6 +155,18 @@ def hello(self, out: str) -> str: ... +def method(name: str = None, disabled: bool = False): + # this is a workaround for https://github.com/altdesktop/python-dbus-next/issues/119 + @typing.no_type_check_decorator + def fixed_decorator(fn): + # we don't actually decorate the function + # dbus-next only cares about the __dict__ + fn.__dict__ = service.method(name, disabled)(fn).__dict__ + return fn + + return fixed_decorator + + class Daemon(service.ServiceInterface): """Starts injecting keycodes based on the configuration. @@ -163,40 +178,6 @@ class Daemon(service.ServiceInterface): on its own. """ - # https://dbus.freedesktop.org/doc/dbus-specification.html#type-system - dbus = f""" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - """ - def __init__(self): """Constructs the daemon.""" logger.debug("Creating daemon") @@ -313,7 +294,7 @@ async def refresh(self, group_key: Optional[str] = None): groups.refresh() self.refreshed_devices_at = now - @service.method() + @method() def stop_injecting(self, group_key: "s"): """Stop injecting the preset mappings for a single device.""" if self.injectors.get(group_key) is None: @@ -326,13 +307,13 @@ def stop_injecting(self, group_key: "s"): self.injectors[group_key].stop_injecting() self.autoload_history.forget(group_key) - @service.method() + @method() def get_state(self, group_key: "s") -> "s": """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else InjectorState.UNKNOWN - @service.method() + @method() def set_config_dir(self, config_dir: "s"): """All future operations will use this config dir. @@ -393,7 +374,7 @@ async def _autoload(self, group_key: str): await self.start_injecting(group.key, preset) self.autoload_history.remember(group.key, preset) - @service.method() + @method() async def autoload_single(self, group_key: "s"): """Inject the configured autoload preset for the device. @@ -420,7 +401,7 @@ async def autoload_single(self, group_key: "s"): await self._autoload(group_key) - @service.method() + @method() async def autoload(self): """Load all autoloaded presets for the current config_dir. @@ -444,7 +425,7 @@ async def autoload(self): for group_key, _ in autoload_presets: await self._autoload(group_key) - @service.method() + @method() async def start_injecting(self, group_key: "s", preset: "s") -> "b": """Start injecting the preset for the device. @@ -535,14 +516,14 @@ async def start_injecting(self, group_key: "s", preset: "s") -> "b": return True - @service.method() + @method() def stop_all(self): """Stop all injections.""" logger.info("Stopping all injections") for group_key in list(self.injectors.keys()): self.stop_injecting(group_key) - @service.method() + @method() def hello(self, out: "s") -> "s": """Used for tests.""" logger.info('Received "%s" from client', out) diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 4707a70a7..ff0c7069f 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -377,7 +377,21 @@ def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: raise e return forward_to + def is_alive(self) -> bool: + """used in tests, can probably be removed, previously defined by the + multiprocessing.Process superclass""" + return self._alive + async def run(self) -> None: + self._alive = True + try: + await self._run() + except: + self._alive = False + raise + self._alive = False + + async def _run(self) -> None: """The injection worker that keeps injecting until terminated. Stuff is non-blocking by using asyncio in order to do multiple things @@ -386,7 +400,6 @@ async def run(self) -> None: Use this function as starting point in a process. It creates the loops needed to read and map events and keeps running them. """ - self._alive = True logger.info('Starting injecting the preset for "%s"', self.group.key) # create a new event loop, because somehow running an infinite loop @@ -414,6 +427,7 @@ async def run(self) -> None: # maybe the preset was empty or something logger.error("Did not grab any device") self._msg_pipe[0].send(InjectorState.NO_GRAB) + self._alive = False return numlock_state = is_numlock_on() @@ -465,4 +479,3 @@ async def run(self) -> None: logger.debug("OSError for ungrab on %s: %s", source.path, str(error)) self._msg_pipe[0].send(InjectorState.STOPPED) - self._alive = False From ebb5f040cacdc27f960a72ea1e83dd6b6bf58945 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Thu, 22 Dec 2022 12:14:50 +0100 Subject: [PATCH 08/14] fixed some tests --- tests/integration/test_gui.py | 154 ++++++++++++++++++---------------- tests/lib/patches.py | 24 ++++++ tests/lib/pipes.py | 1 + tests/lib/stuff.py | 2 +- tests/unit/test_control.py | 38 ++++++--- tests/unit/test_injector.py | 26 +++--- 6 files changed, 145 insertions(+), 100 deletions(-) diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index c175225fc..37e243bda 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -17,7 +17,8 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +import asyncio +import inspect # the tests file needs to be imported first to make sure patches are loaded from contextlib import contextmanager @@ -25,10 +26,11 @@ from tests.test import get_project_root from tests.lib.fixtures import new_event +from tests.lib.patches import SyncProxy from tests.lib.cleanup import cleanup from tests.lib.stuff import spy from tests.lib.constants import EVENT_READ_TIMEOUT -from tests.lib.fixtures import prepare_presets, get_combination_config +from tests.lib.fixtures import prepare_presets from tests.lib.logger import logger from tests.lib.fixtures import fixtures from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe @@ -130,7 +132,7 @@ def patch_launch(): the dbus and don't use pkexec to start the reader-service""" original_connect = Daemon.connect original_os_system = os.system - Daemon.connect = Daemon + Daemon.connect = lambda: SyncProxy(Daemon()) def os_system(cmd): # instead of running pkexec, fork instead. This will make @@ -169,7 +171,7 @@ def get_keyval(self): return True, self.keyval -class TestGroupsFromReaderService(unittest.TestCase): +class TestGroupsFromReaderService(unittest.IsolatedAsyncioTestCase): def setUp(self): # don't try to connect, return an object instance of it instead self.original_connect = Daemon.connect @@ -204,7 +206,7 @@ def tearDown(self): os.system = self.original_os_system Daemon.connect = self.original_connect - def test_knows_devices(self): + async def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge # of groups until the root-reader-service provides them self.data_manager._reader_client.groups.set_groups([]) @@ -217,7 +219,7 @@ def test_knows_devices(self): # perform some iterations so that the reader ends up reading from the pipes # which will make it receive devices. for _ in range(10): - time.sleep(0.02) + await asyncio.sleep(0.02) gtk_iteration() self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) @@ -260,7 +262,7 @@ def __exit__(self, *args, **kwargs): self.patch.__exit__(*args, **kwargs) -class GuiTestBase(unittest.TestCase): +class GuiTestBase(unittest.IsolatedAsyncioTestCase): def setUp(self): prepare_presets() with patch_launch(): @@ -368,22 +370,26 @@ def _callTestMethod(self, method): self.tearDown() self.setUp() - def throttle(self, time_=10): + async def throttle(self, time_=10): """Give GTK some time in ms to process everything.""" + # since the injector runs a async event loop in the same thread (for tests) + # iterate the async and the glib evnet loop simultaneously. + # Hopefully this does not introduce a bunch of race conditions. + # tests suddenly started to freeze my computer up completely and tests started # to fail. By using this (and by optimizing some redundant calls in the gui) it # worked again. EDIT: Might have been caused by my broken/bloated ssd. I'll # keep it in some places, since it did make the tests more reliable after all. for _ in range(time_ // 2): gtk_iteration() - time.sleep(0.002) + await asyncio.sleep(0.002) - def set_focus(self, widget): + async def set_focus(self, widget): logger.info("Focusing %s", widget) self.user_interface.window.set_focus(widget) - self.throttle(20) + await self.throttle(20) def get_selection_labels(self) -> List[MappingSelectionLabel]: return self.selection_label_listbox.get_children() @@ -542,7 +548,7 @@ def test_initial_state(self): def test_set_autoload_refreshes_service_config(self): self.assertFalse(self.data_manager.get_autoload()) - with spy(self.daemon, "set_config_dir") as set_config_dir: + with spy(self.daemon.wrapped, "set_config_dir") as set_config_dir: self.autoload_toggle.set_active(True) gtk_iteration() set_config_dir.assert_called_once() @@ -649,7 +655,7 @@ def test_recording_label_updates_on_recording_finished(self): self.assertFalse(self.recording_status.get_visible()) self.assertFalse(self.recording_toggle.get_active()) - def test_events_from_reader_service_arrive(self): + async def test_events_from_reader_service_arrive(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() @@ -667,7 +673,7 @@ def test_events_from_reader_service_arrive(self): fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 31, 1)], ) - self.throttle(40) + await self.throttle(40) origin = fixtures.foo_device_2_keyboard.get_device_hash() mock1.assert_has_calls( ( @@ -695,19 +701,19 @@ def test_events_from_reader_service_arrive(self): mock2.assert_not_called() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 0)]) - self.throttle(40) + await self.throttle(40) self.assertEqual(mock1.call_count, 2) mock2.assert_not_called() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) - self.throttle(40) + await self.throttle(40) self.assertEqual(mock1.call_count, 2) mock2.assert_called_once() self.assertFalse(self.recording_toggle.get_active()) mock3.assert_called_once() - def test_cannot_create_duplicate_input_combination(self): + async def test_cannot_create_duplicate_input_combination(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() @@ -718,7 +724,7 @@ def test_cannot_create_duplicate_input_combination(self): fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], ) - self.throttle(40) + await self.throttle(40) # if this fails with : this is the initial # mapping or something, so it was never overwritten. @@ -742,7 +748,7 @@ def test_cannot_create_duplicate_input_combination(self): fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], ) - self.throttle(40) + await self.throttle(40) # should still be the empty mapping self.assertEqual( self.data_manager.active_mapping.input_combination, @@ -752,14 +758,14 @@ def test_cannot_create_duplicate_input_combination(self): # try to record a different combination self.controller.start_key_recording() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) - self.throttle(40) + await self.throttle(40) # nothing changed yet, as we got the duplicate combination self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 1)]) - self.throttle(40) + await self.throttle(40) # now the combination is different self.assertEqual( self.data_manager.active_mapping.input_combination, @@ -773,7 +779,7 @@ def test_cannot_create_duplicate_input_combination(self): # let's make the combination even longer push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 32, 1)]) - self.throttle(40) + await self.throttle(40) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination( @@ -794,7 +800,7 @@ def test_cannot_create_duplicate_input_combination(self): InputEvent(0, 0, 1, 32, 0), ], ) - self.throttle(40) + await self.throttle(40) # sending a combination update now should not do anything self.message_broker.publish( @@ -812,7 +818,7 @@ def test_cannot_create_duplicate_input_combination(self): ), ) - def test_create_simple_mapping(self): + async def test_create_simple_mapping(self): self.click_on_group("Foo Device 2") # 1. create a mapping self.create_mapping_btn.clicked() @@ -839,9 +845,9 @@ def test_create_simple_mapping(self): self.recording_toggle.set_active(True) gtk_iteration() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) - self.throttle(40) + await self.throttle(40) push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) - self.throttle(40) + await self.throttle(40) # check the input_combination origin = fixtures.foo_device_2_keyboard.get_device_hash() @@ -910,7 +916,7 @@ def test_show_status(self): text = self.get_status_text() self.assertNotIn("...", text) - def test_hat_switch(self): + async def test_hat_switch(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() @@ -921,14 +927,14 @@ def test_hat_switch(self): ev_3 = (EV_ABS, evdev.ecodes.ABS_HAT0Y, -1) ev_4 = (EV_ABS, evdev.ecodes.ABS_HAT0Y, 1) - def add_mapping(event_tuple, symbol) -> InputCombination: + async def add_mapping(event_tuple, symbol) -> InputCombination: """adds mapping and returns the expected input combination""" event = InputEvent.from_tuple(event_tuple) self.controller.create_mapping() gtk_iteration() self.controller.start_key_recording() push_events(fixtures.foo_device_2_gamepad, [event, event.modify(value=0)]) - self.throttle(40) + await self.throttle(40) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() @@ -938,10 +944,10 @@ def add_mapping(event_tuple, symbol) -> InputCombination: ) ) - config_1 = add_mapping(ev_1, "a") - config_2 = add_mapping(ev_2, "b") - config_3 = add_mapping(ev_3, "c") - config_4 = add_mapping(ev_4, "d") + config_1 = await add_mapping(ev_1, "a") + config_2 = await add_mapping(ev_2, "b") + config_3 = await add_mapping(ev_3, "c") + config_4 = await add_mapping(ev_4, "d") self.assertEqual( self.data_manager.active_preset.get_mapping( @@ -968,7 +974,7 @@ def add_mapping(event_tuple, symbol) -> InputCombination: "d", ) - def test_combination(self): + async def test_combination(self): # if this test freezes, try waiting a few minutes and then look for # stack traces in the console @@ -1011,7 +1017,7 @@ def get_combination(combi: Iterable[Tuple[int, int, int]]) -> InputCombination: configs.append(config) return InputCombination(configs) - def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): + async def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): self.controller.create_mapping() gtk_iteration() self.controller.start_key_recording() @@ -1019,7 +1025,7 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): for event_tuple in combi: event = InputEvent.from_tuple(event_tuple) if event.type != previous_event.type: - self.throttle(20) # avoid race condition if we switch fixture + await self.throttle(20) # avoid race condition if we switch fixture if event.type == EV_KEY: push_event(fixtures.foo_device_2_keyboard, event) if event.type == EV_ABS: @@ -1036,12 +1042,12 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): if event.type == EV_REL: pass - self.throttle(40) + await self.throttle(40) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() - add_mapping(combination_1, "a") + await add_mapping(combination_1, "a") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) @@ -1069,7 +1075,7 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): # it won't write the same combination again, even if the # first two events are in a different order - add_mapping(combination_2, "b") + await add_mapping(combination_2, "b") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) @@ -1095,7 +1101,7 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) - add_mapping(combination_3, "c") + await add_mapping(combination_3, "c") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) @@ -1130,7 +1136,7 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): # same as with combination_2, the existing combination_3 blocks # combination_4 because they have the same keys and end in the # same key. - add_mapping(combination_4, "d") + await add_mapping(combination_4, "d") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) @@ -1162,7 +1168,7 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) - add_mapping(combination_5, "e") + await add_mapping(combination_5, "e") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) @@ -1308,14 +1314,14 @@ def test_selection_label_uses_name_if_available(self): self.assertEqual(row.label.get_text(), "Empty Mapping") self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) - def test_fake_empty_mapping_does_not_sort_to_bottom(self): + async def test_fake_empty_mapping_does_not_sort_to_bottom(self): """If someone chooses to name a mapping "Empty Mapping" it is not sorted to the bottom""" self.controller.load_preset("preset1") gtk_iteration() self.controller.update_mapping(name="Empty Mapping") - self.throttle(20) # sorting seems to take a bit + await self.throttle(20) # sorting seems to take a bit # "Empty Mapping" < "Escape" so we still expect this to be the first row row = self.selection_label_listbox.get_selected_row() @@ -1323,7 +1329,7 @@ def test_fake_empty_mapping_does_not_sort_to_bottom(self): # now create a real empty mapping self.controller.create_mapping() - self.throttle(20) + await self.throttle(20) # for some reason we no longer can use assertIs maybe a gtk bug? # self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) @@ -1358,12 +1364,12 @@ def test_remove_mapping(self): self.assertEqual(len(self.data_manager.active_preset), 1) self.assertEqual(len(self.selection_label_listbox.get_children()), 1) - def test_problematic_combination(self): + async def test_problematic_combination(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() - def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): + async def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): combi = [InputEvent(0, 0, *t) for t in combi] self.controller.create_mapping() gtk_iteration() @@ -1373,14 +1379,14 @@ def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): fixtures.foo_device_2_keyboard, [event.modify(value=0) for event in combi], ) - self.throttle(40) + await self.throttle(40) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() combination = [(EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)] - add_mapping(combination, "b") + await add_mapping(combination, "b") text = self.get_status_text() self.assertIn("shift", text) @@ -1415,13 +1421,13 @@ def save(): gtk_iteration() self.assertFalse(os.path.exists(preset_path)) - def test_check_for_unknown_symbols(self): + async def test_check_for_unknown_symbols(self): status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.controller.load_preset("preset1") - self.throttle(20) + await self.throttle(20) self.controller.load_mapping(InputCombination(InputConfig(type=1, code=1))) gtk_iteration() self.controller.update_mapping(output_symbol="foo") @@ -1718,7 +1724,7 @@ def test_cannot_record_keys(self): text = self.get_status_text() self.assertIn("Stop", text) - def test_start_injecting(self): + async def test_start_injecting(self): self.controller.load_group("Foo Device 2") with spy(self.daemon, "set_config_dir") as spy1: @@ -1731,7 +1737,7 @@ def test_start_injecting(self): spy1.assert_called_once_with(get_config_path()) for _ in range(10): - time.sleep(0.1) + await asyncio.sleep(0.1) gtk_iteration() if self.data_manager.get_state() == InjectorState.RUNNING: break @@ -1772,7 +1778,7 @@ def test_start_injecting(self): device_group_entry = child.get_children()[0] self.assertNotIn("input-remapper", device_group_entry.name) - def test_stop_injecting(self): + async def test_stop_injecting(self): self.controller.load_group("Foo Device 2") self.start_injector_btn.clicked() gtk_iteration() @@ -1800,7 +1806,7 @@ def test_stop_injecting(self): ], ) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertTrue(pipe.poll()) while pipe.poll(): pipe.recv() @@ -1810,7 +1816,7 @@ def test_stop_injecting(self): gtk_iteration() for _ in range(10): - time.sleep(0.1) + await asyncio.sleep(0.1) gtk_iteration() if self.data_manager.get_state() == InjectorState.STOPPED: break @@ -1823,7 +1829,7 @@ def test_stop_injecting(self): new_event(evdev.events.EV_KEY, 5, 0), ], ) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertFalse(pipe.poll()) def test_delete_preset(self): @@ -1844,7 +1850,7 @@ def test_delete_preset(self): self.assertEqual(self.data_manager.active_preset.name, "preset2") self.assertEqual(self.data_manager.active_group.name, "Foo Device") - def test_refresh_groups(self): + async def test_refresh_groups(self): # sanity check: preset3 should be the newest self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" @@ -1865,7 +1871,7 @@ def test_refresh_groups(self): self.controller.refresh_groups() gtk_iteration() - self.throttle(200) + await self.throttle(200) # the gui should not jump to a different preset suddenly self.assertEqual(self.data_manager.active_preset.name, "preset1") @@ -1939,7 +1945,7 @@ def test_delete_last_preset(self): device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) - def test_enable_disable_output(self): + async def test_enable_disable_output(self): # load a group without any presets self.controller.load_group("Bar Device") @@ -1965,7 +1971,7 @@ def test_enable_disable_output(self): InputEvent(0, 0, 1, 30, 0), ], ) - self.throttle(100) # give time for the input to arrive + await self.throttle(100) # give time for the input to arrive self.assertEqual(self.get_unfiltered_symbol_input_text(), "") self.assertTrue(self.output_box.get_sensitive()) @@ -1985,11 +1991,11 @@ def press_key(self, keyval): event.keyval = keyval self.user_interface.autocompletion.navigate(None, event) - def test_autocomplete_key(self): + async def test_autocomplete_key(self): self.controller.update_mapping(output_symbol="") gtk_iteration() - self.set_focus(self.code_editor) + await self.set_focus(self.code_editor) complete_key_name = "Test_Foo_Bar" @@ -2008,7 +2014,7 @@ def test_autocomplete_key(self): ) Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo") - self.throttle(200) + await self.throttle(200) gtk_iteration() autocompletion = self.user_interface.autocompletion @@ -2016,7 +2022,7 @@ def test_autocomplete_key(self): self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Return) - self.throttle(200) + await self.throttle(200) gtk_iteration() # the first suggestion should have been selected @@ -2028,17 +2034,17 @@ def test_autocomplete_key(self): # should be shown Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ") - time.sleep(0.11) + await asyncio.sleep(0.11) gtk_iteration() self.assertFalse(autocompletion.visible) - def test_autocomplete_function(self): + async def test_autocomplete_function(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.code_editor - self.set_focus(source_view) + await self.set_focus(source_view) incomplete = "key(KEY_A).\nepea" Gtk.TextView.do_insert_at_cursor(source_view, incomplete) @@ -2056,12 +2062,12 @@ def test_autocomplete_function(self): modified_symbol = self.get_code_input() self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") - def test_close_autocompletion(self): + async def test_close_autocompletion(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.code_editor - self.set_focus(source_view) + await self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") @@ -2079,11 +2085,11 @@ def test_close_autocompletion(self): symbol = self.get_code_input() self.assertEqual(symbol, "KEY_") - def test_writing_still_works(self): + async def test_writing_still_works(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.code_editor - self.set_focus(source_view) + await self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") @@ -2109,11 +2115,11 @@ def test_writing_still_works(self): # no key matches this completion, so it closes again self.assertFalse(autocompletion.visible) - def test_cycling(self): + async def test_cycling(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.code_editor - self.set_focus(source_view) + await self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") diff --git a/tests/lib/patches.py b/tests/lib/patches.py index c00ae7f79..4292b2335 100644 --- a/tests/lib/patches.py +++ b/tests/lib/patches.py @@ -22,6 +22,7 @@ import asyncio import copy +import inspect import os import subprocess import time @@ -342,3 +343,26 @@ def autoload_single(self, group_key: str) -> None: def hello(self, out: str) -> str: self.calls["hello"].append(out) return out + + +class SyncProxy: + """class decorator which makes all method calls run synchronously""" + + def __init__(self, wrapped): + self.wrapped = wrapped + + def __getattr__(self, item): + attr = getattr(self.wrapped, item) + if not inspect.iscoroutinefunction(attr): + return attr + + def run_sync(*args, **kwargs): + return asyncio.run(attr(*args, **kwargs)) + + return run_sync + + def __setattr__(self, key: str, value): + if key == "wrapped": + super(SyncProxy, self).__setattr__(key, value) + + return setattr(self.wrapped, key, value) diff --git a/tests/lib/pipes.py b/tests/lib/pipes.py index 44e1db36a..53cd4501b 100644 --- a/tests/lib/pipes.py +++ b/tests/lib/pipes.py @@ -77,6 +77,7 @@ def push_event(fixture: Fixture, event, force: bool = False): ): raise AssertionError(f"Fixture {fixture.path} cannot send {event}") logger.info("Simulating %s for %s", event, fixture.path) + logger.info(f"Pipe: {pending_events[fixture]}") pending_events[fixture][0].send(event) diff --git a/tests/lib/stuff.py b/tests/lib/stuff.py index ccc07cc67..9d20f33a5 100644 --- a/tests/lib/stuff.py +++ b/tests/lib/stuff.py @@ -32,7 +32,7 @@ def convert_to_internal_events(events): def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" - return patch.object(obj, name, wraps=obj.__getattribute__(name)) + return patch.object(obj, name, wraps=getattr(obj, name)) environ_copy = copy.deepcopy(os.environ) diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py index db7cde9fb..1c2d4e376 100644 --- a/tests/unit/test_control.py +++ b/tests/unit/test_control.py @@ -21,7 +21,7 @@ """Testing the input-remapper-control command""" - +from tests.lib.patches import SyncProxy from tests.lib.cleanup import quick_cleanup from tests.lib.tmp import tmp @@ -65,7 +65,7 @@ def import_control(): ) -class TestControl(unittest.TestCase): +class TestControl(unittest.IsolatedAsyncioTestCase): def tearDown(self): quick_cleanup() @@ -84,6 +84,7 @@ def test_autoload(self): Preset(paths[2]).save() daemon = Daemon() + sync_daemon = SyncProxy(daemon) start_history = [] stop_counter = 0 @@ -93,7 +94,7 @@ def stop_injecting(self, *args, **kwargs): nonlocal stop_counter stop_counter += 1 - def start_injecting(device: str, preset: str): + async def start_injecting(device: str, preset: str): print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m') start_history.append((device, preset)) daemon.injectors[device] = Injector() @@ -103,7 +104,9 @@ def start_injecting(device: str, preset: str): global_config.set_autoload_preset(groups_[0].key, presets[0]) global_config.set_autoload_preset(groups_[1].key, presets[1]) - communicate(options("autoload", None, None, None, False, False, False), daemon) + communicate( + options("autoload", None, None, None, False, False, False), sync_daemon + ) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) @@ -117,7 +120,9 @@ def start_injecting(device: str, preset: str): ) # calling autoload again doesn't load redundantly - communicate(options("autoload", None, None, None, False, False, False), daemon) + communicate( + options("autoload", None, None, None, False, False, False), sync_daemon + ) self.assertEqual(len(start_history), 2) self.assertEqual(stop_counter, 0) self.assertFalse( @@ -130,7 +135,7 @@ def start_injecting(device: str, preset: str): # unless the injection in question ist stopped communicate( options("stop", None, None, groups_[0].key, False, False, False), - daemon, + sync_daemon, ) self.assertEqual(stop_counter, 1) self.assertTrue( @@ -139,7 +144,9 @@ def start_injecting(device: str, preset: str): self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) - communicate(options("autoload", None, None, None, False, False, False), daemon) + communicate( + options("autoload", None, None, None, False, False, False), sync_daemon + ) self.assertEqual(len(start_history), 3) self.assertEqual(start_history[2], (groups_[0].key, presets[0])) self.assertFalse( @@ -150,7 +157,9 @@ def start_injecting(device: str, preset: str): ) # if a device name is passed, will only start injecting for that one - communicate(options("stop-all", None, None, None, False, False, False), daemon) + communicate( + options("stop-all", None, None, None, False, False, False), sync_daemon + ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) @@ -161,7 +170,7 @@ def start_injecting(device: str, preset: str): global_config.set_autoload_preset(groups_[1].key, presets[2]) communicate( options("autoload", None, None, groups_[1].key, False, False, False), - daemon, + sync_daemon, ) self.assertEqual(len(start_history), 4) self.assertEqual(start_history[3], (groups_[1].key, presets[2])) @@ -176,7 +185,7 @@ def start_injecting(device: str, preset: str): # again communicate( options("autoload", None, None, groups_[1].key, False, False, False), - daemon, + sync_daemon, ) self.assertEqual(len(start_history), 4) self.assertEqual(stop_counter, 3) @@ -210,9 +219,14 @@ def test_autoload_other_path(self): Preset(paths[1]).save() daemon = Daemon() + sync_daemon = SyncProxy(daemon) start_history = [] - daemon.start_injecting = lambda *args: start_history.append(args) + + async def start_injecting(*args): + start_history.append(args) + + daemon.start_injecting = start_injecting global_config.path = os.path.join(config_dir, "config.json") global_config.load_config() @@ -221,7 +235,7 @@ def test_autoload_other_path(self): communicate( options("autoload", config_dir, None, None, False, False, False), - daemon, + sync_daemon, ) self.assertEqual(len(start_history), 2) diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index d55f5182a..b4706b3df 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -17,6 +17,8 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . +import asyncio + from pydantic import ValidationError from tests.lib.fixtures import new_event @@ -135,7 +137,7 @@ def test_grab(self): # success on the third try self.assertEqual(device.name, fixtures[path].name) - def test_fail_grab(self): + async def test_fail_grab(self): self.make_it_fail = 999 preset = Preset() preset.add( @@ -154,11 +156,10 @@ def test_fail_grab(self): self.assertGreaterEqual(self.failed, 1) self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) - self.injector.start() - self.assertEqual(self.injector.get_state(), InjectorState.STARTING) + asyncio.ensure_future(self.injector.run()) # since none can be grabbed, the process will terminate. But that # actually takes quite some time. - time.sleep(self.injector.regrab_timeout * 12) + await asyncio.sleep(self.injector.regrab_timeout * 12) self.assertFalse(self.injector.is_alive()) self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB) @@ -339,7 +340,7 @@ def test_capabilities_and_uinput_presence(self, ungrab_patch): self.assertEqual(ungrab_patch.call_count, 2) - def test_injector(self): + async def test_injector(self): numlock_before = is_numlock_on() # stuff the preset outputs @@ -405,12 +406,11 @@ def test_injector(self): self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) - self.injector.start() - self.assertEqual(self.injector.get_state(), InjectorState.STARTING) - + asyncio.ensure_future(self.injector.run()) + await asyncio.sleep(0) uinput_write_history_pipe[0].poll(timeout=1) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) - time.sleep(EVENT_READ_TIMEOUT * 10) + await asyncio.sleep(EVENT_READ_TIMEOUT * 10) push_events( fixtures.foo_device_2_keyboard, @@ -423,7 +423,7 @@ def test_injector(self): ], ) - time.sleep(0.1) # give a chance that everything arrives in order + await asyncio.sleep(0.1) # give a chance that everything arrives in order push_events( fixtures.foo_device_2_gamepad, [ @@ -433,7 +433,7 @@ def test_injector(self): ], ) - time.sleep(0.1) + await asyncio.sleep(0.1) push_events( fixtures.foo_device_2_keyboard, [ @@ -446,7 +446,7 @@ def test_injector(self): ) # the injector needs time to process this - time.sleep(0.1) + await asyncio.sleep(0.1) # sending anything arbitrary does not stop the process # (is_alive checked later after some time) @@ -503,7 +503,7 @@ def test_injector(self): self.assertEqual(history[4], (EV_KEY, input_b, 0)) self.assertEqual(history[5], (3124, 3564, 6542)) - time.sleep(0.1) + await asyncio.sleep(0.1) self.assertTrue(self.injector.is_alive()) numlock_after = is_numlock_on() From a3b2631412eaef136ebd5bff4fe03c22e8bc5d72 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Tue, 10 Jan 2023 15:45:28 +0100 Subject: [PATCH 09/14] run test_daemon and test_injector async --- tests/unit/test_daemon.py | 70 ++++++++++++++++++++----------------- tests/unit/test_injector.py | 27 +++++++------- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index edd40f31b..799dca5d0 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -17,7 +17,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +import asyncio from tests.test import is_service_running from tests.lib.logger import logger @@ -108,7 +108,7 @@ def set_config_dir(self, *args, **kwargs): self.assertIsInstance(Daemon.connect(False), FakeConnection) self.assertEqual(set_config_dir_callcount, 2) - def test_daemon(self): + async def test_daemon(self): # remove the existing system mapping to force our own into it if os.path.exists(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) @@ -151,24 +151,27 @@ def test_daemon(self): self.assertNotIn("keyboard", global_uinputs.devices) logger.info(f"start injector for {group.key}") - self.daemon.start_injecting(group.key, preset_name) + await self.daemon.start_injecting(group.key, preset_name) + await asyncio.sleep(0.1) # created on demand self.assertIn("keyboard", global_uinputs.devices) self.assertNotIn("gamepad", global_uinputs.devices) - self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN) + self.assertTrue( + uinput_write_history_pipe[0].poll() + ) # needed to not block forever event = uinput_write_history_pipe[0].recv() - self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) self.assertEqual(event.type, EV_KEY) self.assertEqual(event.code, BTN_B) self.assertEqual(event.value, 1) logger.info(f"stopping injector for {group.key}") self.daemon.stop_injecting(group.key) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertEqual(self.daemon.get_state(group.key), InjectorState.STOPPED) try: @@ -180,12 +183,12 @@ def test_daemon(self): """Injection 2""" logger.info(f"start injector for {group.key}") - self.daemon.start_injecting(group.key, preset_name) + await self.daemon.start_injecting(group.key, preset_name) - time.sleep(0.1) + await asyncio.sleep(0.1) # -1234 will be classified as -1 by the injector push_events(fixtures.gamepad, [new_event(*ev, -1234)]) - time.sleep(0.1) + await asyncio.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) @@ -208,7 +211,7 @@ def test_config_dir(self): self.assertEqual(self.daemon.config_dir, get_config_path()) self.assertIsNone(global_config.get("foo")) - def test_refresh_on_start(self): + async def test_refresh_on_start(self): if os.path.exists(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) @@ -253,24 +256,24 @@ def test_refresh_on_start(self): "name": group_name, } push_events(fixtures[self.new_fixture_path], [new_event(*ev, 1)]) - self.daemon.start_injecting(group_key, preset_name) + await self.daemon.start_injecting(group_key, preset_name) # test if the injector called groups.refresh successfully group = groups.find(key=group_key) self.assertEqual(group.name, group_name) self.assertEqual(group.key, group_key) - time.sleep(0.1) + await asyncio.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.t, (EV_KEY, KEY_A, 1)) self.daemon.stop_injecting(group_key) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertEqual(self.daemon.get_state(group_key), InjectorState.STOPPED) - def test_refresh_for_unknown_key(self): + async def test_refresh_for_unknown_key(self): device = "9876 name" # this test only makes sense if this device is unknown yet self.assertIsNone(groups.find(name=device)) @@ -280,7 +283,7 @@ def test_refresh_for_unknown_key(self): # make sure the devices are populated groups.refresh() - self.daemon.refresh() + await self.daemon.refresh() fixtures[self.new_fixture_path] = { "capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]}, @@ -289,13 +292,13 @@ def test_refresh_for_unknown_key(self): "name": device, } - self.daemon._autoload("25v7j9q4vtj") + await self.daemon._autoload("25v7j9q4vtj") # this is unknown, so the daemon will scan the devices again # test if the injector called groups.refresh successfully self.assertIsNotNone(groups.find(name=device)) - def test_xmodmap_file(self): + async def test_xmodmap_file(self): from_keycode = evdev.ecodes.KEY_A target = "keyboard" to_name = "q" @@ -335,9 +338,9 @@ def test_xmodmap_file(self): self.daemon = Daemon() self.daemon.set_config_dir(config_dir) - self.daemon.start_injecting(group.key, preset_name) + await self.daemon.start_injecting(group.key, preset_name) - time.sleep(0.1) + await asyncio.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) event = uinput_write_history_pipe[0].recv() @@ -345,7 +348,7 @@ def test_xmodmap_file(self): self.assertEqual(event.code, to_keycode) self.assertEqual(event.value, 1) - def test_start_stop(self): + async def test_start_stop(self): group_key = "Qux/Device?" group = groups.find(key=group_key) preset_name = "preset8" @@ -364,7 +367,7 @@ def test_start_stop(self): pereset.save() # start - daemon.start_injecting(group_key, preset_name) + await daemon.start_injecting(group_key, preset_name) # explicit start, not autoload, so the history stays empty self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) @@ -374,11 +377,11 @@ def test_start_stop(self): # start again previous_injector = daemon.injectors[group_key] self.assertNotEqual(previous_injector.get_state(), InjectorState.STOPPED) - daemon.start_injecting(group_key, preset_name) + await daemon.start_injecting(group_key, preset_name) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) self.assertIn(group_key, daemon.injectors) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertEqual(previous_injector.get_state(), InjectorState.STOPPED) # a different injetor is now running self.assertNotEqual(previous_injector, daemon.injectors[group_key]) @@ -389,7 +392,7 @@ def test_start_stop(self): # trying to inject a non existing preset keeps the previous inejction # alive injector = daemon.injectors[group_key] - daemon.start_injecting(group_key, "qux") + await daemon.start_injecting(group_key, "qux") self.assertEqual(injector, daemon.injectors[group_key]) self.assertNotEqual( daemon.injectors[group_key].get_state(), InjectorState.STOPPED @@ -397,7 +400,7 @@ def test_start_stop(self): # trying to start injecting for an unknown device also just does # nothing - daemon.start_injecting("quux", "qux") + await daemon.start_injecting("quux", "qux") self.assertNotEqual( daemon.injectors[group_key].get_state(), InjectorState.STOPPED ) @@ -408,12 +411,12 @@ def test_start_stop(self): # stop daemon.stop_injecting(group_key) - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertEqual(daemon.injectors[group_key].get_state(), InjectorState.STOPPED) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) - def test_autoload(self): + async def test_autoload(self): preset_name = "preset7" group_key = "Qux/Device?" group = groups.find(key=group_key) @@ -439,7 +442,7 @@ def test_autoload(self): global_config.set_autoload_preset(group_key, preset_name) len_before = len(self.daemon.autoload_history._autoload_history) # now autoloading is configured, so it will autoload - self.daemon._autoload(group_key) + await self.daemon._autoload(group_key) len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual( daemon.autoload_history._autoload_history[group_key][1], preset_name @@ -472,7 +475,7 @@ def test_autoload(self): len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual(len_before, len_after) - def test_autoload_2(self): + async def test_autoload_2(self): self.daemon = Daemon() history = self.daemon.autoload_history._autoload_history @@ -493,11 +496,11 @@ def test_autoload_2(self): # ignored, won't cause problems: global_config.set_autoload_preset("non-existant-key", "foo") - self.daemon.autoload() + await self.daemon.autoload() self.assertEqual(len(history), 1) self.assertEqual(history[group.key][1], preset_name) - def test_autoload_3(self): + async def test_autoload_3(self): # based on a bug preset_name = "preset7" group = groups.find(key="Foo Device 2") @@ -517,13 +520,14 @@ def test_autoload_3(self): self.daemon = Daemon() groups.set_groups([]) # caused the bug self.assertIsNone(groups.find(key="Foo Device 2")) - self.daemon.autoload() + await self.daemon.autoload() + await asyncio.sleep(0.1) # it should try to refresh the groups because all the # group_keys are unknown at the moment history = self.daemon.autoload_history._autoload_history self.assertEqual(history[group.key][1], preset_name) - self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) self.assertIsNotNone(groups.find(key="Foo Device 2")) diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index b4706b3df..ea307b46e 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -81,11 +81,10 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): - cls.injector = None cls.grab = evdev.InputDevice.grab - quick_cleanup() def setUp(self): + self.injector = None self.failed = 0 self.make_it_fail = 2 @@ -97,9 +96,12 @@ def grab_fail_twice(_): evdev.InputDevice.grab = grab_fail_twice def tearDown(self): + quick_cleanup() + + async def asyncTearDown(self): if self.injector is not None and self.injector.is_alive(): self.injector.stop_injecting() - time.sleep(0.2) + await asyncio.sleep(0.2) self.assertIn( self.injector.get_state(), (InjectorState.STOPPED, InjectorState.FAILED, InjectorState.NO_GRAB), @@ -107,8 +109,6 @@ def tearDown(self): self.injector = None evdev.InputDevice.grab = self.grab - quick_cleanup() - def initialize_injector(self, group, preset: Preset): self.injector = Injector(group, preset) self.injector._devices = self.injector.group.get_devices() @@ -126,7 +126,7 @@ def test_grab(self): ) ) - self.injector = Injector(groups.find(key="Foo Device 2"), preset) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) # this test needs to pass around all other constraints of # _grab_device self.injector.context = Context(preset) @@ -148,7 +148,7 @@ async def test_fail_grab(self): ) ) - self.injector = Injector(groups.find(key="Foo Device 2"), preset) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) path = "/dev/input/event10" self.injector.context = Context(preset) device = self.injector._grab_device(evdev.InputDevice(path)) @@ -245,7 +245,7 @@ def test_skip_unknown_device(self): self.assertEqual(devices, []) def test_get_udev_name(self): - self.injector = Injector(groups.find(key="Foo Device 2"), Preset()) + self.initialize_injector(groups.find(key="Foo Device 2"), Preset()) suffix = "mapped" prefix = "input-remapper" expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' @@ -259,7 +259,7 @@ def test_get_udev_name(self): ) @mock.patch("evdev.InputDevice.ungrab") - def test_capabilities_and_uinput_presence(self, ungrab_patch): + async def test_capabilities_and_uinput_presence(self, ungrab_patch): preset = Preset() m1 = get_key_mapping( InputCombination( @@ -286,9 +286,10 @@ def test_capabilities_and_uinput_presence(self, ungrab_patch): ) preset.add(m1) preset.add(m2) - self.injector = Injector(groups.find(key="Foo Device 2"), preset) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) self.injector.stop_injecting() - self.injector.run() + asyncio.ensure_future(self.injector.run()) + await asyncio.sleep(0.5) self.assertEqual( self.injector.preset.get_mapping( @@ -404,10 +405,10 @@ async def test_injector(self): ) ) - self.injector = Injector(groups.find(key="Foo Device 2"), preset) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) asyncio.ensure_future(self.injector.run()) - await asyncio.sleep(0) + await asyncio.sleep(0.1) uinput_write_history_pipe[0].poll(timeout=1) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) await asyncio.sleep(EVENT_READ_TIMEOUT * 10) From 757a6fa484483937f0567de9cbe943f982b98af1 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Tue, 10 Jan 2023 16:46:26 +0100 Subject: [PATCH 10/14] add dbus-next to ci_install_deps.sh --- scripts/ci-install-deps.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-install-deps.sh b/scripts/ci-install-deps.sh index 6d0b6922b..10dbcfff6 100755 --- a/scripts/ci-install-deps.sh +++ b/scripts/ci-install-deps.sh @@ -11,4 +11,4 @@ python -m pip install --upgrade pip python -m pip install --upgrade --force-reinstall wheel setuptools # install test deps which aren't in setup.py -python -m pip install psutil pylint-pydantic +python -m pip install psutil pylint-pydantic dbus-next From 32b731891efa30c109bf140a4073c0da59ef4876 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Tue, 10 Jan 2023 21:30:55 +0100 Subject: [PATCH 11/14] remove SharedDict --- inputremapper/daemon.py | 3 - inputremapper/injection/macros/macro.py | 7 +- inputremapper/ipc/shared_dict.py | 122 ------------------------ tests/lib/cleanup.py | 12 +-- tests/unit/test_ipc.py | 20 ---- tests/unit/test_macros.py | 40 +------- 6 files changed, 6 insertions(+), 198 deletions(-) delete mode 100644 inputremapper/ipc/shared_dict.py diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 31d127322..ee034ab9d 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -198,9 +198,6 @@ def __init__(self): atexit.register(self.stop_all) - # initialize stuff that is needed alongside the daemon process - macro_variables.start() - @classmethod def connect(cls, fallback: bool = True) -> DaemonProxy: """Get an interface to start and stop injecting keystrokes. diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index e1f6c7a98..c03093287 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -40,6 +40,7 @@ import copy import math import re +from collections import defaultdict from typing import List, Callable, Awaitable, Tuple, Optional, Union, Any from evdev.ecodes import ( @@ -56,13 +57,13 @@ from inputremapper.configs.system_mapping import system_mapping from inputremapper.exceptions import MacroParsingError -from inputremapper.ipc.shared_dict import SharedDict from inputremapper.logger import logger Handler = Callable[[Tuple[int, int, int]], None] MacroTask = Callable[[Handler], Awaitable] -macro_variables = SharedDict() +# global dict object used by macros to share variable across all injectors +macro_variables = defaultdict(None) class Variable: @@ -550,7 +551,7 @@ def add_add(self, variable: str, value: Union[int, float]): _type_check(value, [int, float], "value", 1) async def task(_): - current = macro_variables[variable] + current = macro_variables.get(variable) if current is None: logger.debug('"%s" initialized with 0', variable) macro_variables[variable] = 0 diff --git a/inputremapper/ipc/shared_dict.py b/inputremapper/ipc/shared_dict.py deleted file mode 100644 index 9b9b8060b..000000000 --- a/inputremapper/ipc/shared_dict.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# This file is part of input-remapper. -# -# input-remapper is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# input-remapper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with input-remapper. If not, see . - - -"""Share a dictionary across processes.""" - - -import atexit -import multiprocessing -import select -from typing import Optional, Any - -from inputremapper.logger import logger - - -class SharedDict: - """Share a dictionary across processes.""" - - # because unittests terminate all child processes in cleanup I can't use - # multiprocessing.Manager - def __init__(self): - """Create a shared dictionary.""" - super().__init__() - - # To avoid blocking forever if something goes wrong. The maximum - # observed time communication takes was 0.001 for me on a slow pc - self._timeout = 0.02 - - self.pipe = multiprocessing.Pipe() - self.process = None - atexit.register(self._stop) - - def start(self): - """Ensure the process to manage the dictionary is running.""" - if self.process is not None and self.process.is_alive(): - logger.debug("SharedDict process already running") - return - - # if the manager has already been running in the past but stopped - # for some reason, the dictionary contents are lost. - logger.debug("Starting SharedDict process") - self.process = multiprocessing.Process(target=self.manage) - self.process.start() - - def manage(self): - """Manage the dictionary, handle read and write requests.""" - logger.debug("SharedDict process started") - shared_dict = {} - while True: - message = self.pipe[0].recv() - logger.debug("SharedDict got %s", message) - - if message[0] == "stop": - return - - if message[0] == "set": - shared_dict[message[1]] = message[2] - - if message[0] == "clear": - shared_dict.clear() - - if message[0] == "get": - self.pipe[0].send(shared_dict.get(message[1])) - - if message[0] == "ping": - self.pipe[0].send("pong") - - def _stop(self): - """Stop the managing process.""" - self.pipe[1].send(("stop",)) - - def _clear(self): - """Clears the memory.""" - self.pipe[1].send(("clear",)) - - def get(self, key: str): - """Get a value from the dictionary. - - If it doesn't exist, returns None. - """ - return self[key] - - def is_alive(self, timeout: Optional[int] = None): - """Check if the manager process is running.""" - self.pipe[1].send(("ping",)) - select.select([self.pipe[1]], [], [], timeout or self._timeout) - if self.pipe[1].poll(): - return self.pipe[1].recv() == "pong" - - return False - - def __setitem__(self, key: str, value: Any): - self.pipe[1].send(("set", key, value)) - - def __getitem__(self, key: str): - self.pipe[1].send(("get", key)) - - select.select([self.pipe[1]], [], [], self._timeout) - if self.pipe[1].poll(): - return self.pipe[1].recv() - - logger.error("select.select timed out") - return None - - def __del__(self): - self._stop() diff --git a/tests/lib/cleanup.py b/tests/lib/cleanup.py index e33aa155c..395a78bd1 100644 --- a/tests/lib/cleanup.py +++ b/tests/lib/cleanup.py @@ -107,17 +107,8 @@ def quick_cleanup(log=True): # create a fresh event loop asyncio.set_event_loop(asyncio.new_event_loop()) - if macro_variables.process is not None and not macro_variables.process.is_alive(): - # nothing should stop the process during runtime, if it has been started by - # the injector once - raise AssertionError("the SharedDict manager is not running anymore") - - if macro_variables.process is not None: - macro_variables._stop() - join_children() - - macro_variables.start() + macro_variables.clear() if os.path.exists(tmp): shutil.rmtree(tmp) @@ -146,7 +137,6 @@ def quick_cleanup(log=True): for _, pipe in pending_events.values(): assert not pipe.poll() - assert macro_variables.is_alive(1) for uinput in global_uinputs.devices.values(): uinput.write_count = 0 uinput.write_history = [] diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py index 4a6127a83..544dd091f 100644 --- a/tests/unit/test_ipc.py +++ b/tests/unit/test_ipc.py @@ -29,29 +29,9 @@ import os from inputremapper.ipc.pipe import Pipe -from inputremapper.ipc.shared_dict import SharedDict from inputremapper.ipc.socket import Server, Client, Base -class TestSharedDict(unittest.TestCase): - def setUp(self): - self.shared_dict = SharedDict() - self.shared_dict.start() - time.sleep(0.02) - - def tearDown(self): - quick_cleanup() - - def test_returns_none(self): - self.assertIsNone(self.shared_dict.get("a")) - self.assertIsNone(self.shared_dict["a"]) - - def test_set_get(self): - self.shared_dict["a"] = 3 - self.assertEqual(self.shared_dict.get("a"), 3) - self.assertEqual(self.shared_dict["a"], 3) - - class TestSocket(unittest.TestCase): def test_socket(self): def test(s1, s2): diff --git a/tests/unit/test_macros.py b/tests/unit/test_macros.py index 965d0406d..fd90bd42a 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -1196,7 +1196,7 @@ async def test(macro, expected): """Run the macro and compare the injections with an expectation.""" logger.info("Testing %s", macro) # cleanup - macro_variables._clear() + macro_variables.clear() self.assertIsNone(macro_variables.get("a")) self.result.clear() @@ -1238,44 +1238,6 @@ async def test(macro, expected): await test('set(a, "1").if_eq($a, 1, key(a), key(b))', b_press) await test('set(a, 1).if_eq($a, "1", key(a), key(b))', b_press) - async def test_if_eq_runs_multiprocessed(self): - """ifeq on variables that have been set in other processes works.""" - macro = parse("if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping) - code_a = system_mapping.get("a") - code_b = system_mapping.get("b") - - self.assertEqual(len(macro.child_macros), 2) - - def set_foo(value): - # will write foo = 2 into the shared dictionary of macros - macro_2 = parse(f"set(foo, {value})", self.context, DummyMapping) - loop = asyncio.new_event_loop() - loop.run_until_complete(macro_2.run(lambda: None)) - - """foo is not 3""" - - process = multiprocessing.Process(target=set_foo, args=(2,)) - process.start() - process.join() - await macro.run(self.handler) - self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) - - """foo is 3""" - - process = multiprocessing.Process(target=set_foo, args=(3,)) - process.start() - process.join() - await macro.run(self.handler) - self.assertListEqual( - self.result, - [ - (EV_KEY, code_b, 1), - (EV_KEY, code_b, 0), - (EV_KEY, code_a, 1), - (EV_KEY, code_a, 0), - ], - ) - class TestIfSingle(MacroTestBase): async def test_if_single(self): From e420c14d82b5efa32260e334061a349f28536ba6 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Tue, 10 Jan 2023 22:17:36 +0100 Subject: [PATCH 12/14] exit gracefully --- inputremapper/daemon.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index ee034ab9d..2b6048914 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -23,9 +23,7 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/examples/clientserver # noqa pylint: disable=line-too-long """ import asyncio -import atexit -import functools -import inspect +import signal import json import os import sys @@ -51,7 +49,6 @@ from inputremapper.configs.system_mapping import system_mapping from inputremapper.groups import groups from inputremapper.configs.paths import get_config_path, sanitize_path_component, USER -from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import global_uinputs tracemalloc.start() @@ -183,6 +180,8 @@ def __init__(self): logger.debug("Creating daemon") super().__init__(INTERFACE_NAME) self.injectors: Dict[str, Injector] = {} + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._bus: Optional[MessageBus] = None self.config_dir = None @@ -196,7 +195,7 @@ def __init__(self): self.autoload_history = AutoloadHistory() self.refreshed_devices_at = 0 - atexit.register(self.stop_all) + signal.signal(signal.SIGINT, self.quit) @classmethod def connect(cls, fallback: bool = True) -> DaemonProxy: @@ -254,10 +253,10 @@ def connect(cls, fallback: bool = True) -> DaemonProxy: def run(self): """Start the event loop and publish the daemon. Blocks until the daemon stops.""" - loop = asyncio.get_event_loop() + self._loop = loop = asyncio.get_event_loop() async def task(): - bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + self._bus = bus = await MessageBus(bus_type=BusType.SYSTEM).connect() bus.export(path=PATH_NAME, interface=self) if RequestNameReply.PRIMARY_OWNER != await bus.request_name(BUS_NAME): logger.error("Is the service already running?") @@ -267,6 +266,19 @@ async def task(): logger.debug("Running daemon") loop.run_forever() + def quit(self, *_): + self.stop_all() + self._bus.unexport(path=PATH_NAME, interface=self) + + async def stop_later(): + # give all injections time to reset uinputs + await asyncio.sleep(0.2) + + # loop.run_forever will return + self._loop.stop() + + asyncio.ensure_future(stop_later()) + async def refresh(self, group_key: Optional[str] = None): """Refresh groups if the specified group is unknown. From dfef51d0a8fdcdd38c7bd7b39c6476e1509b30c1 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Wed, 11 Jan 2023 21:03:49 +0100 Subject: [PATCH 13/14] optimize UInput.can_emit() --- inputremapper/injection/global_uinputs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index a68463f87..4fd2abe14 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -65,12 +65,16 @@ def __init__(self, *args, **kwargs): logger.debug('creating UInput device: "%s"', name) super().__init__(*args, **kwargs) + # this will never change, so we cache it since evdev runs an expensive loop to + # gather the capabilities. (can_emit is called regularly) + self._capabilities_cache = self.capabilities(absinfo=False) + def can_emit(self, event: Tuple[int, int, int]): """Check if an event can be emitted by the UIinput. Wrong events might be injected if the group mappings are wrong, """ - return event[1] in self.capabilities(absinfo=False).get(event[0], []) + return event[1] in self._capabilities_cache.get(event[0], []) class FrontendUInput: From 49a1b8fbb076e40abfa4692e3ed1fb81ddf46509 Mon Sep 17 00:00:00 2001 From: Jonas Bosse Date: Thu, 12 Jan 2023 00:03:21 +0100 Subject: [PATCH 14/14] simplyfied injector --- inputremapper/daemon.py | 5 +- inputremapper/gui/controller.py | 3 +- inputremapper/injection/injector.py | 191 +++++++--------------------- tests/unit/test_daemon.py | 2 +- tests/unit/test_injector.py | 29 ++--- 5 files changed, 63 insertions(+), 167 deletions(-) diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 2b6048914..095fb3033 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -320,7 +320,8 @@ def stop_injecting(self, group_key: "s"): def get_state(self, group_key: "s") -> "s": """Get the injectors state.""" injector = self.injectors.get(group_key) - return injector.get_state() if injector else InjectorState.UNKNOWN + # if there is no injector it surely is stopped + return injector.get_state() if injector else InjectorState.STOPPED @method() def set_config_dir(self, config_dir: "s"): @@ -516,7 +517,7 @@ async def start_injecting(self, group_key: "s", preset: "s") -> "b": try: injector = Injector(group, preset) - asyncio.create_task(injector.run()) + await injector.start_injecting() self.injectors[group.key] = injector except OSError: # I think this will never happen, probably leftover from diff --git a/inputremapper/gui/controller.py b/inputremapper/gui/controller.py index b9383d31c..ca822b05f 100644 --- a/inputremapper/gui/controller.py +++ b/inputremapper/gui/controller.py @@ -32,7 +32,6 @@ Any, ) -import gi from evdev.ecodes import EV_KEY, EV_REL, EV_ABS from gi.repository import Gtk @@ -490,7 +489,7 @@ def start_key_recording(self): Updates the active_mapping.input_combination with the recorded events. """ state = self.data_manager.get_state() - if state == InjectorState.RUNNING or state == InjectorState.STARTING: + if state == InjectorState.RUNNING: self.data_manager.stop_combination_recording() self.message_broker.signal(MessageType.recording_finished) self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing')) diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index ff0c7069f..66264d3d2 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -23,13 +23,11 @@ import asyncio import enum -import multiprocessing import sys import time from collections import defaultdict from dataclasses import dataclass -from multiprocessing.connection import Connection -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Union import evdev @@ -60,11 +58,11 @@ class InjectorCommand(str, enum.Enum): # messages the injector process reports back to the service class InjectorState(str, enum.Enum): - UNKNOWN = "UNKNOWN" - STARTING = "STARTING" - FAILED = "FAILED" + """Possible States of the Injector.""" + RUNNING = "RUNNING" STOPPED = "STOPPED" + FAILED = "FAILED" NO_GRAB = "NO_GRAB" UPGRADE_EVDEV = "UPGRADE_EVDEV" @@ -95,29 +93,22 @@ class InjectorStateMessage: state: Union[InjectorState] def active(self) -> bool: - return self.state in [InjectorState.RUNNING, InjectorState.STARTING] + return self.state == InjectorState.RUNNING def inactive(self) -> bool: return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB] class Injector: - """Initializes, starts and stops injections. - - Is a process to make it non-blocking for the rest of the code and to - make running multiple injector easier. There is one process per - hardware-device that is being mapped. - """ + """Manages the Injection for one Preset""" group: _Group preset: Preset context: Optional[Context] - _alive: bool _devices: List[evdev.InputDevice] _state: InjectorState - _msg_pipe: Tuple[Connection, Connection] - _event_readers: List[EventReader] _stop_event: asyncio.Event + _injector_task: Optional[asyncio.Task] # the _run() task if the injector is running regrab_timeout = 0.2 @@ -130,66 +121,43 @@ def __init__(self, group: _Group, preset: Preset) -> None: the device group """ self.group = group - self._alive = False - self._state = InjectorState.UNKNOWN - - # used to interact with the parts of this class that are running within - # the new process - self._msg_pipe = multiprocessing.Pipe() - self.preset = preset - self.context = None # only needed inside the injection process - self._event_readers = [] + self._devices = self.group.get_devices() + self._stop_event = asyncio.Event() + self._injector_task = None + + # InputConfigs may not contain the origin_hash information, this will try to + # make a good guess if the origin_hash information is missing or invalid. + self._update_preset() + self.context = Context(self.preset) # must be after _update_preset - """Functions to interact with the running process.""" + # the injector starts stopped + self._state = InjectorState.STOPPED def get_state(self) -> InjectorState: - """Get the state of the injection. + """Get the state of the injection.""" + return self._state - Can be safely called from the main process. - """ - # before we try to we try to guess anything lets check if there is a message - state = self._state - while self._msg_pipe[1].poll(): - state = self._msg_pipe[1].recv() - - # figure out what is going on step by step - alive = self._alive - - # if `self.start()` has been called - started = state != InjectorState.UNKNOWN or alive - - if started: - if state == InjectorState.UNKNOWN and alive: - # if it is alive, it is definitely at least starting up. - state = InjectorState.STARTING - - if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive: - # we thought it is running (maybe it was when get_state was previously), - # but the process is not alive. It probably crashed - state = InjectorState.FAILED - logger.error("Injector was unexpectedly found stopped") - - logger.debug( - 'Injector state of "%s", "%s": %s', - self.group.key, - self.preset.name, - state, + async def start_injecting(self) -> None: + """Start the Injector. + + Schedules the Injector coroutine in the event loop""" + setup_done = asyncio.Event() + self._injector_task = asyncio.create_task(self._run(setup_done)) + await asyncio.wait( + [setup_done.wait(), self._injector_task], + return_when=asyncio.FIRST_COMPLETED, ) - self._state = state - return self._state @ensure_numlock def stop_injecting(self) -> None: - """Stop injecting keycodes. - - Can be safely called from the main procss. - """ - logger.info('Stopping injecting keycodes for group "%s"', self.group.key) - self._msg_pipe[1].send(InjectorCommand.CLOSE) - - """Process internal stuff.""" + """Stop injecting.""" + logger.info( + f"Stopping the injection of Preset {self.preset.name} " + f"for group {self.group.key}" + ) + self._stop_event.set() def _find_input_device( self, input_config: InputConfig @@ -276,6 +244,7 @@ def _update_preset(self): if not (device := self._find_input_device_fallback(input_config)): # fallback failed, this mapping will be ignored + logger.debug(f"failed to find origin device for {input_config}") continue for mapping in mappings_by_input[input_config]: @@ -331,23 +300,6 @@ def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict: return capabilities - async def _msg_listener(self) -> None: - """Wait for messages from the main process to do special stuff.""" - loop = asyncio.get_event_loop() - while True: - frame_available = asyncio.Event() - loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set) - await frame_available.wait() - frame_available.clear() - msg = self._msg_pipe[0].recv() - if msg == InjectorCommand.CLOSE: - logger.debug("Received close signal") - self._stop_event.set() - # give the event pipeline some time to reset devices - # before shutting the loop down - await asyncio.sleep(0.1) - return - def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: # copy as much information as possible, because libinput uses the extra # information to enable certain features like "Disable touchpad while @@ -371,63 +323,21 @@ def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: # UInput constructor doesn't support input_props and # source.input_props doesn't exist with old python-evdev versions. logger.error("Please upgrade your python-evdev version. Exiting") - self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV) + self._state = InjectorState.UPGRADE_EVDEV sys.exit(12) raise e return forward_to - def is_alive(self) -> bool: - """used in tests, can probably be removed, previously defined by the - multiprocessing.Process superclass""" - return self._alive - - async def run(self) -> None: - self._alive = True - try: - await self._run() - except: - self._alive = False - raise - self._alive = False - - async def _run(self) -> None: - """The injection worker that keeps injecting until terminated. - - Stuff is non-blocking by using asyncio in order to do multiple things - somewhat concurrently. - - Use this function as starting point in a process. It creates - the loops needed to read and map events and keeps running them. - """ + async def _run(self, setup_done: asyncio.Event) -> None: + """The injection worker that keeps injecting until stop_injecting is called.""" logger.info('Starting injecting the preset for "%s"', self.group.key) - # create a new event loop, because somehow running an infinite loop - # that sleeps on iterations (joystick_to_mouse) in one process causes - # another injection process to screw up reading from the grabbed - # device. - # loop = asyncio.new_event_loop() - # asyncio.set_event_loop(loop) - self._devices = self.group.get_devices() - - # InputConfigs may not contain the origin_hash information, this will try to make a - # good guess if the origin_hash information is missing or invalid. - self._update_preset() - # grab devices as early as possible. If events appear that won't get # released anymore before the grab they appear to be held down forever - sources = self._grab_devices() - - # create this within the process after the event loop creation, - # so that the macros use the correct loop - self.context = Context(self.preset) - self._stop_event = asyncio.Event() - - if len(sources) == 0: - # maybe the preset was empty or something + if len(sources := self._grab_devices()) == 0: logger.error("Did not grab any device") - self._msg_pipe[0].send(InjectorState.NO_GRAB) - self._alive = False + self._state = InjectorState.NO_GRAB return numlock_state = is_numlock_on() @@ -443,32 +353,27 @@ async def _run(self) -> None: self._stop_event, ) coroutines.append(event_reader.run()) - self._event_readers.append(event_reader) - - coroutines.append(self._msg_listener()) # set the numlock state to what it was before injecting, because # grabbing devices screws this up set_numlock(numlock_state) - self._msg_pipe[0].send(InjectorState.RUNNING) - try: - await asyncio.gather(*coroutines) + self._state = InjectorState.RUNNING + setup_done.set() + await asyncio.gather(*coroutines) # returns when stop_injecting is called except RuntimeError as error: # the loop might have been stopped via a `CLOSE` message, # which causes the error message below. This is expected behavior if str(error) != "Event loop stopped before Future completed.": + self._state = InjectorState.FAILED raise error except OSError as error: logger.error("Failed to run injector coroutines: %s", str(error)) + self._state = InjectorState.FAILED + return - if len(coroutines) > 0: - # expected when stop_injecting is called, - # during normal operation as well as tests this point is not - # reached otherwise. - logger.debug("Injector coroutines ended") - + logger.debug("Injector coroutines ended") for source in sources: # ungrab at the end to make the next injection process not fail # its grabs @@ -478,4 +383,4 @@ async def _run(self) -> None: # it might have disappeared logger.debug("OSError for ungrab on %s: %s", source.path, str(error)) - self._msg_pipe[0].send(InjectorState.STOPPED) + self._state = InjectorState.STOPPED diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 799dca5d0..e525a995c 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -159,7 +159,7 @@ async def test_daemon(self): self.assertNotIn("gamepad", global_uinputs.devices) self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) - self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN) + self.assertEqual(self.daemon.get_state(group2.key), InjectorState.STOPPED) self.assertTrue( uinput_write_history_pipe[0].poll() diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index ea307b46e..792605aea 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -99,7 +99,10 @@ def tearDown(self): quick_cleanup() async def asyncTearDown(self): - if self.injector is not None and self.injector.is_alive(): + if ( + self.injector is not None + and self.injector.get_state() == InjectorState.RUNNING + ): self.injector.stop_injecting() await asyncio.sleep(0.2) self.assertIn( @@ -111,8 +114,6 @@ async def asyncTearDown(self): def initialize_injector(self, group, preset: Preset): self.injector = Injector(group, preset) - self.injector._devices = self.injector.group.get_devices() - self.injector._update_preset() def test_grab(self): # path is from the fixtures @@ -155,12 +156,10 @@ async def test_fail_grab(self): self.assertIsNone(device) self.assertGreaterEqual(self.failed, 1) - self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) - asyncio.ensure_future(self.injector.run()) + self.assertEqual(self.injector.get_state(), InjectorState.STOPPED) + await self.injector.start_injecting() # since none can be grabbed, the process will terminate. But that # actually takes quite some time. - await asyncio.sleep(self.injector.regrab_timeout * 12) - self.assertFalse(self.injector.is_alive()) self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB) def test_grab_device_1(self): @@ -288,7 +287,7 @@ async def test_capabilities_and_uinput_presence(self, ungrab_patch): preset.add(m2) self.initialize_injector(groups.find(key="Foo Device 2"), preset) self.injector.stop_injecting() - asyncio.ensure_future(self.injector.run()) + await self.injector.start_injecting() await asyncio.sleep(0.5) self.assertEqual( @@ -406,9 +405,8 @@ async def test_injector(self): ) self.initialize_injector(groups.find(key="Foo Device 2"), preset) - self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) - asyncio.ensure_future(self.injector.run()) - await asyncio.sleep(0.1) + self.assertEqual(self.injector.get_state(), InjectorState.STOPPED) + await self.injector.start_injecting() uinput_write_history_pipe[0].poll(timeout=1) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) await asyncio.sleep(EVENT_READ_TIMEOUT * 10) @@ -449,10 +447,6 @@ async def test_injector(self): # the injector needs time to process this await asyncio.sleep(0.1) - # sending anything arbitrary does not stop the process - # (is_alive checked later after some time) - self.injector._msg_pipe[1].send(1234) - # convert the write history to some easier to manage list history = read_write_history_pipe() @@ -505,8 +499,6 @@ async def test_injector(self): self.assertEqual(history[5], (3124, 3564, 6542)) await asyncio.sleep(0.1) - self.assertTrue(self.injector.is_alive()) - numlock_after = is_numlock_on() self.assertEqual(numlock_before, numlock_after) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) @@ -634,7 +626,6 @@ def tearDown(self): def test_copy_capabilities(self): # I don't know what ABS_VOLUME is, for now I would like to just always # remove it until somebody complains, since its presence broke stuff - self.injector = Injector(mock.Mock(), self.preset) self.fake_device._capabilities = { EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], EV_KEY: [1, 2, 3], @@ -643,7 +634,7 @@ def test_copy_capabilities(self): evdev.ecodes.EV_FF: [2], } - capabilities = self.injector._copy_capabilities(self.fake_device) + capabilities = Injector._copy_capabilities(self.fake_device) self.assertNotIn(ABS_VOLUME, capabilities[EV_ABS]) self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) self.assertNotIn(evdev.ecodes.EV_FF, capabilities)