diff --git a/gateway/host_attach.py b/gateway/host_attach.py index 85c34af3b0..caa5f14210 100644 --- a/gateway/host_attach.py +++ b/gateway/host_attach.py @@ -10,7 +10,10 @@ Five outcomes, in order: * ``ATTACH`` — a live host gateway already serves this profile. Nothing to start; exit 0. * ``RESCAN``→ATTACH — it does not serve it yet: ask it to reconcile ``profiles/`` now (control socket ``rescan-profiles``) and attach once the answer includes us. -* ``REPLACE_HOST`` — ``--replace`` names the host process as the target, whichever home launched it. +* ``REPLACE_HOST`` — ``--replace`` names the host process as the target when it serves this + profile (or has not published its served set yet), whichever home launched it. An owner known + not to serve us is another profile's gateway and falls through to REFUSE/START below; + ``--force`` is the explicit takeover-anything switch. * ``REFUSE`` — a live MULTIPLEXING gateway exists and cannot be made to serve this profile. Never start a second one silently. * ``START`` — no live owner, or the owner answers ``multiplex: False``: it is another diff --git a/gateway/run.py b/gateway/run.py index 65cb6ee862..b715699bd7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5482,7 +5482,14 @@ def _owner_is_standalone() -> bool: def _refuse_second_host_gateway(owner) -> None: - """Print the named refusal and exit 75 so a supervisor retries instead of parking the unit.""" + """Print the named refusal and exit 75 so a supervisor retries instead of parking the unit. + + Reached only after losing the host lock to a live process, so ``--replace`` is not offered as + the way past it: it only takes over an owner that serves this profile (which would have been + handled before the claim), and it does not skip the lock check. A holder with no readable + record (its publish failed, or it runs another HOST_PROTOCOL_VERSION) can only be stopped or + bypassed with ``--force``. + """ from gateway import host_rendezvous as hr from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE from hermes_cli.gateway_migrate import MIGRATE_COMMAND @@ -5493,8 +5500,9 @@ def _refuse_second_host_gateway(owner) -> None: f" Exactly one gateway per host serves every profile, so this process will not start a\n" f" second one (it would double-bind this profile's platforms).\n" f" Fold every profile onto the owner: {MIGRATE_COMMAND}\n" - f" Or take the host over: hermes gateway run --replace\n" - f" Or start one anyway: hermes gateway run --force") + f" Or stop the other gateway first, then start this one.\n" + f" Or start one anyway (skips the host-lock check): hermes gateway run --force\n" + f" (--replace does not skip this check; it only replaces an owner that serves this profile.)") logger.error("Refusing to start a second gateway on this host: %s", who) print(message) raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE) @@ -5573,7 +5581,8 @@ async def _host_attach_or_none(replace: bool, force: bool = False) -> Optional[b print(decision.message) return False if decision.outcome == REPLACE_HOST and decision.owner is not None: - # --replace names the HOST process, whichever home launched it. + # --replace names the HOST process, whichever home launched it, but only when it serves + # this profile or has not published its served set yet (decide() already filtered). if not await _start_gateway_replace_existing_instance(decision.owner.pid, True): return False return None @@ -5876,7 +5885,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # PID file BEFORE adapters: of two concurrent `run --replace`, only the O_EXCL winner opens sockets. # Only --force skips the host-lock refusal. Every generated unit carries --replace, so reading it as # --force there disabled the one arbiter of the two-units-at-once race; a replace that took the - # owner over already freed the lock with that process. + # owner over already freed the lock with that process. Consequence: a live holder with no + # readable record (publish_record failed after the claim, or a different HOST_PROTOCOL_VERSION + # during a rolling upgrade) blocks every other unit with exit 75 until it exits; only --force + # gets past it. if not _start_gateway_claim_pid_file(force=force): return False