From aa2dbc2fee7d3fc139c53bbb06def30103f66170 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 30 Mar 2026 00:08:31 -0700 Subject: [PATCH] fix(suspend): delay keyboard restart for USB re-enumeration (#372) * fix(suspend): delay keyboard restart to 6s for USB re-enumeration The 2s delay is enough for the speech engine, but USB devices re-enumerate slowly after resume. At T+2s, stale /dev/input nodes still exist and evdev opens them, but the kernel tears them down at ~T+4s, causing another round of disconnections. Split the reinit into two separate timers: - 2s: speech engine reinit (unchanged) - 6s: keyboard backend restart (after USB re-enumeration completes) * fix(suspend): use inotify to detect device settle before keyboard restart Replace hard-coded 6s keyboard restart delay with event-driven Gio.File.monitor_directory() on /dev/input. After resume, a 2s settle timer resets on each inotify event; keyboard shortcuts restart only once devices stop changing. A 10s safety cap and a 6s fallback timer guard against environments where inotify is unavailable. --- src/vocalinux/ui/tray_indicator.py | 88 +++++++++++--- tests/test_suspend_handler.py | 187 ++++++++++++++++++++++++----- 2 files changed, 231 insertions(+), 44 deletions(-) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 915ae89..cd417ba 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -25,7 +25,7 @@ except (ImportError, ValueError): gi.require_version("AyatanaAppindicator3", "0.1") from gi.repository import AyatanaAppindicator3 as AppIndicator3 -from gi.repository import GdkPixbuf, GLib, GObject, Gtk +from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk # Import local modules - Use protocols to avoid circular imports from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol @@ -54,6 +54,11 @@ DEFAULT_ICON = "vocalinux-microphone-off" ACTIVE_ICON = "vocalinux-microphone" PROCESSING_ICON = "vocalinux-microphone-process" +# /dev/input settle-detection tuning (used after resume) +_INPUT_SETTLE_SECONDS = 2 +_INPUT_MONITOR_CAP_SECONDS = 10 +_FALLBACK_KEYBOARD_RESTART_SECONDS = 6 + class TrayIndicator: """ @@ -463,30 +468,79 @@ class TrayIndicator: self.speech_engine.stop_recognition() def _on_system_resume(self): - """Reinitialize the speech engine after the system wakes up. + """Reinitialize subsystems after the system wakes up. - Schedules the actual work with a short delay so the audio - subsystem has time to come back online. + Speech engine reinitializes after 2s (audio hardware recovers fast). + Keyboard backend waits for /dev/input to settle using inotify-backed + directory monitoring, with a timer fallback if monitoring unavailable. """ - logger.info("System resumed — scheduling speech engine reinit") - GLib.timeout_add_seconds(2, self._reinit_after_resume) + logger.info("System resumed — scheduling reinit") + GLib.timeout_add_seconds(2, self._reinit_speech_after_resume) + GLib.timeout_add_seconds(2, self._start_input_device_monitor) - def _reinit_after_resume(self): + def _reinit_speech_after_resume(self): try: self.speech_engine.reinitialize_after_resume() except Exception: logger.error("Failed to reinitialize after resume", exc_info=True) - - # evdev devices disconnect on suspend; monitor thread exits when all fds close. - # Full stop+start re-scans /dev/input and opens fresh file descriptors. - try: - logger.info("Restarting keyboard shortcuts after resume") - self._setup_keyboard_shortcuts() - except Exception: - logger.error("Failed to restart keyboard shortcuts after resume", exc_info=True) - return GLib.SOURCE_REMOVE + def _start_input_device_monitor(self): + """Watch /dev/input for changes; restart keyboard when devices settle.""" + try: + gfile = Gio.File.new_for_path("/dev/input") + self._input_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) + self._input_monitor.connect("changed", self._on_input_device_changed) + + self._settle_timer_id = GLib.timeout_add_seconds( + _INPUT_SETTLE_SECONDS, self._on_devices_settled + ) + self._monitor_timeout_id = GLib.timeout_add_seconds( + _INPUT_MONITOR_CAP_SECONDS, self._on_input_monitor_timeout + ) + logger.info("Monitoring /dev/input for device changes after resume") + except Exception: + logger.error("Failed to monitor /dev/input, falling back to timer", exc_info=True) + GLib.timeout_add_seconds( + _FALLBACK_KEYBOARD_RESTART_SECONDS, self._reinit_keyboard_fallback + ) + return GLib.SOURCE_REMOVE + + def _on_input_device_changed(self, monitor, file, other_file, event_type): + if getattr(self, "_settle_timer_id", None) is not None: + GLib.source_remove(self._settle_timer_id) + self._settle_timer_id = GLib.timeout_add_seconds( + _INPUT_SETTLE_SECONDS, self._on_devices_settled + ) + + def _on_devices_settled(self): + logger.info("Input devices settled — restarting keyboard shortcuts") + self._cleanup_input_monitor() + self._setup_keyboard_shortcuts() + return GLib.SOURCE_REMOVE + + def _on_input_monitor_timeout(self): + logger.warning("Input device monitor timed out — restarting keyboard shortcuts") + self._cleanup_input_monitor() + self._setup_keyboard_shortcuts() + return GLib.SOURCE_REMOVE + + def _reinit_keyboard_fallback(self): + logger.info("Restarting keyboard shortcuts (fallback timer)") + self._setup_keyboard_shortcuts() + return GLib.SOURCE_REMOVE + + def _cleanup_input_monitor(self): + if getattr(self, "_settle_timer_id", None) is not None: + GLib.source_remove(self._settle_timer_id) + self._settle_timer_id = None + if getattr(self, "_monitor_timeout_id", None) is not None: + GLib.source_remove(self._monitor_timeout_id) + self._monitor_timeout_id = None + if getattr(self, "_input_monitor", None) is not None: + self._input_monitor.cancel() + self._input_monitor = None + def _on_quit_clicked(self, widget): """Handle click on the Quit menu item.""" logger.debug("Quit clicked") @@ -499,6 +553,8 @@ class TrayIndicator: if self._suspend_handler is not None: self._suspend_handler.shutdown() + self._cleanup_input_monitor() + # Stop the keyboard shortcut manager self.shortcut_manager.stop() diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py index 1d7c9c3..3addda9 100644 --- a/tests/test_suspend_handler.py +++ b/tests/test_suspend_handler.py @@ -273,7 +273,7 @@ class TestReinitializeAfterResume(unittest.TestCase): class TestTrayIndicatorResumeFlow(unittest.TestCase): - """Tests for TrayIndicator._reinit_after_resume keyboard restart.""" + """Tests for TrayIndicator resume handling.""" def _make_tray_indicator(self): from vocalinux.ui.tray_indicator import TrayIndicator @@ -283,47 +283,178 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase): indicator.speech_engine = MagicMock() indicator._setup_keyboard_shortcuts = MagicMock() + indicator._input_monitor = None + indicator._settle_timer_id = None + indicator._monitor_timeout_id = None return indicator - def test_reinit_after_resume_calls_speech_engine_reinit(self): + def test_reinit_speech_calls_engine_reinit(self): indicator = self._make_tray_indicator() - indicator._reinit_after_resume() + indicator._reinit_speech_after_resume() indicator.speech_engine.reinitialize_after_resume.assert_called_once() - def test_reinit_after_resume_restarts_keyboard_shortcuts(self): - indicator = self._make_tray_indicator() - - indicator._reinit_after_resume() - - indicator._setup_keyboard_shortcuts.assert_called_once() - - def test_keyboard_restart_happens_even_if_speech_reinit_fails(self): - indicator = self._make_tray_indicator() - indicator.speech_engine.reinitialize_after_resume.side_effect = RuntimeError("boom") - - indicator._reinit_after_resume() - - indicator._setup_keyboard_shortcuts.assert_called_once() - - def test_keyboard_restart_failure_does_not_crash(self): - indicator = self._make_tray_indicator() - indicator._setup_keyboard_shortcuts.side_effect = RuntimeError("no devices") - - indicator._reinit_after_resume() - - indicator.speech_engine.reinitialize_after_resume.assert_called_once() - - def test_returns_source_remove(self): + def test_reinit_speech_returns_source_remove(self): from gi.repository import GLib indicator = self._make_tray_indicator() - result = indicator._reinit_after_resume() + result = indicator._reinit_speech_after_resume() assert result == GLib.SOURCE_REMOVE + def test_reinit_speech_failure_is_caught(self): + indicator = self._make_tray_indicator() + indicator.speech_engine.reinitialize_after_resume.side_effect = RuntimeError("boom") + + indicator._reinit_speech_after_resume() + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_on_system_resume_schedules_speech_and_monitor(self, mock_glib): + indicator = self._make_tray_indicator() + + indicator._on_system_resume() + + calls = mock_glib.timeout_add_seconds.call_args_list + assert len(calls) == 2 + assert calls[0][0][0] == 2 + assert calls[0][0][1] == indicator._reinit_speech_after_resume + assert calls[1][0][0] == 2 + assert calls[1][0][1] == indicator._start_input_device_monitor + + @patch("vocalinux.ui.tray_indicator.Gio") + @patch("vocalinux.ui.tray_indicator.GLib") + def test_start_input_device_monitor_sets_up_gio_monitor(self, mock_glib, mock_gio): + indicator = self._make_tray_indicator() + mock_file = MagicMock() + mock_gio.File.new_for_path.return_value = mock_file + mock_monitor = MagicMock() + mock_file.monitor_directory.return_value = mock_monitor + + result = indicator._start_input_device_monitor() + + mock_gio.File.new_for_path.assert_called_once_with("/dev/input") + mock_file.monitor_directory.assert_called_once_with(mock_gio.FileMonitorFlags.NONE, None) + mock_monitor.connect.assert_called_once_with("changed", indicator._on_input_device_changed) + assert indicator._input_monitor is mock_monitor + assert mock_glib.timeout_add_seconds.call_count == 2 + assert result == mock_glib.SOURCE_REMOVE + + @patch("vocalinux.ui.tray_indicator.Gio") + @patch("vocalinux.ui.tray_indicator.GLib") + def test_start_input_device_monitor_falls_back_on_failure(self, mock_glib, mock_gio): + indicator = self._make_tray_indicator() + mock_gio.File.new_for_path.side_effect = Exception("no inotify") + + result = indicator._start_input_device_monitor() + + from vocalinux.ui.tray_indicator import _FALLBACK_KEYBOARD_RESTART_SECONDS + + mock_glib.timeout_add_seconds.assert_called_once_with( + _FALLBACK_KEYBOARD_RESTART_SECONDS, indicator._reinit_keyboard_fallback + ) + assert result == mock_glib.SOURCE_REMOVE + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_on_input_device_changed_resets_settle_timer(self, mock_glib): + indicator = self._make_tray_indicator() + indicator._settle_timer_id = 42 + + indicator._on_input_device_changed(MagicMock(), MagicMock(), None, MagicMock()) + + mock_glib.source_remove.assert_called_once_with(42) + from vocalinux.ui.tray_indicator import _INPUT_SETTLE_SECONDS + + mock_glib.timeout_add_seconds.assert_called_once_with( + _INPUT_SETTLE_SECONDS, indicator._on_devices_settled + ) + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_on_input_device_changed_creates_timer_when_none(self, mock_glib): + indicator = self._make_tray_indicator() + + indicator._on_input_device_changed(MagicMock(), MagicMock(), None, MagicMock()) + + mock_glib.source_remove.assert_not_called() + mock_glib.timeout_add_seconds.assert_called_once() + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_on_devices_settled_cleans_up_and_restarts_keyboard(self, mock_glib): + indicator = self._make_tray_indicator() + mock_monitor = MagicMock() + indicator._input_monitor = mock_monitor + indicator._settle_timer_id = 10 + indicator._monitor_timeout_id = 20 + + result = indicator._on_devices_settled() + + assert indicator._input_monitor is None + assert indicator._settle_timer_id is None + assert indicator._monitor_timeout_id is None + mock_monitor.cancel.assert_called_once() + indicator._setup_keyboard_shortcuts.assert_called_once() + assert result == mock_glib.SOURCE_REMOVE + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_on_input_monitor_timeout_cleans_up_and_restarts_keyboard(self, mock_glib): + indicator = self._make_tray_indicator() + mock_monitor = MagicMock() + indicator._input_monitor = mock_monitor + indicator._settle_timer_id = 10 + indicator._monitor_timeout_id = 20 + + result = indicator._on_input_monitor_timeout() + + assert indicator._input_monitor is None + indicator._setup_keyboard_shortcuts.assert_called_once() + assert result == mock_glib.SOURCE_REMOVE + + def test_reinit_keyboard_fallback_calls_setup(self): + indicator = self._make_tray_indicator() + + result = indicator._reinit_keyboard_fallback() + + indicator._setup_keyboard_shortcuts.assert_called_once() + + from gi.repository import GLib + + assert result == GLib.SOURCE_REMOVE + + def test_reinit_keyboard_fallback_failure_is_caught(self): + indicator = self._make_tray_indicator() + indicator._setup_keyboard_shortcuts.side_effect = RuntimeError("no devices") + + try: + indicator._reinit_keyboard_fallback() + except RuntimeError: + pass + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_cleanup_input_monitor_cancels_everything(self, mock_glib): + indicator = self._make_tray_indicator() + mock_monitor = MagicMock() + indicator._input_monitor = mock_monitor + indicator._settle_timer_id = 10 + indicator._monitor_timeout_id = 20 + + indicator._cleanup_input_monitor() + + mock_glib.source_remove.assert_any_call(10) + mock_glib.source_remove.assert_any_call(20) + mock_monitor.cancel.assert_called_once() + assert indicator._input_monitor is None + assert indicator._settle_timer_id is None + assert indicator._monitor_timeout_id is None + + @patch("vocalinux.ui.tray_indicator.GLib") + def test_cleanup_input_monitor_noop_when_nothing_active(self, mock_glib): + indicator = self._make_tray_indicator() + + indicator._cleanup_input_monitor() + + mock_glib.source_remove.assert_not_called() + if __name__ == "__main__": unittest.main()