diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 031b74c33666..ef3554aabf4e 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -40,6 +40,7 @@ Requires: """ import asyncio +import errno import hashlib import hmac import json @@ -5212,6 +5213,25 @@ class APIServerAdapter(BasePlatformAdapter): await self._runner.cleanup() self._runner = None self._site = None + if getattr(exc, "errno", None) == errno.EADDRINUSE: + # A port conflict is a configuration error, not a + # transient blip — another process holds the port for + # its lifetime. A bare ``return False`` makes the + # reconnect watcher in gateway.run treat it as retryable + # and loop forever at the backoff cap (observed: 1568+ + # retries over 5 days across multi-profile setups all + # defaulting to the same port, #52132), filling + # errors.log and leaking the adapter's ResponseStore + # fds each retry. Non-retryable drops it from the + # reconnect queue; the operator recovers with + # ``/platform resume api_server`` after changing the port. + self._set_fatal_error( + "api_server_port_in_use", + f"Port {self._port} already in use. Set " + f"platforms.api_server.port in config.yaml to a " + f"different value, then `/platform resume api_server`.", + retryable=False, + ) logger.error( "[%s] Could not bind %s:%d: %s. Set a different port in " "config.yaml: platforms.api_server.port", diff --git a/tests/gateway/test_api_server_bind_guard.py b/tests/gateway/test_api_server_bind_guard.py index 284f6b289a79..78d039c73d2c 100644 --- a/tests/gateway/test_api_server_bind_guard.py +++ b/tests/gateway/test_api_server_bind_guard.py @@ -217,3 +217,29 @@ class TestBindMechanics: def test_pre_probe_helper_removed(self): """The racy single-family pre-probe must not come back.""" assert not hasattr(APIServerAdapter, "_port_is_available") + + @pytest.mark.asyncio + async def test_port_conflict_sets_non_retryable_fatal_error(self): + """A real port conflict (EADDRINUSE) must set a non-retryable fatal + error so the reconnect watcher drops the platform from the retry + queue instead of looping indefinitely. + + Previously connect() returned bare ``False``, which the reconnect + watcher treated as retryable — retrying every 5 minutes forever, + filling errors.log and leaking 2 fds per retry (#52132: 1568+ + retries over 5 days in a multi-profile setup). + """ + port = self._free_port() + first = self._make_adapter(port) + assert await first.connect() is True + second = self._make_adapter(port) + try: + result = await second.connect() + assert result is False + assert second.has_fatal_error is True + assert second.fatal_error_retryable is False + assert second.fatal_error_code == "api_server_port_in_use" + assert str(port) in (second.fatal_error_message or "") + finally: + await first.disconnect() + await second.disconnect()