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 <other profile's gateway> cannot be proven
to belong to this profile's gateway" until the respawn-storm breaker engaged.
This commit is contained in:
John Paul Soliva
2026-09-23 12:43:59 +09:00
committed by Teknium
parent ae163160fe
commit 516687d7e3
2 changed files with 41 additions and 3 deletions

View File

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

View File

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