diff --git a/CHANGELOG.md b/CHANGELOG.md index 6077ec1..478753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## PyTAK 7.5.1 + +- Treats a transient `EPERM` from a network send as a recoverable transport + outage. Linux can return this while firewall, route, or network namespace + state is being replaced during an appliance update. PyTAK now tears down the + affected client cleanly and reconnects with bounded backoff instead of + terminating the gateway process. + +## PyTAK 7.5.0 + +- Added a common, additive runtime health contract to `StatusWriter`: `health`, + `input`, and `output` blocks describe process state, receiver activity, and + egress connection health without removing gateway-specific counters. + ## PyTAK 7.4.3 - PyTAK-backed gateways now survive transient TAK transport failures in the @@ -230,8 +244,3 @@ New Features: Bug & Performance Fixes: - Added async sleeps to each TX & RX loops iteration to fix broken async regiment in PYTAK. -# 7.5.0 - -* Add a common, additive runtime health contract to `StatusWriter`: `health`, - `input`, and `output` blocks describe process state, receiver activity, and - egress connection health without removing gateway-specific counters. diff --git a/src/pytak/VERSION b/src/pytak/VERSION index 18bb418..a5f017a 100644 --- a/src/pytak/VERSION +++ b/src/pytak/VERSION @@ -1 +1 @@ -7.5.0 +7.5.1 diff --git a/src/pytak/client_functions.py b/src/pytak/client_functions.py index b634e3e..d0abbcd 100644 --- a/src/pytak/client_functions.py +++ b/src/pytak/client_functions.py @@ -1048,6 +1048,11 @@ def _retryable_transport_error(exc: Exception) -> bool: errno.EHOSTUNREACH, errno.ENETDOWN, errno.ENETUNREACH, + # Linux can report EPERM from UDP sendto() while nftables, + # routes, or network namespaces are being replaced. Treat it as + # a transport outage so an appliance update cannot terminate a + # high-rate feeder during that short transition. + errno.EPERM, errno.ETIMEDOUT, } if hasattr(errno, "EHOSTDOWN"): diff --git a/tests/test_client_functions.py b/tests/test_client_functions.py index 4bc214e..0fc121f 100644 --- a/tests/test_client_functions.py +++ b/tests/test_client_functions.py @@ -20,6 +20,7 @@ import asyncio +import errno import os from configparser import ConfigParser, SectionProxy @@ -564,6 +565,42 @@ async def test_run_with_reconnect_keeps_local_os_errors_fatal(): fake_sleep.assert_not_called() +def test_reconnect_keeps_local_access_denied_fatal(): + """Ordinary local file access failures remain configuration errors.""" + error = PermissionError(errno.EACCES, "Permission denied") + assert not pytak.client_functions._retryable_transport_error(error) + + +@pytest.mark.asyncio +async def test_run_with_reconnect_retries_transient_udp_permission_error(): + """A firewall transition must not terminate a UDP-backed gateway.""" + config_p = ConfigParser( + { + "PYTAK_RECONNECT_INITIAL": "1", + "PYTAK_RECONNECT_MAX": "2", + "PYTAK_RECONNECT_FACTOR": "2", + "PYTAK_RECONNECT_JITTER": "0", + "PYTAK_RECONNECT_RESET": "300", + } + ) + config_p.add_section("fakeapp") + config = config_p["fakeapp"] + fake_main = AsyncMock( + side_effect=[PermissionError(errno.EPERM, "Operation not permitted"), None] + ) + fake_sleep = AsyncMock() + + with mock.patch( + "pytak.client_functions.main", new=fake_main + ), mock.patch("pytak.client_functions.asyncio.sleep", new=fake_sleep): + await pytak.client_functions.run_with_reconnect( + "fakeapp", config, config_p + ) + + assert fake_main.call_count == 2 + fake_sleep.assert_called_once_with(1) + + @pytest.mark.asyncio async def test_run_with_reconnect_retries_tak_enrollment_resolution(): """A transient enrollment failure retries from the original deep link."""