fix(api_server): mark port conflict as non-retryable to stop infinite reconnect loop (#65665)

A bare False from connect() on EADDRINUSE made the gateway reconnect
watcher treat a port conflict as transient and retry forever at the
backoff cap — 1568+ retries over 5 days in a multi-profile production
setup, filling errors.log and leaking 2 ResponseStore fds per retry.
Set a non-retryable fatal error (api_server_port_in_use) in the bind
OSError branch so the platform drops from the reconnect queue;
recover via /platform resume api_server after changing the port.

Re-implementation of #52132 by @msalles1 against the direct-bind path
from #65621 (the pre-probe block their patch targeted no longer
exists). Their production diagnosis and test scenario preserved.
This commit is contained in:
Teknium 2026-07-16 06:29:09 -07:00 committed by GitHub
parent 75c878217d
commit bda8bd76a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 46 additions and 0 deletions

View file

@ -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",

View file

@ -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()