242 lines
12 KiB
Python
242 lines
12 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
|
|
|
|
# The historical updater entrypoints. `_historical_context` reads capture data
|
|
# from these frames; `tools.lazy_deps.install_specs` hands off only inside them.
|
|
# Keep in sync with scripts/audit-old-updater-imports.py UPDATE_ENTRYPOINTS: the
|
|
# audit list is wider on purpose (helpers run *under* these frames and are found
|
|
# by the frame walk).
|
|
_HISTORICAL_UPDATE_FRAMES = (
|
|
("hermes_cli.update_cmd", "hermes_cli.main"),
|
|
("_cmd_update_impl", "cmd_update"),
|
|
)
|
|
|
|
|
|
def _is_historical_update_frame(frame: Any) -> bool:
|
|
modules, names = _HISTORICAL_UPDATE_FRAMES
|
|
return (frame.f_globals.get("__name__") in modules
|
|
and frame.f_code.co_name in names)
|
|
|
|
|
|
def in_historical_update() -> bool:
|
|
"""True when an ancestor frame is a historical updater entrypoint.
|
|
|
|
The current tree's updater sets ``_hermes_current_updater_frame`` in its
|
|
entrypoints; no historical on-disk version contains that local, so its
|
|
presence proves the caller is current code that must keep running rather
|
|
than hand control to a takeover child.
|
|
"""
|
|
frame = sys._getframe(1)
|
|
try:
|
|
while frame is not None:
|
|
if (_is_historical_update_frame(frame)
|
|
and "_hermes_current_updater_frame" not in frame.f_code.co_varnames):
|
|
return True
|
|
frame = frame.f_back
|
|
finally:
|
|
del frame
|
|
return False
|
|
|
|
|
|
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 _is_historical_update_frame(frame)
|
|
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.
|
|
# ASCII only, like every line printed before the child starts: a Windows pipe encodes this
|
|
# process's stdout in the ANSI code page, and a non-cp1252 character raised here, before the
|
|
# takeover ever ran (review S1).
|
|
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")
|
|
code = _run_in_custody(
|
|
[sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/_update_takeover.py"),
|
|
str(context), str(result)], root, 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 code if code >= 0 else 1, completed
|
|
|
|
|
|
def _run_in_custody(argv: list, root: Path, **kwargs) -> int:
|
|
"""Run the takeover (it syncs and builds the checkout) in this updater's checkout custody,
|
|
from the update_lock module ALREADY loaded here (nothing new is imported). Windows: created
|
|
suspended, bound to this updater's kill-on-close job, then resumed; a refused bind still runs
|
|
(the update committed) and the takeover joins the lock holding its own lease. Releases
|
|
without these helpers hold no checkout lock, and the takeover acquires it itself."""
|
|
lock = sys.modules.get("hermes_cli.update_lock")
|
|
bind, resume = (getattr(lock, name, None) for name in ("bind_child_to_update_tree", "resume_suspended_child"))
|
|
if os.name != "nt" or bind is None or resume is None or getattr(lock, "_HELD", None) is None:
|
|
# Unbounded for the same reason as the Windows wait below (review L7): the takeover is
|
|
# the rest of a committed update (sync + build), and it holds the checkout lock fd, so the
|
|
# checkout stays locked for as long as it runs, even past this updater's death.
|
|
# health: allow HX006 -- unbounded by design: the update's own supervised child
|
|
return subprocess.run(argv, **kwargs, **_checkout_custody(root)).returncode # noqa: PLW1510 -- forwarding wrapper: callers pass subprocess kwargs via **kwargs (check may arrive through it); an explicit check=False would raise TypeError
|
|
proc = subprocess.Popen(argv, creationflags=lock.CREATE_SUSPENDED, **kwargs)
|
|
try:
|
|
refusal = bind(proc)
|
|
if refusal is not None:
|
|
print(f" ! The update's job would not take the takeover ({refusal}); it runs outside the job, "
|
|
"holding its own checkout lease.", flush=True)
|
|
resume(proc)
|
|
# The takeover is the rest of a committed update (sync + build): a bound would abandon it
|
|
# mid-write. It dies with this updater (kill-on-close job, or the except path below).
|
|
# health: allow HX006 -- unbounded by design: the update's own supervised child
|
|
return proc.wait()
|
|
except BaseException:
|
|
proc.kill()
|
|
raise
|
|
|
|
|
|
def _checkout_custody(root: Path) -> dict:
|
|
"""The takeover child keeps the checkout lock this (old) updater holds: its fd, read from the
|
|
update_lock module ALREADY loaded in this process (nothing new is imported here). Releases
|
|
without a checkout lock hold none, and the takeover acquires it itself."""
|
|
held = getattr(sys.modules.get("hermes_cli.update_lock"), "checkout_lock_fds", None)
|
|
try:
|
|
fds = tuple(held(root)) if held is not None and os.name == "posix" else ()
|
|
except Exception: # health: allow BLE001 -- any older release's checkout_lock_fds; no fds = takeover locks itself
|
|
fds = ()
|
|
return {"pass_fds": fds} if fds else {}
|
|
|
|
|
|
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. It names its stop on
|
|
# the receipt the loaded update_receipt keeps open (an older one lacks the call: no-op).
|
|
record = getattr(sys.modules.get("hermes_cli.update_receipt"), "record_stop_reason", None)
|
|
if record is not None:
|
|
record("old_version_handoff")
|
|
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)
|