fix(update): keep gateway launcher visible in _venv_launcher_ancestors - #87666
fix(update): keep gateway launcher visible in _venv_launcher_ancestors#87666mck156 wants to merge 8 commits into
Conversation
Follow-up to NousResearch#87608: the same unconditional ancestor-skip exists in _venv_launcher_ancestors (update_cmd.py). When /update is spawned by the gateway itself, the gateway's venv-side launcher (venv\Scripts\python.exe, which holds the .pyd files) sits in the updater's ancestor chain and gets skipped, so the launcher survives the pause and the venv-holder guard still aborts the update. Gate the ancestor skip on the command line, mirroring NousResearch#87608: - an ancestor that looks like a gateway runtime stays visible - everything else is skipped - fallback to the old whole-chain skip on error Refs NousResearch#87594
|
关闭此 PR,暂不开 follow-up。 |
There was a problem hiding this comment.
Pull request overview
This PR fixes a Windows-specific failure mode in hermes update --gateway when /update is initiated from a messaging gateway: _venv_launcher_ancestors() no longer unconditionally suppresses the updater’s own ancestor chain, so the gateway’s venv-side launcher can be discovered and stopped to release locked .pyd files.
Changes:
- Update
_venv_launcher_ancestors()to keep gateway-runtime ancestors visible (usinglooks_like_gateway_runtime_command_line) while still skipping non-gateway ancestors. - Add a defensive fallback path that preserves the previous “skip whole ancestry” behavior on errors.
- Expand inline commentary explaining why gateway ancestors must be treated differently.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Exception (mirrors PR #87608 / #87594): an ancestor that is ITSELF a | ||
| # running gateway (command line matches ``gateway run``) is a real | ||
| # venv-holder, not a false positive — when the update was spawned by the | ||
| # gateway (e.g. /update from a messaging platform), the gateway launcher | ||
| # sits in this process's ancestry, and skipping it leaves the launcher | ||
| # locking venv .pyd files so the update still aborts on the venv-holder | ||
| # guard. Only skip ancestors that are NOT gateways. |
Copilot review on NousResearch#87666: looks_like_gateway_runtime_command_line() tokenizes via shlex.split(), which relies on proper quoting for paths containing spaces. ' '.join(anc.cmdline()) dropped the quotes, so a gateway whose executable path contains a space could fail to match and be added to the skip set, reintroducing the launcher-hidden behaviour on Windows. Use subprocess.list2cmdline() on Windows to re-serialize argv with correct quoting; keep the plain join elsewhere. Also add focused tests for the ancestor gating: - a gateway launcher in the updater's own ancestry is found (the fix) - non-gateway ancestors are still skipped (NousResearch#13242 intent preserved) - a launcher path containing spaces still matches
Import resolution comes from pytest's rootdir, not a hard-coded path; the inserted absolute path would break on CI runners.
psutil.Process() with no arguments returns the current process; the fake previously required a pid, so the ancestor-gating code path hit TypeError, fell into the fallback, and the tests passed without ever exercising the gating. FakeProc now defaults to os.getpid(). Mutation check: reverting the fix makes the two gateway-ancestry tests fail, so they genuinely pin the regression.
jackulau
left a comment
There was a problem hiding this comment.
Thanks for taking this, and for the independent repro on #87608 that turned it up. Reviewed as offered. The production change is right; two of the three tests do not test what their names claim, and one of them passes with the entire fix deleted.
The fix itself
Correct, and it lands in the right place. looks_like_gateway_runtime_command_line rather than the strict matcher is the right choice for the same reason it was on #87608: on a host without a service manager the restart fallback runs run_gateway() in-process while argv still reads gateway restart, so a strict == "run" would miss a launcher that genuinely holds the venv. And the call-time from gateway.status import ... matches the five existing ones in this module, so no import-cycle risk.
The subprocess.list2cmdline re-serialization is also right, and I want to say why in the thread so nobody later "simplifies" it to " ".join. _gateway_command_subcommand tokenizes with shlex.split(command, posix=False). In non-POSIX mode a quoted argument survives as one token and the quotes are stripped afterwards, so "C:\Program Files\Hermes\venv\Scripts\python.exe" -m hermes_cli.main gateway run matches. Joined with plain spaces it becomes four tokens, none of which is a gateway entrypoint, and the match fails. Your comment says this; I would only add the words posix=False so the reason is checkable without opening the other file.
test_non_gateway_ancestors_still_skipped never reaches the code it names
This is the one that matters. The tree is {300: 250, 250: 1, 1: None}, and the call is _venv_launcher_ancestors([200]). There is no 200 in that tree, so FakeProc(200).parent() hits proc_tree.get(200) which returns None, and the loop body continues on the very next line. The skip set is never consulted, found == [] is reached by a route that has nothing to do with gating, and the assertion holds identically if you delete the whole looks_like_gateway_runtime_command_line block. Your own inline comment says as much ("worker 200 doesn't exist in this tree") without drawing the conclusion.
The property you want is that a non-gateway ancestor that would otherwise qualify stays skipped, so the worker has to hang off it and the ancestor has to look like a venv launcher:
# updater(300) -> shell(250, cmd.exe) -> wscript(1), and the worker also
# hangs off the shell so ppid 250 actually reaches the skip check.
tree = {300: 250, 250: 1, 200: 250, 1: None}
cmdlines = {
300: [r"C:\Windows\System32\cmd.exe"],
250: [r"C:\Windows\System32\cmd.exe", "/c", "hermes update"],
200: GATEWAY_WORKER_CMD,
1: [],
}
...
found = cli_main._venv_launcher_ancestors([200])
assert found == [], "a cmd.exe ancestor is not a gateway launcher"250 is even, so FakeProc.exe() reports it under the venv prefix and it would be returned if the skip set had dropped it. That version fails if the gating is widened to skip nothing, which is what the test is for.
test_gateway_launcher_with_spaces_in_path_still_found has no spaces except on your machine
The comment says "PROJECT_ROOT itself contains a space (D:\Program file\...)". That is true of your checkout and of essentially no other. On CI the repo lives at a path with no space, list2cmdline adds no quotes, and the test becomes a byte-identical copy of the first one. The one property it exists to pin is the one it stops checking the moment it leaves your laptop.
Hardcode it instead of deriving it, since nothing here needs the real project root:
100: [
r"C:\Program Files\Hermes\venv\Scripts\python.exe",
"-m", "hermes_cli.main", "gateway", "run",
],and make FakeProc.exe() return a matching venv-prefixed path for that pid. Worth also asserting the quoting directly rather than only through the outcome, so a failure says which half broke:
assert '"C:\\Program Files\\Hermes\\venv\\Scripts\\python.exe"' in serializedSmaller things
The fallback silently restores the bug. If from gateway.status import ... ever fails, the except re-walks and skips the entire ancestry, which is precisely the behaviour this PR removes, with no trace. Since the whole failure mode is "the update aborts on the venv-holder guard and the user cannot tell why", a logger.debug naming the fallback would pay for itself the first time someone reads an update log. The try/except Exception: pass shape it replaces was at least not hiding a fix.
os.name == "nt" is unreachable here. _venv_launcher_ancestors returns [] at the top unless _m()._is_windows(), so the " ".join(raw) branch can never execute. Not harmful, but it implies a portability the function does not have, and a reader has to go check. Either drop it, or keep it with a one-line note that it is there so the helper stays correct if it is ever lifted out.
Unused in the test module: import pytest and WINDOWS_PATCH_TARGET.
Latent trap in the fake, worth a comment rather than a change: FakeProc.exe() keys on pid parity, so the worker (200) also reports a venv path. It does not affect the current tests because ppid in set(pids) excludes it, but the next person to add a case will trip over it.
Happy to re-review once these are in. Cross-linking #87608, which is the sibling fix in _gateway_ancestor_pids.
fix(update): keep gateway launcher visible in _venv_launcher_ancestors The fix correctly distinguishes gateway-runtime ancestors from false positives, and the fake-psutil tests exercise the Windows quoting path. Observations:
|
Addresses both review comments on NousResearch#87666 (jackulau + AI review): - tests: fix fake-proc ancestry so the non-gateway test actually walks the gating branch (tree lacked pid 200); hardcode a spaces path for the quoting test instead of deriving from PROJECT_ROOT (which only has spaces on this machine); assert found == [100] exactly; use any() instead of relying on walk order - tests: add gateway restart launcher coverage (no-supervisor fallback) - fix: log when the ancestor-gating fallback triggers (was silent -> update could hang on the venv-holder guard with no diagnostics) - refactor: narrow try to import + ancestry walk; per-ancestor cmdline errors stay "" (removes the duplicated fallback walk) - docs: note os.name==nt branch is unreachable-but-deliberate; enumerate verified gateway launch shapes on the runtime matcher - tests(proc fallback): cover _suppressed_as_ancestor keeping a gateway-runtime ancestor visible on the wmic path
|
Hi @jackulau, I saw your comments on PR #87666 — thanks for going through it so thoroughly. I also saw the cross-link to #87608 in your note; you're right that it's the sibling fix in
The full gating test suite plus related tests are green (55 passed), and I verified the tests themselves with mutation testing in both directions so they're actually catching what they claim. Would be great to hear if you think anything still needs another look. Thanks again! |
|
@mck156 - I owed you this review since you found the second site and took it on. Read the whole diff. The core reasoning is right, the Three things, in the order that will cost you time if they are left. 1. This PR has no CI at allNot "failing" - zero workflows have ever run on it, and it has been open since 2026-08-16. Every other PR at this seam shows 45-50 checks. On this repo a fork PR's workflows need a maintainer to approve the run the first time, so this is very likely waiting on that rather than anything you did wrong. Worth asking for explicitly in a comment, because right now nobody can merge it: 2. It overlaps #87608, and stacking it will save you a rebaseYour diff changes Cleanest resolution, and I am not asking for it because it is my PR:
I am fine with either. Tell me which and I will do my half. 3.
|
|
Hi @teknium1 👋 — this is my first PR to this repo, and I noticed the workflows never ran on it (0 checks so far, open since Aug 16). Since fork PRs need a maintainer to approve the first run here, could you approve it when you get a chance? It's the fix for #87594 (gateway ancestor gating), already reviewed by @jackulau . Happy to push a trivial commit if that re-triggers the approval prompt. Thanks! |
The fallback only fires when the matcher genuinely fails to import, which silently reverts to the pre-fix whole-chain skip that caused the bug. At debug level it never shows in a normal run, defeating the logging added in the parent commit. warning is appropriate: it costs nothing and fires only on a genuinely broken environment.
|
@jackulau — thanks for the thorough review, and for spelling out the three things in order of impact. That sequencing genuinely helped me prioritize. #1 CI — you were right that the blocker is the missing workflow run rather than anything in the diff. I've left a comment asking a maintainer to approve the first run on my fork PR; if that doesn't get picked up after a bit I'll push a trivial commit to re-trigger the approval prompt. #2 overlap with #87608 — I read your point about the two PRs touching the same line, and I've settled on keeping this PR's #3 log level — you were right that As for the smaller notes — I've added the Windows-only scope note and the matcher-asymmetry explanation to the body, and left the |
…ncestor-gating # Conflicts: # tests/hermes_cli/test_gateway_proc_fallback.py
main switched the wmic scan from subprocess.run to bounded_probe_run (NousResearch#87134 deadlock-safe probe), so the old subprocess.run mock never hit and the gateway-runtime ancestor test failed after the merge. Point the mock at hermes_cli._subprocess_compat.bounded_probe_run instead, matching the pattern already used in test_update_stale_dashboard.py.
|
@kshitijk4poor @OutThisLife — sorry to ping you both, but hoping one of you can help unblock this fork PR's CI. It's been open for a bit with 0 workflow checks, because fork PRs on this repo need a maintainer to approve the first run. The branch is now fully merged with latest |
|
Closing this PR — #91869 (merged) fixes #87594 with the same ancestor-carve-out at both For the record: the |
Bug Description
Follow-up to #87608. Fixes #87594. Even with the ancestor-gating fix in
_scan_gateway_pids, a/updateissued from a messaging platform can still abort with the venv-holder guard on Windows.The reason:
_venv_launcher_ancestors()inhermes_cli/update_cmd.pyhas the same unconditional ancestor-skip that #87608 fixed on the scan side.On Windows the gateway is a two-process chain:
venv\Scripts\python.exe— the launcher, which holds the.pydfiles mappedThe pause path calls
_venv_launcher_ancestors([worker_pid])to find the launcher. But itsskipset contains the whole ancestor chain — and when the update is spawned by the gateway, the launcher sits in that chain. So the launcher is hidden, stays alive during the update, and keeps the venv.pydfiles locked.Root Cause
Location:
hermes_cli/update_cmd.py, function_venv_launcher_ancestors()(around line 3418).When the gateway spawns the updater, the updater's ancestor chain is:
The launcher discovery walks parents of the worker PID — but every ancestor (including the launcher itself) is already in
skip, so the launcher is never returned.Fix
Mirror the gating idea from #87608: an ancestor whose command line looks like a gateway runtime stays visible; everything else is skipped. A fallback keeps the old whole-chain skip on any error, so behaviour is unchanged in non-gateway-spawned scenarios.
The change is scoped in
_venv_launcher_ancestors():tryto the fragile pieces only — the matcher import and the ancestry walk — instead of wrapping the whole loop (an innertryalready keeps per-ancestorcmdline()errors as""); this removes a duplicated fallback walk.subprocess.list2cmdlineon Windows so paths with spaces (e.g.C:\Program Files\...) stay quoted for theshlex-based matcher.{run, restart}matcher, skip it from theskipset so a gateway launcher stays visible.warning— it silently reverts to the buggy form otherwise, anddebugnever shows in a normal run.The same predicate keeps the two layers consistent: this PR also adds
_suppressed_as_ancestoron the scan side (per-PID gating with the command line in hand — matcher detail below).Files changed
gateway/status.py— document verified launch shapes onlooks_like_gateway_runtime_command_line, note why no fallback signal.hermes_cli/gateway.py— add_suppressed_as_ancestorto gate per-PID on the scan side.hermes_cli/update_cmd.py— fix_venv_launcher_ancestors(main change); promote fallback log towarning.tests/hermes_cli/test_gateway_proc_fallback.py— cover_suppressed_as_ancestorkeeping a gateway-runtime ancestor visible on thewmicpath.tests/hermes_cli/test_update_venv_launcher_ancestor_gating.py— fake-proc ancestry walks the gating branch, hardcoded spaces path,found == [100],any()ordering, andgateway restartno-supervisor coverage.How to Verify (reproducible)
On Windows with the gateway running as the two-process chain:
Observed on a Windows 11 box (gateway launched via the official scheduled-task VBS):
Also confirmed the first layer still works with both fixes applied:
find_gateway_pids(all_profiles=True)→[1580](no regression on #87608).Test Plan
[], after:[23668])find_gateway_pids(fix(gateway): keep an ancestor gateway visible so the update it spawned can pause it #87608 behaviour preserved)test_update_venv_launcher_ancestor_gating.py— gating-branch walk, spaces-path quoting, exact[100],any()ordering,gateway restartno-supervisor fallbacktest_gateway_proc_fallback.py—_suppressed_as_ancestorkeeps a gateway-runtime ancestor visible on thewmicpathRisk Assessment
Low — mirrors the gating already merged in #87608, using the same predicate (
looks_like_gateway_runtime_command_line). Non-gateway ancestors are still skipped exactly as before; the fallback preserves the old behaviour on any error path. The change affects only the venv-launcher discovery path and the per-PID gating on the scan side.Relationship to #87608
Decision: keep this PR's per-PID form and close #87608 as subsumed (credit above). If you'd rather #87608 land first, I'll rebase — just say so.