A desktop hand-off on Windows stalled at "Another Hermes update is already running (process <the hand-off itself>)". The hand-off PowerShell claims the marker and runs the old `hermes update`, whose _old_updater shim spawns _update_takeover.py as python -I -S -B. HERMES_UPDATE_HANDOFF_PID names the old updater, not the marker owner, so adoption relies on the ancestry walk; without psutil (-S) that walk falls back to /proc or ps, and Windows has neither, so the grandchild refused its own orchestrator and exited 2. Read the parent pid from a Toolhelp32 snapshot through stdlib ctypes, and treat a parent created after its child as a recycled pid, as psutil does. Reproduced and verified on a real Windows venv: the grandchild now adopts its ancestor's marker and still refuses an unrelated live holder. The regression tests were unmarked, and the Windows lane imports only files carrying a platforms marker, so the Windows half never ran: mark both stdlib-walk tests for every lane. Also print a line when the old updater hands off to the new (PM) updater, so logs show where the switch happened.
159 lines
7.1 KiB
Python
159 lines
7.1 KiB
Python
"""The newly imported escape hatch from an already-running historical updater.
|
|
|
|
This module must remain stdlib-only: the parent still has the old interpreter,
|
|
modules and native extensions. Only the child imports the updated application.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, NoReturn
|
|
|
|
|
|
_result: int | None = None
|
|
|
|
|
|
def _historical_context() -> tuple[dict, list[dict], Any]:
|
|
"""Carry data already held by old frames, without importing their modules.
|
|
|
|
Older callers did not pass a continuation object. Read only the historical
|
|
updater's known local names; do not serialize frames or arbitrary globals.
|
|
"""
|
|
names = ("had_desktop_app_before_update", "pre_update_snapshot_id",
|
|
"pre_update_version", "gateway_mode", "assume_yes", "_pre_update_plan")
|
|
found = {}
|
|
resumes = []
|
|
restart_update = None
|
|
frame = sys._getframe(1)
|
|
try:
|
|
while frame is not None:
|
|
# Capture hooks can be imported BEFORE the old updater has pulled.
|
|
# A declared-but-unassigned version in its innermost known frame
|
|
# proves early entry; a None value is still a post-capture value.
|
|
if (restart_update is None
|
|
and frame.f_globals.get("__name__") in ("hermes_cli.update_cmd", "hermes_cli.main")
|
|
and frame.f_code.co_name in ("_cmd_update_impl", "cmd_update")
|
|
and "pre_update_version" in frame.f_code.co_varnames):
|
|
restart_update = "pre_update_version" not in frame.f_locals
|
|
for name in names:
|
|
if name not in found and name in frame.f_locals:
|
|
found[name] = frame.f_locals[name]
|
|
token = frame.f_locals.get("_windows_gateway_resume")
|
|
if isinstance(token, dict) and not any(token is item for item in resumes):
|
|
resumes.append(token)
|
|
frame = frame.f_back
|
|
finally:
|
|
del frame
|
|
receipt_module = sys.modules.get("hermes_cli.update_receipt")
|
|
receipt_slot = vars(receipt_module).get("_current") if receipt_module else None
|
|
get_current = getattr(receipt_slot, "get", None)
|
|
current = get_current() if get_current is not None else receipt_slot
|
|
receipt = getattr(current, "data", None)
|
|
plan = found.get("_pre_update_plan")
|
|
if dataclasses.is_dataclass(plan) and not isinstance(plan, type):
|
|
plan = dataclasses.asdict(plan)
|
|
elif not isinstance(plan, dict):
|
|
plan = receipt.get("plan") if isinstance(receipt, dict) else None
|
|
return {
|
|
"restart_update": restart_update is True,
|
|
# Some old updaters reach this hook before checking Desktop at all.
|
|
"desktop": found.get("had_desktop_app_before_update"),
|
|
"pre_update_snapshot_id": found.get("pre_update_snapshot_id"),
|
|
"pre_update_version": found.get("pre_update_version"),
|
|
"gateway_mode": bool(found.get("gateway_mode", "--gateway" in sys.argv)),
|
|
"assume_yes": bool(found.get("assume_yes", "--yes" in sys.argv)),
|
|
"windows_resume": resumes[0] if resumes else None,
|
|
"plan": plan,
|
|
"receipt": receipt if isinstance(receipt, dict) else None,
|
|
}, resumes, receipt_slot
|
|
|
|
|
|
def _run_child(request: dict) -> tuple[int, dict]:
|
|
"""One JSON exchange; the old process never imports the updated graph."""
|
|
root = Path(__file__).resolve().parents[1]
|
|
home = os.environ.get("HERMES_HOME")
|
|
constants = sys.modules.get("hermes_constants")
|
|
override = vars(constants).get("_HERMES_HOME_OVERRIDE") if constants else None
|
|
if override is not None:
|
|
value = override.get()
|
|
if isinstance(value, (str, Path)) and value:
|
|
home = str(value)
|
|
request.update(root=str(root), home=home, argv=list(sys.argv))
|
|
env = {key: value for key, value in os.environ.items()
|
|
if not key.startswith(("PYTHON", "UV_")) and key != "VIRTUAL_ENV"}
|
|
if home:
|
|
env["HERMES_HOME"] = home
|
|
env.setdefault("HERMES_UPDATE_HANDOFF_PID", str(os.getpid()))
|
|
# Everything the user saw so far came from the OLD updater; say so before the
|
|
# new one (package manager) takes over, so logs show where the switch happened.
|
|
print("→ Handing off to the new updater (package manager) for the rest of this update...", flush=True)
|
|
with tempfile.TemporaryDirectory(prefix="hermes-update-takeover-") as directory:
|
|
context = Path(directory) / "request.json"
|
|
result = Path(directory) / "result.json"
|
|
context.write_text(json.dumps(request), encoding="utf-8")
|
|
child = subprocess.run(
|
|
[sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/_update_takeover.py"),
|
|
str(context), str(result)], cwd=root, env=env,
|
|
)
|
|
completed = json.loads(result.read_text(encoding="utf-8-sig")) if result.is_file() else {}
|
|
if not isinstance(completed, dict):
|
|
raise ValueError("invalid update takeover acknowledgement")
|
|
return child.returncode if child.returncode >= 0 else 1, completed
|
|
|
|
|
|
def stop_for_relaunch(*, incomplete: bool = False) -> NoReturn:
|
|
"""Finish in a fresh child, then exit rather than resume an old fallback.
|
|
|
|
The historical name is retained. A finally/atexit path can call another
|
|
shim while unwinding; it must receive the same result, not start again.
|
|
"""
|
|
if incomplete:
|
|
# A newly retired completion hook has no complete captured worklist.
|
|
# It must not start another update or invent a successful receipt.
|
|
print(
|
|
"You're updating from an older version of Hermes Agent. "
|
|
"To complete this update, run `hermes update` again.",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(1)
|
|
global _result
|
|
if _result is not None:
|
|
raise SystemExit(_result)
|
|
_result = 1
|
|
try:
|
|
request, resumes, receipt_slot = _historical_context()
|
|
_result, completed = _run_child(request)
|
|
if completed.get("resume_handled"):
|
|
for token in resumes:
|
|
token["resume_needed"] = False
|
|
if completed.get("receipt_handled"):
|
|
if hasattr(receipt_slot, "set"):
|
|
receipt_slot.set(None)
|
|
elif receipt_slot is not None:
|
|
vars(sys.modules["hermes_cli.update_receipt"])["_current"] = None
|
|
except (OSError, ValueError, TypeError) as exc:
|
|
_result = 1
|
|
print(f"Update takeover failed: {exc}", file=sys.stderr, flush=True)
|
|
raise SystemExit(_result)
|
|
|
|
|
|
def relaunch_stopped_serves(token: dict) -> None:
|
|
"""A historical atexit token is separate work, not another whole update."""
|
|
if not token.get("pending"):
|
|
return
|
|
code = 1
|
|
try:
|
|
code, completed = _run_child({"stopped_serves": token})
|
|
if completed.get("serves_handled") is True:
|
|
token["pending"] = False
|
|
except (OSError, ValueError, TypeError) as exc:
|
|
print(f"Stopped serve recovery failed: {exc}", file=sys.stderr, flush=True)
|
|
if code or token.get("pending"):
|
|
print("Stopped serve recovery did not complete; restart the affected "
|
|
"serve/dashboard backends manually.", file=sys.stderr, flush=True)
|