Compare commits

...

1 Commits

Author SHA1 Message Date
Franck Nijhof
5144b2b68a Handle serial port close race condition in rfxtrx during shutdown 2026-02-27 20:26:29 +00:00
2 changed files with 63 additions and 1 deletions

View File

@@ -127,6 +127,26 @@ def _create_rfx(
else:
transport = rfxtrxmod.PySerialTransport(config[CONF_DEVICE])
# Work around a race condition in pyRFXtrx during shutdown.
# When close_connection() closes the serial port, the background read
# thread may still be inside serial.read(), which calls
# os.read(self.fd, ...). Since closing sets fd=None, this raises
# TypeError. The library's transport_errors decorator only catches
# socket.error, SerialException, and OSError — not TypeError.
# https://github.com/Danielhiversen/pyRFXtrx/pull/155
original_receive = transport.receive_blocking
def _receive_blocking_wrapper() -> rfxtrxmod.RFXtrxEvent | None:
try:
return original_receive()
except TypeError:
if getattr(transport.serial, "fd", ...) is None:
_LOGGER.debug("Serial port closed during read")
return None
raise
transport.receive_blocking = _receive_blocking_wrapper
rfx = rfxtrxmod.Connect(
transport,
event_callback,

View File

@@ -2,8 +2,9 @@
from __future__ import annotations
from unittest.mock import ANY, call
from unittest.mock import ANY, MagicMock, call
import pytest
import RFXtrx as rfxtrxmod
from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT
@@ -19,6 +20,47 @@ from tests.typing import WebSocketGenerator
SOME_PROTOCOLS = ["ac", "arc"]
async def test_serial_receive_blocking_type_error_on_shutdown(
hass: HomeAssistant, rfxtrx, transport_mock
) -> None:
"""Test that TypeError during serial shutdown is suppressed.
pyRFXtrx has a race condition where closing the serial port sets fd=None
while the read thread is still inside os.read(), causing a TypeError.
"""
original_receive = MagicMock(
side_effect=TypeError("'NoneType' object cannot be interpreted as an integer")
)
serial_mock = MagicMock(fd=None)
transport_mock.return_value.receive_blocking = original_receive
transport_mock.return_value.serial = serial_mock
config_entry = await setup_rfx_test_cfg(hass, device="/dev/ttyUSBfake")
assert config_entry.state is ConfigEntryState.LOADED
# After setup, the transport's receive_blocking should have been wrapped.
# Calling the wrapper should suppress the TypeError when serial.fd is None.
wrapped_receive = transport_mock.return_value.receive_blocking
assert wrapped_receive() is None
async def test_serial_receive_blocking_type_error_reraised(
hass: HomeAssistant, rfxtrx, transport_mock
) -> None:
"""Test that unrelated TypeErrors still propagate."""
original_receive = MagicMock(side_effect=TypeError("unexpected"))
serial_mock = MagicMock(fd=1)
transport_mock.return_value.receive_blocking = original_receive
transport_mock.return_value.serial = serial_mock
config_entry = await setup_rfx_test_cfg(hass, device="/dev/ttyUSBfake")
assert config_entry.state is ConfigEntryState.LOADED
wrapped_receive = transport_mock.return_value.receive_blocking
with pytest.raises(TypeError, match="unexpected"):
wrapped_receive()
async def test_fire_event(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, rfxtrx
) -> None: