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:
committed by
Teknium
parent
ae163160fe
commit
516687d7e3
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user