From 5909def48d79bee5a18d2a848d59f37de7fa9b28 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 29 Mar 2026 23:21:55 -0700 Subject: [PATCH] fix(suspend): restart keyboard shortcut backend after resume (#371) After suspend/resume, evdev devices disconnect and the monitor thread exits when all file descriptors close. The resume handler only reinitialized the speech engine, leaving keyboard shortcuts dead. Restart _setup_keyboard_shortcuts() in _reinit_after_resume() to re-scan /dev/input and open fresh device file descriptors. --- src/vocalinux/ui/tray_indicator.py | 9 +++++ tests/test_suspend_handler.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 934e7f8..915ae89 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -476,6 +476,15 @@ class TrayIndicator: 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 _on_quit_clicked(self, widget): diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py index 92119d5..1d7c9c3 100644 --- a/tests/test_suspend_handler.py +++ b/tests/test_suspend_handler.py @@ -272,5 +272,58 @@ class TestReinitializeAfterResume(unittest.TestCase): mgr._update_state.assert_called_once_with(RecognitionState.ERROR) +class TestTrayIndicatorResumeFlow(unittest.TestCase): + """Tests for TrayIndicator._reinit_after_resume keyboard restart.""" + + def _make_tray_indicator(self): + from vocalinux.ui.tray_indicator import TrayIndicator + + with patch.object(TrayIndicator, "__init__", lambda self: None): + indicator = TrayIndicator.__new__(TrayIndicator) + + indicator.speech_engine = MagicMock() + indicator._setup_keyboard_shortcuts = MagicMock() + return indicator + + def test_reinit_after_resume_calls_speech_engine_reinit(self): + indicator = self._make_tray_indicator() + + indicator._reinit_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): + from gi.repository import GLib + + indicator = self._make_tray_indicator() + + result = indicator._reinit_after_resume() + + assert result == GLib.SOURCE_REMOVE + + if __name__ == "__main__": unittest.main()