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.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
+159
-28
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user