From 516687d7e376d3591d5c76857fff6e827a726124 Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Wed, 23 Sep 2026 12:43:59 +0900 Subject: [PATCH] fix(gateway): --replace starts beside another profile's standalone owner host_attach.decide() returned REPLACE_HOST for any live host owner whenever --replace was passed, before checking whether that owner serves this profile. On a one-process-per-profile fleet the owner is another profile's standalone gateway: _replace_target_belongs_to_other_profile correctly refuses to signal it (fail closed), the gateway exits, and the supervisor restarts it into the same refusal. hermes gateway install generates --replace for every unit, so every profile but the one holding the host lock respawn-storms. The non-replace path already handles this owner by starting beside it; only --replace skipped that branch. --replace now targets the owner only when it serves this profile, or when its served set is not known yet (the boot race, where replacing keeps --replace's authority and the ownership guard still decides). An owner known not to serve us takes the non-replace path. Reproduced on a live macOS launchd fleet after updating to 0.21.4: the first profile to restart claimed the host lock, and the default profile's unit then looped on "Refusing --replace: PID cannot be proven to belong to this profile's gateway" until the respawn-storm breaker engaged. --- gateway/host_attach.py | 9 ++++-- tests/gateway/test_host_attach_lifecycle.py | 35 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/gateway/host_attach.py b/gateway/host_attach.py index 5de5e5670a..a33a472c5a 100644 --- a/gateway/host_attach.py +++ b/gateway/host_attach.py @@ -337,9 +337,12 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision: return HostAttachDecision(START, "") if gateway is None or gateway.pid == os.getpid(): return standalone_attach_decision(our_home, None) or HostAttachDecision(START, "") - if replace: - # --replace is explicit authority over the host role; the target is the host process, - # whichever home launched it. + if replace and (gateway.serves(profile) or not gateway.served_known): + # --replace is authority over the process SERVING THIS PROFILE, whichever home launched it. + # An owner known not to serve us is another profile's gateway: replacing it is always refused + # (_replace_target_belongs_to_other_profile fails closed) and the gateway exits, so on a + # one-process-per-profile fleet, whose generated units all carry --replace, every unit but + # the lock holder respawn-storms. Such an owner takes the non-replace path below instead. return HostAttachDecision(REPLACE_HOST, "", gateway) if gateway.served_known: standalone = standalone_attach_decision(our_home, gateway) diff --git a/tests/gateway/test_host_attach_lifecycle.py b/tests/gateway/test_host_attach_lifecycle.py index 903e51f582..9a471aa6d6 100644 --- a/tests/gateway/test_host_attach_lifecycle.py +++ b/tests/gateway/test_host_attach_lifecycle.py @@ -165,6 +165,41 @@ def test_a_standalone_owner_is_the_per_profile_topology_not_a_refusal(tmp_path, assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is None +def test_replace_starts_beside_a_standalone_owner_it_does_not_belong_to(tmp_path, monkeypatch, owner_pid): + """Generated launchd/s6 units all run ``gateway run --replace``. When ANOTHER profile's standalone + gateway holds the host lock, ``--replace`` must not target it: that owner never serves us, the + ownership guard refuses to signal it, and the gateway exits, so every unit but the lock holder + respawn-storms. It must start beside the owner exactly as the non-replace path does.""" + owner_home = tmp_path / "root" / "profiles" / "tank" + _publish(owner_pid, owner_home, ("tank",)) + _answer_identify(monkeypatch, owner_pid, owner_home, ["tank"]) + monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: tmp_path / "root" / "profiles" / "nous") + monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles", + lambda home, timeout=8.0: {"multiplex": False, "served_profiles": ["tank"]}) + signalled: list[int] = [] + + async def _replace(pid, replace): + signalled.append(pid) + return False # what the ownership guard answers for another profile's gateway + + monkeypatch.setattr(gateway_run, "_start_gateway_replace_existing_instance", _replace) + + assert host_attach.decide(tmp_path / "root" / "profiles" / "nous", replace=True).outcome == host_attach.START + assert asyncio.run(gateway_run._host_attach_or_none(replace=True)) is None + assert signalled == [], "--replace must not target a standalone owner that does not serve this profile" + + +def test_replace_still_targets_an_owner_whose_served_set_is_not_known_yet(tmp_path, monkeypatch, owner_pid): + """Boot race: the claim-time record carries no served set until the owner's channel answers. + ``--replace`` must keep its authority over that owner rather than fall into the attach path + and stand down; the per-target ownership guard still decides whether it may be signalled.""" + owner_home = tmp_path / "root" + _publish(owner_pid, owner_home, ()) # record only: no identify answer, served set unknown + decision = host_attach.decide(owner_home / "profiles" / "other", replace=True) + assert decision.outcome == host_attach.REPLACE_HOST + assert decision.owner is not None and decision.owner.pid == owner_pid + + def test_replace_signals_the_owner_instead_of_standing_down(tmp_path, monkeypatch, owner_pid): """``gateway run --replace`` against a live owner must REACH it — the branch was dead code.""" owner_home = tmp_path / "root"