Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions aw_watcher_afk/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ def __init__(self):
self._threads: List[threading.Thread] = []
self._devices = [] # track open devices for cleanup in stop()
self._stop_event = threading.Event()
# True once start() has actually spawned at least one reader thread.
# Used by callers to distinguish "no gamepad ever attached" (don't
# restart) from "gamepad threads died after running" (do restart).
self._was_started = False

def _reset_data(self):
self.event_data = {"buttons": 0}
Expand Down Expand Up @@ -183,6 +187,7 @@ def start(self):
)
t.start()
self._threads.append(t)
self._was_started = True

def stop(self):
self._stop_event.set()
Expand All @@ -198,10 +203,22 @@ def stop(self):
for t in self._threads:
t.join(timeout=2.0)
self._threads.clear()
self._was_started = False

def is_alive(self) -> bool:
return any(t.is_alive() for t in self._threads)

def needs_restart(self) -> bool:
"""Return True if start() previously launched threads but they have all died.

Used to distinguish a system that simply has no gamepad attached (in
which case start() returned early and no restart is needed) from one
where all reader threads have exited unexpectedly (e.g. device removed
or USB error). In the latter case the caller should reinitialize the
listener so a reconnected gamepad starts being tracked again.
"""
return self._was_started and not self.is_alive()
Comment on lines 208 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multi-gamepad partial disconnection is not detected

is_alive() uses any(t.is_alive() for t in self._threads), so when a system has two gamepads and only one is unplugged, one thread dies but is_alive() stays True. needs_restart() therefore returns False, and the disconnected gamepad is never re-discovered on reconnect. The watcher silently tracks one fewer device until the remaining gamepad is also unplugged. This is a narrow edge case but worth noting, especially since the PR description specifically calls out multi-device USB scenarios.


# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
Expand Down
22 changes: 18 additions & 4 deletions aw_watcher_afk/unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,24 @@ def _check_listeners(self):

See: https://github.com/ActivityWatch/aw-watcher-afk/issues/27
"""
if not self.mouseListener.is_alive() or not self.keyboardListener.is_alive():
self.logger.warning(
"Input listeners died (X server restart?), reinitializing..."
)
# Only restart the gamepad listener if it was actually running before
# and all its threads died (e.g. device unplugged). Without this guard,
# systems without a gamepad would trigger a restart on every poll,
# since is_alive() is always False when start() returned early.
gamepad_died = self.gamepadListener.needs_restart()
if (
not self.mouseListener.is_alive()
or not self.keyboardListener.is_alive()
or gamepad_died
):
if gamepad_died:
self.logger.warning(
"Gamepad listener died (device removed?), reinitializing..."
)
else:
self.logger.warning(
"Input listeners died (X server restart?), reinitializing..."
)
Comment on lines +56 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Log message swallows keyboard/mouse death when gamepad also dies

The current branching logs only the gamepad message when gamepad_died is True, even if mouseListener.is_alive() or keyboardListener.is_alive() are also False at the same time (e.g., the X server crashed right as the gamepad was unplugged). In that scenario the "X server restart?" attribution is silently dropped, making diagnosis harder. The two warnings aren't mutually exclusive — consider logging both conditions when both are true.

# Stop any still-running listeners before creating new ones
# to avoid duplicate listener instances (e.g. if only one died)
self._stop_listeners()
Expand Down
115 changes: 115 additions & 0 deletions tests/test_listener_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def test_unix_reinitializes_dead_listeners(MockGamepad, MockMouse, MockKeyboard)
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = False # no gamepad connected
# No gamepad attached → needs_restart() should be False so we don't
# reinitialize every poll on gamepad-less systems.
mock_gamepad_instance.needs_restart.return_value = False
mock_kb_instance.has_new_event.return_value = False
mock_mouse_instance.has_new_event.return_value = False
mock_gamepad_instance.has_new_event.return_value = False
Expand Down Expand Up @@ -175,6 +178,7 @@ def test_unix_gamepad_event_resets_afk_timer(MockGamepad, MockMouse, MockKeyboar
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = True
mock_gamepad_instance.needs_restart.return_value = False

# No keyboard/mouse events, but gamepad has an event
mock_kb_instance.has_new_event.return_value = False
Expand All @@ -188,3 +192,114 @@ def test_unix_gamepad_event_resets_afk_timer(MockGamepad, MockMouse, MockKeyboar
assert seconds < 1.0
# Event was consumed
mock_gamepad_instance.next_event.assert_called_once()


# ---------------------------------------------------------------------------
# GamepadListener.needs_restart()
# ---------------------------------------------------------------------------


def test_gamepad_needs_restart_false_before_start():
"""needs_restart() must be False before start() so we don't try to restart
a listener that was never running (e.g. on systems with no gamepad)."""
listener = GamepadListener()
assert not listener.needs_restart()


def test_gamepad_needs_restart_false_when_start_was_noop():
"""If start() returns early (no evdev / no devices), needs_restart() must
stay False to avoid an infinite restart loop on gamepad-less systems."""
listener = GamepadListener()
with patch.object(listener, "_find_gamepads", return_value=[]):
listener.start()
assert not listener.is_alive()
assert not listener.needs_restart()


def test_gamepad_needs_restart_true_when_threads_died():
"""After start() spawned threads, needs_restart() should flip to True once
all reader threads have exited (e.g. device unplugged)."""
import sys
from unittest.mock import MagicMock

listener = GamepadListener()

mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"

mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"), # returns immediately
):
listener.start()

# Wait for the (no-op) reader thread to exit
for t in listener._threads:
t.join(timeout=2.0)

assert not listener.is_alive()
assert listener.needs_restart()


def test_gamepad_needs_restart_false_after_stop():
"""After an explicit stop(), needs_restart() must be False so the caller
doesn't keep recreating a listener the user wanted shut down."""
import sys
from unittest.mock import MagicMock

listener = GamepadListener()

mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"

mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"),
):
listener.start()
listener.stop()
assert not listener.needs_restart()


@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_restarts_when_only_gamepad_dies(MockGamepad, MockMouse, MockKeyboard):
"""If the gamepad listener dies but mouse/keyboard are still alive, the
listeners should still be reinitialized so a reconnected gamepad starts
being tracked again (regression for the original bug)."""
from aw_watcher_afk.unix import LastInputUnix

mock_kb = MockKeyboard.return_value
mock_mouse = MockMouse.return_value
mock_gamepad = MockGamepad.return_value

mock_kb.is_alive.return_value = True
mock_mouse.is_alive.return_value = True
mock_gamepad.is_alive.return_value = True
mock_gamepad.needs_restart.return_value = False
mock_kb.has_new_event.return_value = False
mock_mouse.has_new_event.return_value = False
mock_gamepad.has_new_event.return_value = False

unix = LastInputUnix()
unix.seconds_since_last_input()

assert MockGamepad.call_count == 1 # not restarted yet

# Now simulate the gamepad dying while mouse/keyboard stay alive
mock_gamepad.is_alive.return_value = False
mock_gamepad.needs_restart.return_value = True

unix.seconds_since_last_input()

# All listeners should have been recreated
assert MockKeyboard.call_count == 2
assert MockMouse.call_count == 2
assert MockGamepad.call_count == 2
Loading