fix(gateway): keep an ancestor gateway visible so the update it spawned can pause it - #87608
Conversation
…ed can pause it `hermes update --gateway` is spawned BY the gateway when `/update` is issued from a messaging platform, which puts a real `gateway run` in the updater's own ancestor chain. `_scan_gateway_pids` excluded every ancestor unconditionally, so the scan reported no gateway at all: `find_gateway_pids(all_profiles=True)` returned `[]`, the update pause path had nothing to pause, and the updater then mutated the venv while the gateway still held its `.pyd` files. The run aborted on the venv-holder guard with "Other Hermes processes are running from this install's venv", which on Windows is unrecoverable without killing the gateway by hand. The blanket exclusion came from NousResearch#13242, whose actual case is narrower: stop the CLI that invoked the scan (`hermes gateway status`, `hermes update`) from being counted as a gateway. Those command lines are not gateway runtimes, and the exclusion is applied only after the strict matcher has already accepted a process, so gating it on the command line preserves NousResearch#13242 exactly while letting a genuine `gateway run` parent through. Changes: * Hold the ancestor set separately from `exclude_pids`. `exclude_pids` belongs to the caller and stays unconditional; ancestry is now a heuristic that the scan resolves with the command line in hand. * Add `_suppressed_as_ancestor(pid, command)`: true only when the pid is our ancestor AND its command line does not look like a gateway runtime. * Wire it into all three scan arms (Windows wmic, POSIX /proc, ps fallback) so the behaviour is identical on every platform. Tests: `tests/hermes_cli/test_gateway_scan_ancestor_gateway.py`, 8 cases. The Windows arm is stubbed end to end so it runs on any host, since that is where the reported failure lives. `test_gateway_that_spawned_us_is_reported` fails against the unpatched source with the reporter's exact symptom (empty pid list) while the two NousResearch#13242 guardrail tests pass both before and after, showing the older contract is not loosened. Fixes NousResearch#87594
PR: fix(gateway): keep an ancestor gateway visible so the update it spawned can pause it
|
|
Thanks for the fix! I applied your patch locally on my Windows 11 machine and confirmed the first part works: While testing though, I found a second spot the PR doesn't cover, and it can still abort the update in exactly the scenario you fixed:
So even with this PR merged, a My local patch uses the same gating idea as yours ( Two more things from the automated review that I think are worth your attention:
|
Review raised that `_suppressed_as_ancestor` gates on `looks_like_gateway_runtime_command_line` while the include decision uses `_matches_gateway_runtime`, so a command line accepted by the include predicate but rejected by the suppression one would still be hidden and NousResearch#87594 would return in that shape. That set is empty today: `looks_like_gateway_command_line` is `subcommand == "run"` and `looks_like_gateway_runtime_command_line` is `subcommand in {"run", "restart"}`, so the strict matcher is a subset of the broad one and `_matches_gateway_runtime` implies the suppression predicate on both arms. Rather than leave that argument in a review thread, assert it: the new test fails the moment the strict matcher accepts anything the broad one does not. Also covers the `restart` form, which the original tests omitted. A no-supervisor `gateway restart` executes `run_gateway()` in its own process, so it hosts the runtime while its argv still says `restart`, and Windows reaches it because `include_restart_managers` is `not supports_systemd_services()`. Tests only; no behaviour change.
|
Thanks both. @mck156 the independent Windows repro is the most useful thing on this PR so far, and your second site is real. Taking the four points in turn. 1. Predicate divergence: the set is empty, and I have pinned itBoth of you flagged that def looks_like_gateway_command_line(command):
return _gateway_command_subcommand(command) == "run"
def looks_like_gateway_runtime_command_line(command):
return _gateway_command_subcommand(command) in {"run", "restart"}So I would rather not leave that as an argument in a thread, so I did consider switching to 2. Test coverage for a non-
|
Summary
hermes update --gatewayis spawned by the gateway when a user issues/updatefrom a messaging platform. That puts a realgateway runprocess in the updater's own ancestor chain, and_scan_gateway_pidswas excluding every ancestor unconditionally, so the scan reported no gateway at all.The consequences, in the order the reporter hits them:
find_gateway_pids(all_profiles=True)returns[]..pydfiles.Other Hermes processes are running from this install's venv.On Windows step 3 is the one that matters, because open
.pydhandles cannot be replaced in place, which is exactly why the pause exists. The user is left having to kill the gateway by hand.The blanket ancestor exclusion came from #13242, whose real case is narrower: keep the CLI that invoked the scan (
hermes gateway status,hermes update) from being counted as a running gateway. Two facts make the narrower rule safe:looks_like_gateway_command_linerequires the subcommand to be exactlyrun, and the runtime matcher accepts only{run, restart}. Neithergateway statusnorupdatecan match either one.So this gates the exclusion on the command line instead of removing it: an ancestor that looks like a gateway runtime stays visible, everything else is still suppressed. #13242's behaviour is preserved verbatim.
Changes
hermes_cli/gateway.pyexclude_pids.exclude_pidsbelongs to the caller and stays unconditional; ancestry is a heuristic that the scan now resolves with the command line in hand._suppressed_as_ancestor(pid, command), true only when the pid is our ancestor and its command line does not look like a gateway runtime.wmic, POSIX/proc, and thepsfallback.tests/hermes_cli/test_gateway_scan_ancestor_gateway.py(new): 8 regression tests.I deliberately did not take the larger option of dropping the ancestor exclusion entirely. It would also fix the bug, and the analysis above suggests it is probably safe, but it changes behaviour for every caller rather than only the broken one, and the #13242 report is old enough that I would rather keep its guarantee mechanical than argue it away.
ruff formatwants to reformat pre-existing unrelated code inhermes_cli/gateway.py(the PATH concatenation near the top and severaltext=True, encoding=...blocks). That is already true on a cleanupstream/main, so I left it alone rather than bury this diff in unrelated churn.Testing
New file, 8 cases:
TestAncestorGatewayStaysVisible(6 cases, Windows arm). Stubbed end to end so it runs on any host, since Windows is where the reported failure lives.TestAncestorGatewayViaProc(2 cases,@pytest.mark.linux_only). Same contract through the/procarm that Linux hosts actually take.Both the fix case and the #13242 guardrails are covered on purpose:
test_gateway_that_spawned_us_is_reportedgateway runancestor is foundtest_updater_ancestor_is_not_reportedtest_non_gateway_ancestor_is_still_excludedtest_gateway_status_ancestor_is_still_excludedtest_caller_supplied_exclusions_stay_unconditionalexclude_pidsstill outranks the matchertest_unrelated_gateway_is_unaffectedVerified the tests actually catch the regression by stashing
hermes_cli/gateway.pyand re-running:That empty list is the reporter's
list(find_gateway_pids(all_profiles=True)) == []. The two #13242 guardrail tests pass both before and after the fix, which is the evidence that the older contract is not loosened.With the fix applied:
Type of change
Checklist
Cross-platform
/proc, and thepsfallback share one rulescripts/check-windows-footguns.py --allcleanFixes #87594