device: Treat empty hidraw read as device removal (EOF) (#3174)

* Treat empty hidraw read as device removal (EOF)

When select() reports a hidraw fd as readable but os.read() returns
empty bytes, that's EOF per POSIX — the device has been removed.
Previously this was silently treated as no data, causing the listener
to loop indefinitely on a gone device instead of cleaning up.

* Fix test_ping_errors missing mocks for _read_input_buffer and write

The test was not mocking _read_input_buffer or write, so ping() would
call into real hidapi.read() with a fake handle (fd 1). The empty-read
EOF detection added in the previous commit made this consistently fail
by raising OSError → NoReceiver before reaching the mocked _read path.

Add the same mocks used by the adjacent test_request_errors.

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
This commit is contained in:
Ken Sanislo 2026-04-14 08:56:01 -07:00 committed by GitHub
parent 9c80b64b49
commit 25865994cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 4 additions and 0 deletions

View File

@ -409,6 +409,8 @@ def read(device_handle, bytes_count, timeout_ms=-1):
data = os.read(device_handle, bytes_count)
assert data is not None
assert isinstance(data, bytes), (repr(data), type(data))
if not data: # empty read when select() said readable means EOF (device removed)
raise OSError(errno.EIO, f"device disconnected on file descriptor {int(device_handle)}")
return data
else:
return b""

View File

@ -188,6 +188,8 @@ def test_ping_errors(simulated_error: Hidpp10Error, expected_result):
with mock.patch(
"logitech_receiver.base._read",
return_value=(HIDPP_SHORT_MESSAGE_ID, device_number, b"\x8f" + reply_data_sw_id + bytes([simulated_error])),
), mock.patch("logitech_receiver.base._read_input_buffer"), mock.patch(
"logitech_receiver.base.write", return_value=None
), mock.patch("logitech_receiver.base._get_next_sw_id", return_value=next_sw_id):
if isinstance(expected_result, type) and issubclass(expected_result, Exception):
with pytest.raises(expected_result) as context: