fix(gateway): reserve retrying Photon listener ownership

This commit is contained in:
Nick Edson 2026-07-11 08:27:57 -07:00 committed by Teknium
parent c608a6937a
commit 201be3e546
2 changed files with 87 additions and 4 deletions

View file

@ -8475,6 +8475,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"config": platform_config,
"attempts": 1,
"next_retry": time.monotonic() + 30,
"credential_claim": self._adapter_credential_claim(
platform, adapter
),
"listener_claim": self._adapter_listener_claim(
platform, adapter
),
}
else:
self._update_platform_runtime_status(
@ -8491,6 +8497,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"config": platform_config,
"attempts": 1,
"next_retry": time.monotonic() + 30,
"credential_claim": self._adapter_credential_claim(
platform, adapter
),
"listener_claim": self._adapter_listener_claim(
platform, adapter
),
}
except Exception as e:
logger.error("%s error: %s", platform.value, e)
@ -8510,6 +8522,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"config": platform_config,
"attempts": 1,
"next_retry": time.monotonic() + 30,
"credential_claim": self._adapter_credential_claim(
platform, adapter
),
"listener_claim": self._adapter_listener_claim(
platform, adapter
),
}
if await self._abort_startup_if_shutdown_requested():
return True
@ -10231,6 +10249,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
listener_claim = self._adapter_listener_claim(_plat, _ad)
if listener_claim is not None:
claimed[listener_claim] = active
# A retryable primary still owns its configured credential and listener.
# Reserve both while it is queued so a secondary cannot take the endpoint
# before the reconnect watcher retries the primary adapter.
for retry_info in getattr(self, "_failed_platforms", {}).values():
for claim_name in ("credential_claim", "listener_claim"):
retry_claim = retry_info.get(claim_name)
if isinstance(retry_claim, tuple):
claimed[retry_claim] = active
for profile_name, profile_home in profiles_to_serve(multiplex=True):
if profile_name == active:
@ -10339,8 +10365,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
continue
# Same-token conflict detection — refuse a duplicate poll.
fp = self._adapter_credential_fingerprint(adapter)
credential_claim = (platform, fp) if fp is not None else None
credential_claim = self._adapter_credential_claim(platform, adapter)
if credential_claim is not None:
owner = claimed.get(credential_claim)
if owner is not None:
@ -10619,6 +10644,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return _handler
@staticmethod
def _adapter_credential_claim(
platform: Platform, adapter: Any
) -> Optional[tuple]:
"""Return the exclusive credential resource claimed by an adapter."""
fingerprint = GatewayRunner._adapter_credential_fingerprint(adapter)
if fingerprint is None:
return None
return (platform, fingerprint)
@staticmethod
def _adapter_listener_claim(platform: Platform, adapter: Any) -> Optional[tuple]:
"""Return the exclusive listener resource claimed by an adapter.

View file

@ -878,7 +878,9 @@ class TestSecondaryProfileConfigHandling:
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: reviewer_cfg)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: secondary)
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", _connect)
monkeypatch.setattr(runner, "_make_adapter_auth_check", lambda p: None)
monkeypatch.setattr(
runner, "_make_adapter_auth_check", lambda p, **kwargs: None
)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/x", claimed
@ -934,7 +936,9 @@ class TestSecondaryProfileConfigHandling:
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: profile_cfg)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: next(adapters))
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", _connect)
monkeypatch.setattr(runner, "_make_adapter_auth_check", lambda p: None)
monkeypatch.setattr(
runner, "_make_adapter_auth_check", lambda p, **kwargs: None
)
first = await runner._start_one_profile_adapters("broken", "/tmp/x", claimed)
second = await runner._start_one_profile_adapters("later", "/tmp/y", claimed)
@ -944,6 +948,50 @@ class TestSecondaryProfileConfigHandling:
assert second == 1
assert runner._profile_adapters["later"][photon] is later
@pytest.mark.asyncio
async def test_failed_primary_photon_listener_is_reserved_for_retry(
self, monkeypatch
):
"""A retrying primary keeps secondaries off its sidecar endpoint."""
from gateway.config import GatewayConfig, Platform
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.adapters = {}
runner._profile_adapters = {}
runner.pairing_stores = {}
photon = Platform("photon")
listener_claim = ("listener", "photon", "127.0.0.1", 8789)
runner._failed_platforms = {
photon: {
"config": object(),
"attempts": 1,
"next_retry": 0,
"listener_claim": listener_claim,
}
}
seen = {}
async def _start(profile_name, profile_home, claimed):
seen.update(claimed)
return 0
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex=True: (("default", "/tmp/default"), ("reviewer", "/tmp/reviewer")),
)
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.setattr("gateway.status.write_runtime_status", lambda **kwargs: None)
monkeypatch.setattr(runner, "_start_one_profile_adapters", _start)
connected = await runner._start_secondary_profile_adapters()
assert connected == 0
assert seen[listener_claim] == "default"
def test_port_binding_set_covers_known_listeners(self):
from gateway.run import _PORT_BINDING_PLATFORM_VALUES
# Every adapter that binds a TCP port must be in the guard set.