fix(update): pause SCM-supervised Windows gateway services before venv mutation
On Windows installs where the gateway runs as an SCM service (WinSW, NSSM, sc.exe create), the existing pause machinery kills the gateway process directly — and the service wrapper's failure ladder resurrects it within seconds, re-taking the venv file locks mid-update. The update then dies partway through dependency sync with access-denied errors. This extends _pause_windows_gateways_for_update() to detect when a gateway's process tree is owned by a running SCM service, and to stop the SERVICE through sc.exe instead of killing the child: - gateway/status.py: expose service-ownership discovery for gateway runtimes (find_windows_gateway_services maps validated gateway PIDs through process ancestry to running SCM service PIDs, with create-time identity checks against PID reuse). - hermes_cli/update_cmd.py: stop verified services via sc.exe before venv mutation and restart them afterward. Stops wait for a stable SCM 'stopped' state AND for the original descendant processes to exit (service 'Stopped' is not proof the child released its handles). Failure to prove ownership, stop a service, or restart it fails closed; rollback restores attempted services, and rollback failures are surfaced rather than swallowed. - Fail-closed throughout: unreadable identities, ambiguous ancestry, or a service that will not reach a stable state abort the update before any file mutation. Complements #37039 (gateway-only concurrent instances no longer abort): that fix lets the update proceed past the gate; this one makes the pause actually stick when the gateway is service-supervised. Note: tests/gateway/test_status.py::TestReadProcessCmdlinePsFallback:: test_ps_fallback_when_proc_unavailable fails on Windows on current main before this change as well (POSIX ps fallback asserted on a platform without it); all other touched suites pass (155 passed, 5 skipped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
SmelterLabs
|
||||
@@ -1034,6 +1034,38 @@ def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
def _strict_path_exists(path: Path, label: str) -> bool:
|
||||
try:
|
||||
path.stat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"{label} metadata is not inspectable: {exc}") from exc
|
||||
|
||||
|
||||
def _is_gateway_runtime_lock_active_strict(lock_path: Path) -> bool:
|
||||
"""Probe ownership without treating access failures as absence."""
|
||||
try:
|
||||
handle = open(lock_path, "r+", encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"gateway runtime lock is not inspectable: {exc}") from exc
|
||||
try:
|
||||
if _try_acquire_file_lock(handle):
|
||||
_release_file_lock(handle)
|
||||
return False
|
||||
return True
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"gateway runtime lock probe failed: {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_pid_file() -> None:
|
||||
"""Write the current process PID and metadata to the gateway PID file.
|
||||
|
||||
@@ -2356,6 +2388,61 @@ def get_running_pid(
|
||||
return None
|
||||
|
||||
|
||||
def get_running_pid_identity_strict(pid_path: Path) -> Optional[tuple[int, float]]:
|
||||
"""Return a verified process identity or fail on ambiguous runtime state."""
|
||||
resolved_pid_path = Path(pid_path)
|
||||
resolved_lock_path = _get_gateway_lock_path(resolved_pid_path)
|
||||
pid_exists = _strict_path_exists(resolved_pid_path, "gateway PID")
|
||||
lock_exists = _strict_path_exists(resolved_lock_path, "gateway lock")
|
||||
if not pid_exists and not lock_exists:
|
||||
return None
|
||||
if not lock_exists:
|
||||
# No runtime lock can be owned. A stale PID file is not a live gateway.
|
||||
return None
|
||||
if not _is_gateway_runtime_lock_active_strict(resolved_lock_path):
|
||||
# The lock probe is authoritative for absence. Stale or malformed files
|
||||
# may remain after a crash, but no process currently owns this runtime.
|
||||
return None
|
||||
if not pid_exists:
|
||||
raise RuntimeError("active gateway lock has no PID metadata")
|
||||
pid_record = _read_pid_record(resolved_pid_path)
|
||||
lock_record = _read_gateway_lock_record(resolved_lock_path)
|
||||
if not pid_record or not lock_record:
|
||||
raise RuntimeError("gateway PID or lock metadata is malformed")
|
||||
pid = _pid_from_record(pid_record)
|
||||
if pid is None or pid <= 0 or _pid_from_record(lock_record) != pid:
|
||||
raise RuntimeError("gateway PID and lock identities disagree")
|
||||
if not _pid_exists(pid):
|
||||
raise RuntimeError("gateway identity is not live")
|
||||
current_start = _get_process_start_time(pid)
|
||||
starts = (pid_record.get("start_time"), lock_record.get("start_time"))
|
||||
if current_start is None or any(start is None for start in starts):
|
||||
raise RuntimeError("gateway creation time is unavailable")
|
||||
try:
|
||||
current = float(current_start)
|
||||
recorded = tuple(float(start) for start in starts)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("gateway creation time is malformed") from exc
|
||||
if current <= 0 or any(start <= 0 or abs(start - current) > 0.001 for start in recorded):
|
||||
raise RuntimeError("gateway process identity changed")
|
||||
if not all(_record_matches_live_gateway_pid(record, pid) for record in (pid_record, lock_record)):
|
||||
raise RuntimeError("runtime metadata does not identify a live gateway")
|
||||
# Windows persists a centisecond fingerprint; SCM ownership checks need the
|
||||
# exact psutil epoch timestamp. Re-read it only after the persisted identity
|
||||
# has been validated, and prove it still rounds to that same fingerprint.
|
||||
if _IS_WINDOWS:
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
exact_create_time = float(psutil.Process(pid).create_time())
|
||||
except Exception as exc:
|
||||
raise RuntimeError("exact gateway creation time is unavailable") from exc
|
||||
if int(round(exact_create_time * 100)) != int(current):
|
||||
raise RuntimeError("gateway process identity changed")
|
||||
return pid, exact_create_time
|
||||
return pid, current
|
||||
|
||||
|
||||
def get_running_pid_cached(
|
||||
pid_path: Optional[Path] = None,
|
||||
*,
|
||||
|
||||
+166
-5
@@ -96,6 +96,21 @@ class ProfileGatewayProcess:
|
||||
profile: str
|
||||
path: Path
|
||||
pid: int
|
||||
create_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowsGatewayService:
|
||||
"""A real Windows service supervising a profile gateway process tree."""
|
||||
|
||||
name: str
|
||||
profile: str
|
||||
service_pid: int
|
||||
gateway_pid: int
|
||||
descendant_pids: frozenset[int]
|
||||
descendant_identities: tuple[tuple[int, float], ...]
|
||||
service_create_time: float = 0.0
|
||||
gateway_create_time: float = 0.0
|
||||
|
||||
|
||||
def _get_service_pids(all_profiles: bool = False) -> set:
|
||||
@@ -784,31 +799,177 @@ def find_gateway_pids(
|
||||
|
||||
def find_profile_gateway_processes(
|
||||
exclude_pids: set | None = None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> list[ProfileGatewayProcess]:
|
||||
"""Return running gateway PIDs mapped to Hermes profiles via PID files."""
|
||||
_exclude = set(exclude_pids or set())
|
||||
processes: list[ProfileGatewayProcess] = []
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
from gateway.status import get_running_pid, get_running_pid_identity_strict
|
||||
from hermes_cli.profiles import list_profiles
|
||||
except Exception:
|
||||
if strict:
|
||||
raise
|
||||
return processes
|
||||
|
||||
seen: set[int] = set()
|
||||
for profile in list_profiles():
|
||||
try:
|
||||
profiles = list_profiles()
|
||||
except Exception:
|
||||
if strict:
|
||||
raise
|
||||
return processes
|
||||
for profile in profiles:
|
||||
try:
|
||||
pid = get_running_pid(profile.path / "gateway.pid", cleanup_stale=False)
|
||||
except Exception:
|
||||
if strict:
|
||||
identity = get_running_pid_identity_strict(profile.path / "gateway.pid")
|
||||
pid = identity[0] if identity else None
|
||||
create_time = identity[1] if identity else 0.0
|
||||
else:
|
||||
pid = get_running_pid(profile.path / "gateway.pid", cleanup_stale=False)
|
||||
create_time = 0.0
|
||||
except Exception as exc:
|
||||
if strict:
|
||||
raise RuntimeError(
|
||||
f"Could not inspect gateway PID for profile {profile.name}"
|
||||
) from exc
|
||||
continue
|
||||
if pid is None or pid <= 0 or pid in _exclude or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
processes.append(
|
||||
ProfileGatewayProcess(profile=profile.name, path=profile.path, pid=pid)
|
||||
ProfileGatewayProcess(
|
||||
profile=profile.name,
|
||||
path=profile.path,
|
||||
pid=pid,
|
||||
create_time=create_time,
|
||||
)
|
||||
)
|
||||
return processes
|
||||
|
||||
|
||||
def find_windows_gateway_services(
|
||||
*,
|
||||
psutil_module=None,
|
||||
profile_processes: list[ProfileGatewayProcess] | None = None,
|
||||
) -> list[WindowsGatewayService]:
|
||||
"""Find profile gateways supervised by real Windows services.
|
||||
|
||||
Service-logon processes can deny the interactive Desktop access to their
|
||||
command lines. The updater can still identify them without guessing: a
|
||||
validated profile gateway PID comes from Hermes's own PID file, and its
|
||||
parent chain terminates at a running SCM service PID. The complete service
|
||||
subtree is returned so the Desktop preflight exempts only processes the CLI
|
||||
updater will stop through the Service Control Manager.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
try:
|
||||
if psutil_module is None:
|
||||
import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415
|
||||
if profile_processes is None:
|
||||
profile_processes = find_profile_gateway_processes(strict=True)
|
||||
service_names_by_pid: dict[int, set[str]] = {}
|
||||
for service in psutil_module.win_service_iter():
|
||||
try:
|
||||
if all(
|
||||
callable(getattr(service, field, None))
|
||||
for field in ("name", "status", "pid")
|
||||
):
|
||||
service_name = str(service.name() or "")
|
||||
service_status = service.status()
|
||||
service_pid = int(service.pid() or 0)
|
||||
else:
|
||||
data = service.as_dict()
|
||||
service_name = str(data.get("name") or "")
|
||||
service_status = data.get("status")
|
||||
service_pid = int(data.get("pid") or 0)
|
||||
except FileNotFoundError:
|
||||
# The service was deleted between enumeration and inspection;
|
||||
# it cannot still supervise a live gateway tree.
|
||||
continue
|
||||
except Exception as exc:
|
||||
raise RuntimeError("SCM service inspection failed") from exc
|
||||
if not service_name:
|
||||
raise RuntimeError("SCM service has an empty name")
|
||||
if service_status == "stopped":
|
||||
continue
|
||||
if service_status != "running":
|
||||
raise RuntimeError(
|
||||
f"SCM service {service_name} has indeterminate status: {service_status}"
|
||||
)
|
||||
if service_pid <= 0:
|
||||
raise RuntimeError(
|
||||
f"Running SCM service {service_name} has no valid process ID"
|
||||
)
|
||||
service_names_by_pid.setdefault(service_pid, set()).add(service_name)
|
||||
except Exception as exc:
|
||||
raise RuntimeError("SCM service enumeration failed") from exc
|
||||
|
||||
found: dict[str, WindowsGatewayService] = {}
|
||||
for profile_process in profile_processes:
|
||||
try:
|
||||
gateway_process = psutil_module.Process(int(profile_process.pid))
|
||||
gateway_create_time = float(gateway_process.create_time())
|
||||
if profile_process.create_time <= 0 or abs(
|
||||
gateway_create_time - profile_process.create_time
|
||||
) > 0.001:
|
||||
raise RuntimeError("Gateway process identity changed during SCM discovery")
|
||||
ancestor_pids = [int(parent.pid) for parent in gateway_process.parents()]
|
||||
shared_service_pids = [
|
||||
pid
|
||||
for pid in ancestor_pids
|
||||
if len(service_names_by_pid.get(pid, set())) > 1
|
||||
]
|
||||
if shared_service_pids:
|
||||
raise RuntimeError(
|
||||
"Gateway ownership is ambiguous under shared SCM host PID(s): "
|
||||
+ ", ".join(str(pid) for pid in shared_service_pids)
|
||||
)
|
||||
service_pid = next(
|
||||
(
|
||||
pid
|
||||
for pid in ancestor_pids
|
||||
if len(service_names_by_pid.get(pid, set())) == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
if service_pid is None:
|
||||
continue
|
||||
service_name = next(iter(service_names_by_pid[service_pid]))
|
||||
service_process = psutil_module.Process(service_pid)
|
||||
service_create_time = float(service_process.create_time())
|
||||
descendant_processes = service_process.children(recursive=True)
|
||||
descendants = frozenset(int(child.pid) for child in descendant_processes)
|
||||
if int(profile_process.pid) not in descendants:
|
||||
continue
|
||||
descendant_identities = tuple(
|
||||
sorted(
|
||||
(int(child.pid), float(child.create_time()))
|
||||
for child in descendant_processes
|
||||
)
|
||||
)
|
||||
found[service_name] = WindowsGatewayService(
|
||||
name=service_name,
|
||||
profile=str(profile_process.profile),
|
||||
service_pid=service_pid,
|
||||
gateway_pid=int(profile_process.pid),
|
||||
descendant_pids=descendants,
|
||||
descendant_identities=descendant_identities,
|
||||
service_create_time=service_create_time,
|
||||
gateway_create_time=gateway_create_time,
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Could not determine SCM ownership for gateway profile "
|
||||
f"{profile_process.profile}"
|
||||
) from exc
|
||||
return [found[name] for name in sorted(found)]
|
||||
|
||||
|
||||
def _gateway_run_args_for_profile(profile: str) -> list[str]:
|
||||
args = [get_python_path(), "-m", "hermes_cli.main"]
|
||||
if profile != "default":
|
||||
|
||||
+415
-39
@@ -5033,6 +5033,173 @@ def _desktop_owns_gateway_lifecycle() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _stop_windows_gateway_service(
|
||||
name: str,
|
||||
*,
|
||||
expected_processes: tuple[tuple[int, float], ...] = (),
|
||||
expected_service_identity: tuple[int, float] | None = None,
|
||||
expected_gateway_identity: tuple[int, float] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Stop one verified Windows service and wait until SCM reports it down."""
|
||||
import psutil # noqa: PLC0415
|
||||
|
||||
service = psutil.win_service_get(name)
|
||||
if expected_service_identity is not None:
|
||||
try:
|
||||
current_status = str(service.status())
|
||||
current_service_pid = int(service.pid() or 0)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} SCM identity is unavailable before stop"
|
||||
) from exc
|
||||
if current_status != "running":
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} is not stably running before stop: {current_status}"
|
||||
)
|
||||
if current_service_pid != int(expected_service_identity[0]):
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} SCM process identity changed before stop"
|
||||
)
|
||||
for label, identity in (
|
||||
("service", expected_service_identity),
|
||||
("gateway", expected_gateway_identity),
|
||||
):
|
||||
if identity is None:
|
||||
continue
|
||||
pid, create_time = identity
|
||||
try:
|
||||
current = float(psutil.Process(int(pid)).create_time())
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Windows {label} process identity is unavailable before stop"
|
||||
) from exc
|
||||
if abs(current - float(create_time)) > 0.001:
|
||||
raise RuntimeError(
|
||||
f"Windows {label} process identity changed before stop"
|
||||
)
|
||||
if expected_service_identity is not None and expected_gateway_identity is not None:
|
||||
service_pid = int(expected_service_identity[0])
|
||||
gateway_pid = int(expected_gateway_identity[0])
|
||||
try:
|
||||
ancestor_pids = {
|
||||
int(parent.pid) for parent in psutil.Process(gateway_pid).parents()
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Windows gateway ancestry is unavailable before service stop"
|
||||
) from exc
|
||||
if service_pid not in ancestor_pids:
|
||||
raise RuntimeError(
|
||||
f"Windows gateway is no longer owned by service {name}"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["sc.exe", "stop", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 and service.status() != "stopped":
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
raise RuntimeError(detail or f"sc.exe stop failed with {result.returncode}")
|
||||
|
||||
def _original_process_is_alive(pid: int, create_time: float) -> bool:
|
||||
try:
|
||||
current = float(psutil.Process(pid).create_time())
|
||||
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
# A vanished process is clear.
|
||||
return False
|
||||
except Exception:
|
||||
# AccessDenied or any unknown probe failure stays fail-closed
|
||||
# because the venv may still be locked.
|
||||
return True
|
||||
return abs(current - create_time) <= 0.001
|
||||
|
||||
alive = [
|
||||
pid
|
||||
for pid, create_time in expected_processes
|
||||
if _original_process_is_alive(pid, create_time)
|
||||
]
|
||||
deadline = _time.monotonic() + timeout
|
||||
while _time.monotonic() < deadline:
|
||||
service_stopped = service.status() == "stopped"
|
||||
alive = [
|
||||
pid
|
||||
for pid, create_time in expected_processes
|
||||
if _original_process_is_alive(pid, create_time)
|
||||
]
|
||||
if service_stopped and not alive:
|
||||
return
|
||||
_time.sleep(0.2)
|
||||
if service.status() == "stopped":
|
||||
# We only return if the original processes have also exited their identity.
|
||||
# A lingering process with a matching creation time means the venv mutation
|
||||
# must not proceed — fail closed.
|
||||
alive_after_stop = [
|
||||
pid
|
||||
for pid, create_time in expected_processes
|
||||
if _original_process_is_alive(pid, create_time)
|
||||
]
|
||||
if alive_after_stop:
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} stopped but its process tree is still alive: "
|
||||
f"{alive_after_stop}"
|
||||
)
|
||||
return
|
||||
# If we reach here, the timeout elapsed without the service reaching a stable stopped state
|
||||
# while its original descendants are still alive. Fail closed — venv mutation is unsafe.
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} did not stop within {timeout:.0f}s; venv mutation unsafe."
|
||||
)
|
||||
|
||||
|
||||
def _start_windows_gateway_service(name: str, *, timeout: float = 30.0) -> None:
|
||||
"""Start one previously paused Windows service and verify it is running."""
|
||||
import psutil # noqa: PLC0415
|
||||
|
||||
service = psutil.win_service_get(name)
|
||||
result = subprocess.run(
|
||||
["sc.exe", "start", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 and service.status() != "running":
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
raise RuntimeError(detail or f"sc.exe start failed with {result.returncode}")
|
||||
deadline = _time.monotonic() + timeout
|
||||
while _time.monotonic() < deadline:
|
||||
if service.status() == "running":
|
||||
return
|
||||
_time.sleep(0.2)
|
||||
raise RuntimeError(f"Windows service {name} did not start within {timeout:.0f}s")
|
||||
|
||||
|
||||
def _restore_windows_gateway_service(name: str, *, timeout: float = 60.0) -> None:
|
||||
"""Restore a service after an uncertain stop, including STOP_PENDING."""
|
||||
import psutil # noqa: PLC0415
|
||||
|
||||
service = psutil.win_service_get(name)
|
||||
deadline = _time.monotonic() + timeout
|
||||
while _time.monotonic() < deadline:
|
||||
status = service.status()
|
||||
if status == "running":
|
||||
return
|
||||
if status == "stopped":
|
||||
_start_windows_gateway_service(name)
|
||||
return
|
||||
_time.sleep(0.2)
|
||||
raise RuntimeError(
|
||||
f"Windows service {name} did not reach a restorable state within {timeout:.0f}s"
|
||||
)
|
||||
|
||||
|
||||
def _pause_windows_gateways_for_update() -> dict | None:
|
||||
"""Stop running Windows gateways before mutating the checkout or venv.
|
||||
|
||||
@@ -5051,16 +5218,45 @@ def _pause_windows_gateways_for_update() -> dict | None:
|
||||
_get_restart_drain_timeout,
|
||||
find_gateway_pids,
|
||||
find_profile_gateway_processes,
|
||||
find_windows_gateway_services,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not prepare Windows gateway pause for update: %s", exc)
|
||||
return None
|
||||
raise RuntimeError(
|
||||
f"Could not prepare Windows gateway pause for update: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
running_pids = list(dict.fromkeys(find_gateway_pids(all_profiles=True)))
|
||||
profile_process_list = find_profile_gateway_processes(strict=True)
|
||||
profile_processes = {proc.pid: proc for proc in profile_process_list}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not discover Windows gateway PIDs before update: %s", exc)
|
||||
return None
|
||||
raise RuntimeError(
|
||||
f"Could not map Windows gateway PIDs to profiles: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
service_gateways = find_windows_gateway_services(
|
||||
profile_processes=profile_process_list
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not determine Windows gateway service ownership: {exc}"
|
||||
) from exc
|
||||
|
||||
service_gateway_pids = {int(service.gateway_pid) for service in service_gateways}
|
||||
try:
|
||||
running_pids = list(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*find_gateway_pids(all_profiles=True),
|
||||
*sorted(profile_processes),
|
||||
*sorted(service_gateway_pids),
|
||||
]
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not discover Windows gateway PIDs before update: {exc}"
|
||||
) from exc
|
||||
if not running_pids:
|
||||
# No gateway is running right now, but the user may have installed an
|
||||
# autostart entry (Scheduled Task or Startup-folder login item) — that
|
||||
@@ -5107,18 +5303,12 @@ def _pause_windows_gateways_for_update() -> dict | None:
|
||||
)
|
||||
return None
|
||||
|
||||
profile_processes = {}
|
||||
try:
|
||||
profile_processes = {
|
||||
proc.pid: proc for proc in find_profile_gateway_processes()
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not map Windows gateway PIDs to profiles: %s", exc)
|
||||
|
||||
profiles: dict[str, int] = {}
|
||||
mapped_pids = []
|
||||
socket_acks: list[dict] = []
|
||||
for pid in running_pids:
|
||||
if pid in service_gateway_pids:
|
||||
continue
|
||||
proc = profile_processes.get(pid)
|
||||
if proc is None:
|
||||
continue
|
||||
@@ -5183,7 +5373,11 @@ def _pause_windows_gateways_for_update() -> dict | None:
|
||||
mapped_pids,
|
||||
timeout=drain_timeout,
|
||||
)
|
||||
unmapped_pids = [pid for pid in running_pids if pid not in profile_processes]
|
||||
unmapped_pids = [
|
||||
pid
|
||||
for pid in running_pids
|
||||
if pid not in profile_processes and pid not in service_gateway_pids
|
||||
]
|
||||
|
||||
# Snapshot each unmapped gateway's command line *before* we force-kill it,
|
||||
# so ``_resume_windows_gateways_after_update`` can respawn it by replaying
|
||||
@@ -5228,14 +5422,76 @@ def _pause_windows_gateways_for_update() -> dict | None:
|
||||
# denied, already gone): those still need a manual restart.
|
||||
print(" Restart manually after update: hermes gateway run")
|
||||
|
||||
return {
|
||||
token = {
|
||||
"resume_needed": True,
|
||||
"profiles": profiles,
|
||||
"unmapped_pids": unmapped_pids,
|
||||
"unmapped": unmapped,
|
||||
}
|
||||
|
||||
def _cold_start_windows_gateway_after_update() -> None:
|
||||
# Stop SCM-supervised gateways only after every fallible preparation step
|
||||
# for ordinary gateways is complete. From this point to return, any error
|
||||
# restores both the attempted services and the already-paused ordinary
|
||||
# gateways before aborting the update.
|
||||
paused_services = []
|
||||
current_service_name = None
|
||||
try:
|
||||
for service in service_gateways:
|
||||
current_service_name = str(service.name)
|
||||
_stop_windows_gateway_service(
|
||||
current_service_name,
|
||||
expected_processes=tuple(
|
||||
getattr(service, "descendant_identities", ())
|
||||
),
|
||||
expected_service_identity=(
|
||||
int(service.service_pid),
|
||||
float(service.service_create_time),
|
||||
),
|
||||
expected_gateway_identity=(
|
||||
int(service.gateway_pid),
|
||||
float(service.gateway_create_time),
|
||||
),
|
||||
)
|
||||
paused_services.append(current_service_name)
|
||||
current_service_name = None
|
||||
if paused_services:
|
||||
token["services"] = paused_services
|
||||
token["expected_services"] = list(paused_services)
|
||||
token["restarted_services"] = []
|
||||
token["service_profiles"] = {
|
||||
str(service.name): str(service.profile)
|
||||
for service in service_gateways
|
||||
if str(service.name) in paused_services
|
||||
}
|
||||
print(
|
||||
" ✓ Paused Windows gateway service(s): "
|
||||
+ ", ".join(paused_services)
|
||||
)
|
||||
return token
|
||||
except Exception as exc:
|
||||
restore_names = []
|
||||
if current_service_name:
|
||||
restore_names.append(current_service_name)
|
||||
restore_names.extend(reversed(paused_services))
|
||||
rollback_failures = []
|
||||
for service_name in dict.fromkeys(restore_names):
|
||||
try:
|
||||
_restore_windows_gateway_service(service_name)
|
||||
except Exception as restore_exc:
|
||||
rollback_failures.append(f"{service_name}: {restore_exc}")
|
||||
if profiles or unmapped:
|
||||
try:
|
||||
_resume_windows_gateways_after_update(token)
|
||||
except Exception as restore_exc:
|
||||
rollback_failures.append(f"ordinary gateways: {restore_exc}")
|
||||
failed_service = current_service_name or "unknown"
|
||||
detail = f"Could not stop Windows gateway service {failed_service}: {exc}"
|
||||
if rollback_failures:
|
||||
detail += "; rollback failures: " + "; ".join(rollback_failures)
|
||||
raise RuntimeError(detail) from exc
|
||||
|
||||
|
||||
def _cold_start_windows_gateway_after_update() -> bool:
|
||||
"""Start a fresh detached gateway after update when one is installed but down.
|
||||
|
||||
Invoked from ``_resume_windows_gateways_after_update`` for the
|
||||
@@ -5258,45 +5514,57 @@ def _cold_start_windows_gateway_after_update() -> None:
|
||||
unconditionally from the returned PID.
|
||||
"""
|
||||
if not _m()._is_windows():
|
||||
return
|
||||
return True
|
||||
try:
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load Windows gateway cold-start helpers: %s", exc)
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Could not load Windows gateway cold-start helpers: {exc}"
|
||||
) from exc
|
||||
|
||||
# Re-check liveness right before spawning — between pause and resume the
|
||||
# autostart entry may have already brought a gateway up, or a leftover
|
||||
# process may have re-registered. Don't double-start.
|
||||
try:
|
||||
if list(find_gateway_pids(all_profiles=True)):
|
||||
return
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Could not re-check gateway liveness before cold-start: %s", exc)
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Could not re-check gateway liveness before cold-start: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
if _desktop_owns_gateway_lifecycle():
|
||||
logger.debug(
|
||||
"Skipping Windows gateway cold-start: Desktop owns gateway lifecycle"
|
||||
)
|
||||
return
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not re-check Desktop gateway-lifecycle ownership before cold-start: %s",
|
||||
exc,
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Could not re-check Desktop gateway-lifecycle ownership before cold-start: "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
pid = gateway_windows._spawn_detached()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not cold-start Windows gateway after update: %s", exc)
|
||||
return
|
||||
raise RuntimeError(f"Could not cold-start Windows gateway after update: {exc}") from exc
|
||||
|
||||
if not pid:
|
||||
raise RuntimeError("Windows gateway cold-start did not return a process ID")
|
||||
ready_pids = gateway_windows._wait_for_gateway_ready()
|
||||
if not ready_pids:
|
||||
raise RuntimeError(
|
||||
f"Windows gateway cold-start PID {pid} did not become ready"
|
||||
)
|
||||
print()
|
||||
print(
|
||||
"✓ Gateway started via cold-start after update "
|
||||
f"(PID: {', '.join(map(str, ready_pids))})"
|
||||
)
|
||||
return True
|
||||
|
||||
if pid:
|
||||
print()
|
||||
gateway_windows._report_gateway_start(f"cold-start after update (PID {pid})")
|
||||
|
||||
def _for_each_systemd_gateway_unit(
|
||||
list_units_stdout: str,
|
||||
@@ -5979,21 +6247,60 @@ def _resume_windows_gateways_after_update(token: dict | None) -> None:
|
||||
"""Restart Windows profile gateways previously paused for update."""
|
||||
if not token or not token.get("resume_needed"):
|
||||
return
|
||||
token["resume_needed"] = False
|
||||
if not _m()._is_windows():
|
||||
token["resume_needed"] = False
|
||||
return
|
||||
|
||||
# Regenerate the persisted launcher scripts before respawning anything,
|
||||
# so a legacy pythonw-era Scheduled Task / Startup entry comes back on
|
||||
# the current hidden-console design at the next login too.
|
||||
# current hidden-console design at the next login too.
|
||||
_m()._refresh_windows_gateway_launchers()
|
||||
|
||||
services = list(token.get("services") or [])
|
||||
token.setdefault("expected_services", list(services))
|
||||
verified_restarts = list(token.get("restarted_services") or [])
|
||||
restarted_services = []
|
||||
failed_services = []
|
||||
for service_name in services:
|
||||
try:
|
||||
_start_windows_gateway_service(str(service_name))
|
||||
restarted_services.append(str(service_name))
|
||||
if str(service_name) not in verified_restarts:
|
||||
verified_restarts.append(str(service_name))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not restart Windows gateway service %s after update: %s",
|
||||
service_name,
|
||||
exc,
|
||||
)
|
||||
print(f" ⚠ Could not restart Windows gateway service: {service_name}")
|
||||
failed_services.append(str(service_name))
|
||||
|
||||
if failed_services:
|
||||
token["services"] = failed_services
|
||||
token["restarted_services"] = verified_restarts
|
||||
raise RuntimeError(
|
||||
"Could not restart Windows gateway service(s): "
|
||||
+ ", ".join(failed_services)
|
||||
)
|
||||
token["services"] = []
|
||||
token["restarted_services"] = verified_restarts
|
||||
if restarted_services:
|
||||
print()
|
||||
print(
|
||||
" ✓ Restarted Windows gateway service(s): "
|
||||
+ ", ".join(restarted_services)
|
||||
)
|
||||
|
||||
profiles = token.get("profiles") or {}
|
||||
unmapped = token.get("unmapped") or []
|
||||
cold_start = bool(token.get("cold_start_if_installed"))
|
||||
if not profiles and not any(u.get("argv") for u in unmapped):
|
||||
if cold_start:
|
||||
_m()._cold_start_windows_gateway_after_update()
|
||||
if not _m()._cold_start_windows_gateway_after_update():
|
||||
raise RuntimeError("Windows gateway cold-start was not verified")
|
||||
token["cold_start_if_installed"] = False
|
||||
token["resume_needed"] = False
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -6002,20 +6309,25 @@ def _resume_windows_gateways_after_update(token: dict | None) -> None:
|
||||
launch_detached_profile_gateway_restart,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load Windows gateway restart helper: %s", exc)
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Could not load Windows gateway restart helper: {exc}"
|
||||
) from exc
|
||||
|
||||
relaunched = []
|
||||
failed_profiles = {}
|
||||
for profile, old_pid in sorted(profiles.items()):
|
||||
try:
|
||||
if launch_detached_profile_gateway_restart(str(profile), int(old_pid)):
|
||||
relaunched.append(str(profile))
|
||||
else:
|
||||
failed_profiles[str(profile)] = int(old_pid)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not restart Windows gateway profile %s after update: %s",
|
||||
profile,
|
||||
exc,
|
||||
)
|
||||
failed_profiles[str(profile)] = int(old_pid)
|
||||
|
||||
# Surface the outcome on the token (#91277 Phase 2 plan-vs-execution
|
||||
# reconciliation): the git-based update path's fleet reconciliation
|
||||
@@ -6034,20 +6346,31 @@ def _resume_windows_gateways_after_update(token: dict | None) -> None:
|
||||
# Respawn unmapped gateways (no profile→PID-file mapping, e.g. a Scheduled
|
||||
# Task) by replaying the argv we snapshotted before force-killing them.
|
||||
unmapped_relaunched = 0
|
||||
failed_unmapped = []
|
||||
for entry in unmapped:
|
||||
argv = entry.get("argv")
|
||||
old_pid = entry.get("pid")
|
||||
if not argv or not old_pid:
|
||||
failed_unmapped.append(entry)
|
||||
continue
|
||||
try:
|
||||
if launch_detached_gateway_restart_by_cmdline(int(old_pid), list(argv)):
|
||||
unmapped_relaunched += 1
|
||||
else:
|
||||
failed_unmapped.append(entry)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not restart unmapped Windows gateway (pid %s) after update: %s",
|
||||
old_pid,
|
||||
exc,
|
||||
)
|
||||
failed_unmapped.append(entry)
|
||||
|
||||
token["profiles"] = failed_profiles
|
||||
token["unmapped"] = failed_unmapped
|
||||
if failed_profiles or failed_unmapped:
|
||||
raise RuntimeError("Could not restart every paused Windows gateway")
|
||||
token["resume_needed"] = False
|
||||
|
||||
if relaunched:
|
||||
print()
|
||||
@@ -8087,6 +8410,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_write_gateway_update_exit_code(desktop_build_ok)
|
||||
|
||||
gateway_fleet_restart_incomplete = False
|
||||
gateway_restart_phase_errors: list[str] = []
|
||||
# Snapshot of gateways running before we touch anything. Stays empty
|
||||
# until we successfully import the probe and are about to stop/drain —
|
||||
# so an exception raised before we touch any gateway keeps this empty
|
||||
@@ -8865,6 +9189,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Gateway restart during update failed: %s", e)
|
||||
gateway_restart_phase_errors.append(str(e))
|
||||
# An exception escaping the whole phase means the drain/restart
|
||||
# output the user relies on never printed. Don't let that pass for
|
||||
# a clean update: surface it and treat the fleet as stale unless we
|
||||
@@ -8953,8 +9278,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
if _windows_gateway_resume:
|
||||
try:
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
except Exception as _windows_resume_exc:
|
||||
gateway_fleet_restart_incomplete = True
|
||||
gateway_restart_phase_errors.append(str(_windows_resume_exc))
|
||||
print(
|
||||
" ⚠ Windows gateway service restart incomplete: "
|
||||
f"{_windows_resume_exc}"
|
||||
)
|
||||
if gateway_mode:
|
||||
_exit_code_path = get_hermes_home() / ".update_exit_code"
|
||||
try:
|
||||
_exit_code_path.write_text("1", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if isinstance(_windows_gateway_resume, dict):
|
||||
# Feed Windows's own pause/resume outcome into the same
|
||||
# relaunched_profiles bookkeeping the systemd/launchd restart
|
||||
# phase populates, so the #91277 Phase 2 reconciliation below
|
||||
@@ -8976,6 +9316,40 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"reconciliation bookkeeping: %s",
|
||||
_win_reconcile_exc,
|
||||
)
|
||||
windows_restarted = list(
|
||||
_windows_gateway_resume.get("restarted_services") or []
|
||||
)
|
||||
for service_name in windows_restarted:
|
||||
if service_name not in restarted_services:
|
||||
restarted_services.append(service_name)
|
||||
service_profiles = _windows_gateway_resume.get("service_profiles") or {}
|
||||
for service_name in windows_restarted:
|
||||
profile_name = service_profiles.get(service_name)
|
||||
if profile_name and profile_name not in relaunched_profiles:
|
||||
relaunched_profiles.append(profile_name)
|
||||
pending_services = list(_windows_gateway_resume.get("services") or [])
|
||||
for service_name in pending_services:
|
||||
label = str(service_profiles.get(service_name) or service_name)
|
||||
if label not in failed_or_stale_units:
|
||||
failed_or_stale_units.append(label)
|
||||
|
||||
try:
|
||||
from hermes_cli.update_receipt import record_gateway_restart
|
||||
|
||||
record_gateway_restart(
|
||||
restarted_services=restarted_services,
|
||||
relaunched_profiles=relaunched_profiles,
|
||||
externally_supervised_profiles=externally_supervised_profiles,
|
||||
killed_pids=sorted(killed_pids),
|
||||
failed_units=failed_or_stale_units,
|
||||
incomplete=(
|
||||
gateway_fleet_restart_incomplete
|
||||
or bool(failed_or_stale_units)
|
||||
),
|
||||
phase_error="; ".join(gateway_restart_phase_errors) or None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Warn if legacy Hermes gateway unit files are still installed.
|
||||
# When both hermes.service (from a pre-rename install) and the
|
||||
@@ -9271,6 +9645,8 @@ def _fleet_probe_expected_runtimes(
|
||||
if isinstance(windows_resume_token, dict) and (
|
||||
windows_resume_token.get("profiles")
|
||||
or windows_resume_token.get("unmapped")
|
||||
or windows_resume_token.get("services")
|
||||
or windows_resume_token.get("expected_services")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -78,8 +78,16 @@ class UpdatePlan:
|
||||
return payload
|
||||
|
||||
|
||||
def _detect_supervisor_for_pid(pid: int, service_pids: set) -> str:
|
||||
def _detect_supervisor_for_pid(
|
||||
pid: int, service_pids: set, windows_service_pids: set | None = None
|
||||
) -> str:
|
||||
"""Classify how a live gateway PID is supervised."""
|
||||
if windows_service_pids and pid in windows_service_pids:
|
||||
# SCM-supervised Windows gateway (WinSW/NSSM/sc.exe create): the
|
||||
# update pause machinery stops the SERVICE via sc.exe instead of
|
||||
# killing the child, so #91277 Phase 2 reconciliation must plan it
|
||||
# under its own mechanism id, not "manual".
|
||||
return "windows-service"
|
||||
if pid in service_pids:
|
||||
try:
|
||||
from hermes_cli.gateway import is_macos, supports_systemd_services
|
||||
@@ -109,6 +117,8 @@ def _restart_mechanism(supervisor: str, profile: str) -> str:
|
||||
return "launchd"
|
||||
if supervisor == "desktop":
|
||||
return "desktop"
|
||||
if supervisor == "windows-service":
|
||||
return "windows-service"
|
||||
if supervisor == "manual-serve":
|
||||
return "respawn-argv"
|
||||
return "manual"
|
||||
@@ -122,6 +132,8 @@ def describe_restart_mechanism(mechanism: str, profile: str) -> str:
|
||||
return "launchctl kickstart -k (drain-first, per-label domain)"
|
||||
if mechanism == "desktop":
|
||||
return "Desktop app respawns its serve backend"
|
||||
if mechanism == "windows-service":
|
||||
return "sc.exe stop before venv mutation, sc.exe start after update"
|
||||
if mechanism == "respawn-argv":
|
||||
return "stop before code swap, relaunch with recorded launch args"
|
||||
if profile != "default":
|
||||
@@ -215,6 +227,23 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
except Exception as exc:
|
||||
logger.debug("Service-PID probe failed: %s", exc)
|
||||
|
||||
# --- SCM-supervised gateway PIDs (Windows) ------------------------------
|
||||
# find_windows_gateway_services() maps validated gateway PIDs through
|
||||
# process ancestry to running SCM service PIDs (no-op off Windows). The
|
||||
# update's pause phase stops these via `sc.exe stop` / restarts via
|
||||
# `sc.exe start`, so the plan must carry the matching mechanism id for
|
||||
# the #91277 Phase 2 reconciliation and the fleet check.
|
||||
windows_service_pids: set = set()
|
||||
try:
|
||||
from hermes_cli.gateway import find_windows_gateway_services
|
||||
|
||||
windows_service_pids = {
|
||||
int(service.gateway_pid)
|
||||
for service in find_windows_gateway_services()
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("Windows SCM service-ownership probe failed: %s", exc)
|
||||
|
||||
# --- per-profile gateways (PID files + runtime status stamps) ----------
|
||||
seen_pids: set[int] = set()
|
||||
try:
|
||||
@@ -247,7 +276,9 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
supervisor = (
|
||||
str(declared)
|
||||
if declared
|
||||
else _detect_supervisor_for_pid(sock_pid, service_pids)
|
||||
else _detect_supervisor_for_pid(
|
||||
sock_pid, service_pids, windows_service_pids
|
||||
)
|
||||
)
|
||||
sock_sha = identity.get("code_sha")
|
||||
plan.runtimes.append(
|
||||
@@ -275,7 +306,9 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
if pid is None or not _pid_exists(pid):
|
||||
continue
|
||||
seen_pids.add(pid)
|
||||
supervisor = _detect_supervisor_for_pid(pid, service_pids)
|
||||
supervisor = _detect_supervisor_for_pid(
|
||||
pid, service_pids, windows_service_pids
|
||||
)
|
||||
plan.runtimes.append(
|
||||
RuntimeRecord(
|
||||
kind="gateway",
|
||||
@@ -298,7 +331,9 @@ def collect_runtime_inventory() -> UpdatePlan:
|
||||
if proc.pid in seen_pids:
|
||||
continue
|
||||
seen_pids.add(proc.pid)
|
||||
supervisor = _detect_supervisor_for_pid(proc.pid, service_pids)
|
||||
supervisor = _detect_supervisor_for_pid(
|
||||
proc.pid, service_pids, windows_service_pids
|
||||
)
|
||||
plan.runtimes.append(
|
||||
RuntimeRecord(
|
||||
kind="gateway",
|
||||
|
||||
@@ -1387,3 +1387,53 @@ class TestResolveGatewayLiveness:
|
||||
# profile's live gateway from being reported as this profile's.
|
||||
assert seen["expected_home"] == profile_dir
|
||||
|
||||
|
||||
def test_strict_gateway_identity_returns_none_for_confirmed_absence(tmp_path):
|
||||
assert status.get_running_pid_identity_strict(tmp_path / "gateway.pid") is None
|
||||
|
||||
|
||||
def test_strict_gateway_identity_raises_when_metadata_stat_is_denied(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
original_stat = Path.stat
|
||||
|
||||
def denied_stat(self, *args, **kwargs):
|
||||
if self == pid_path:
|
||||
raise PermissionError("denied")
|
||||
return original_stat(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", denied_stat)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not inspectable"):
|
||||
status.get_running_pid_identity_strict(pid_path)
|
||||
|
||||
|
||||
def test_strict_gateway_identity_raises_on_malformed_active_metadata(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
lock_path = tmp_path / "gateway.lock"
|
||||
pid_path.write_text("bad", encoding="utf-8")
|
||||
lock_path.write_text("bad", encoding="utf-8")
|
||||
monkeypatch.setattr(status, "_get_gateway_lock_path", lambda _path=None: lock_path)
|
||||
monkeypatch.setattr(status, "_is_gateway_runtime_lock_active_strict", lambda _path=None: True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="malformed"):
|
||||
status.get_running_pid_identity_strict(pid_path)
|
||||
|
||||
|
||||
def test_strict_gateway_identity_rejects_reused_pid(tmp_path, monkeypatch):
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
lock_path = tmp_path / "gateway.lock"
|
||||
record = {"pid": 123, "start_time": 10.0, "kind": "hermes-gateway"}
|
||||
pid_path.write_text(json.dumps(record), encoding="utf-8")
|
||||
lock_path.write_text(json.dumps(record), encoding="utf-8")
|
||||
monkeypatch.setattr(status, "_get_gateway_lock_path", lambda _path=None: lock_path)
|
||||
monkeypatch.setattr(status, "_is_gateway_runtime_lock_active_strict", lambda _path=None: True)
|
||||
monkeypatch.setattr(status, "_pid_exists", lambda _pid: True)
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda _pid: 20.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="identity changed"):
|
||||
status.get_running_pid_identity_strict(pid_path)
|
||||
|
||||
|
||||
@@ -1007,3 +1007,151 @@ class TestWindowsScheduledTaskSupervisorGuard:
|
||||
monkeypatch.setattr(gateway, "_windows_scheduled_task_state", lambda name, s=state: s)
|
||||
assert gateway._windows_scheduled_task_supervises("Hermes_Gateway") is expected, state
|
||||
assert gateway._windows_scheduled_task_running("Hermes_Gateway") is (state == "Running")
|
||||
|
||||
|
||||
def test_find_windows_gateway_services_maps_verified_pid_tree(monkeypatch):
|
||||
"""Only an SCM service whose subtree contains a validated gateway PID is returned."""
|
||||
monkeypatch.setattr(gateway.sys, "platform", "win32")
|
||||
profile = SimpleNamespace(profile="default", pid=300, create_time=300.0)
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, name, pid):
|
||||
self.name = name
|
||||
self.pid = pid
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"pid": self.pid,
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
def parents(self):
|
||||
return [FakeProcess(200), FakeProcess(100)]
|
||||
|
||||
def children(self, recursive=False):
|
||||
assert self.pid == 100
|
||||
assert recursive is True
|
||||
return [FakeProcess(200), FakeProcess(300)]
|
||||
|
||||
def create_time(self):
|
||||
return float(self.pid)
|
||||
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_iter=lambda: [
|
||||
FakeService("HermesGateway", 100),
|
||||
FakeService("UnrelatedService", 900),
|
||||
],
|
||||
Process=FakeProcess,
|
||||
)
|
||||
|
||||
result = gateway.find_windows_gateway_services(
|
||||
psutil_module=fake_psutil,
|
||||
profile_processes=[profile],
|
||||
)
|
||||
|
||||
assert result == [
|
||||
gateway.WindowsGatewayService(
|
||||
name="HermesGateway",
|
||||
profile="default",
|
||||
service_pid=100,
|
||||
gateway_pid=300,
|
||||
descendant_pids=frozenset({200, 300}),
|
||||
descendant_identities=((200, 200.0), (300, 300.0)),
|
||||
service_create_time=100.0,
|
||||
gateway_create_time=300.0,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_find_windows_gateway_services_rejects_shared_service_host_pid(monkeypatch):
|
||||
"""A shared host PID cannot prove which service owns the gateway subtree."""
|
||||
monkeypatch.setattr(gateway.sys, "platform", "win32")
|
||||
profile = SimpleNamespace(profile="default", pid=300, create_time=300.0)
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def as_dict(self):
|
||||
return {"name": self.name, "pid": 100, "status": "running"}
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
def parents(self):
|
||||
return [FakeProcess(100)]
|
||||
|
||||
def children(self, recursive=False):
|
||||
return [FakeProcess(300)]
|
||||
|
||||
def create_time(self):
|
||||
return float(self.pid)
|
||||
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_iter=lambda: [FakeService("ServiceA"), FakeService("ServiceB")],
|
||||
Process=FakeProcess,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="shared SCM host"):
|
||||
gateway.find_windows_gateway_services(
|
||||
psutil_module=fake_psutil,
|
||||
profile_processes=[profile],
|
||||
)
|
||||
|
||||
|
||||
def test_find_windows_gateway_services_fails_closed_on_service_access_error(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(gateway.sys, "platform", "win32")
|
||||
profile = SimpleNamespace(profile="default", pid=300, create_time=300.0)
|
||||
|
||||
class InaccessibleService:
|
||||
def as_dict(self):
|
||||
raise PermissionError("access denied")
|
||||
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_iter=lambda: [InaccessibleService()],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SCM"):
|
||||
gateway.find_windows_gateway_services(
|
||||
psutil_module=fake_psutil,
|
||||
profile_processes=[profile],
|
||||
)
|
||||
|
||||
|
||||
def test_find_windows_gateway_services_fails_closed_when_scm_scan_is_indeterminate(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(gateway.sys, "platform", "win32")
|
||||
profile = SimpleNamespace(profile="default", pid=300, create_time=300.0)
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_iter=lambda: (_ for _ in ()).throw(OSError("SCM unavailable")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SCM"):
|
||||
gateway.find_windows_gateway_services(
|
||||
psutil_module=fake_psutil,
|
||||
profile_processes=[profile],
|
||||
)
|
||||
|
||||
|
||||
def test_find_profile_gateway_processes_strict_propagates_profile_listing_failure(
|
||||
monkeypatch,
|
||||
):
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
profiles_mod,
|
||||
"list_profiles",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("profile listing failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="profile listing failed"):
|
||||
gateway.find_profile_gateway_processes(strict=True)
|
||||
|
||||
@@ -40,11 +40,56 @@ def test_mechanism_ids_are_machine_readable_and_described():
|
||||
assert _restart_mechanism("launchd", "work") == "launchd"
|
||||
assert _restart_mechanism("desktop", "default") == "desktop"
|
||||
assert _restart_mechanism("manual", "work") == "manual"
|
||||
assert _restart_mechanism("windows-service", "default") == "windows-service"
|
||||
# display derives FROM the id
|
||||
assert "systemctl" in describe_restart_mechanism("systemd", "default")
|
||||
assert "kickstart" in describe_restart_mechanism("launchd", "work")
|
||||
assert "-p work" in describe_restart_mechanism("manual", "work")
|
||||
assert describe_restart_mechanism("manual", "default") == "hermes gateway restart"
|
||||
assert "sc.exe" in describe_restart_mechanism("windows-service", "default")
|
||||
|
||||
|
||||
def test_windows_service_supervisor_classification():
|
||||
from hermes_cli.update_inventory import _detect_supervisor_for_pid
|
||||
|
||||
# An SCM-owned gateway PID classifies as windows-service even when the
|
||||
# generic service-PID probe also knows the pid.
|
||||
assert (
|
||||
_detect_supervisor_for_pid(41, set(), {41}) == "windows-service"
|
||||
)
|
||||
assert (
|
||||
_detect_supervisor_for_pid(41, {41}, {41}) == "windows-service"
|
||||
)
|
||||
# Without SCM ownership the existing classification is untouched.
|
||||
assert _detect_supervisor_for_pid(42, set(), set()) == "manual"
|
||||
assert _detect_supervisor_for_pid(42, set(), None) == "manual"
|
||||
|
||||
|
||||
def test_windows_service_runtime_reconciles_via_service_profiles():
|
||||
# The update path merges the pause token's service_profiles into
|
||||
# relaunched_profiles after sc.exe start — a restarted SCM gateway
|
||||
# must not trip the unaccounted tripwire.
|
||||
outcomes = match_runtime_outcomes(
|
||||
_plan(_rt("default", 500, supervisor="windows-service")),
|
||||
restarted_services=["hermes-gateway"], relaunched_profiles=["default"],
|
||||
externally_supervised_profiles=[], killed_pids=set(), failed_units=[],
|
||||
)
|
||||
assert outcomes == [
|
||||
{"kind": "gateway", "profile": "default", "pid": 500,
|
||||
"mechanism": "windows-service", "outcome": "restarted"}
|
||||
]
|
||||
assert report_unaccounted_runtimes(outcomes) is False
|
||||
|
||||
|
||||
def test_windows_service_runtime_unaccounted_when_restart_fails():
|
||||
outcomes = match_runtime_outcomes(
|
||||
_plan(_rt("work", 501, supervisor="windows-service")),
|
||||
restarted_services=[], relaunched_profiles=[],
|
||||
externally_supervised_profiles=[], killed_pids=set(), failed_units=[],
|
||||
)
|
||||
assert outcomes[0]["mechanism"] == "windows-service"
|
||||
assert outcomes[0]["outcome"] == "unaccounted"
|
||||
assert report_unaccounted_runtimes(outcomes) is True
|
||||
|
||||
|
||||
def test_relaunched_profile_is_restarted():
|
||||
|
||||
@@ -10,6 +10,8 @@ same as every other ``_spawn_detached`` caller.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import gateway as hermes_gateway
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli import main as cli_main
|
||||
@@ -37,11 +39,11 @@ def _run_cold_start(monkeypatch, capsys, *, surviving_pids):
|
||||
return capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cold_start_reports_failure_when_process_does_not_survive(monkeypatch, capsys):
|
||||
out = _run_cold_start(monkeypatch, capsys, surviving_pids=[])
|
||||
def test_cold_start_raises_when_process_does_not_survive(monkeypatch, capsys):
|
||||
with pytest.raises(RuntimeError, match="did not become ready"):
|
||||
_run_cold_start(monkeypatch, capsys, surviving_pids=[])
|
||||
|
||||
assert "✓ Starting Windows gateway after update" not in out
|
||||
assert "no process detected" in out
|
||||
assert "✓ Starting Windows gateway after update" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cold_start_reports_success_when_process_survives(monkeypatch, capsys):
|
||||
|
||||
@@ -204,6 +204,9 @@ def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
|
||||
profile_proc = SimpleNamespace(profile="work", path=profile_home, pid=101)
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [101, 202])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_windows_gateway_services", lambda **_k: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_profile_gateway_processes",
|
||||
@@ -262,6 +265,292 @@ def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
|
||||
assert "Restart manually after update" not in captured
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_and_resume_windows_gateway_service(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""A real Windows service is stopped before venv mutation and restarted
|
||||
afterward instead of spawning a competing detached gateway."""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
profile_home = tmp_path / "profiles" / "default"
|
||||
profile_home.mkdir(parents=True)
|
||||
profile_proc = SimpleNamespace(profile="default", path=profile_home, pid=101)
|
||||
service = SimpleNamespace(
|
||||
name="HermesGateway",
|
||||
profile="default",
|
||||
service_pid=11,
|
||||
service_create_time=11.0,
|
||||
gateway_pid=101,
|
||||
gateway_create_time=101.0,
|
||||
descendant_pids=frozenset({11, 22, 101}),
|
||||
descendant_identities=((22, 22.0), (101, 101.0)),
|
||||
)
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_profile_gateway_processes", lambda **_k: [profile_proc]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_windows_gateway_services",
|
||||
lambda **_k: [service],
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(gateway_mod, "_get_restart_drain_timeout", lambda: 0.1)
|
||||
|
||||
stopped = []
|
||||
started = []
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_stop_windows_gateway_service",
|
||||
lambda name, **_kwargs: stopped.append(name),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_start_windows_gateway_service",
|
||||
lambda name: started.append(name),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(cli_main, "_refresh_windows_gateway_launchers", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
cli_main,
|
||||
"_cold_start_windows_gateway_after_update",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("service resume must not cold-start")),
|
||||
)
|
||||
|
||||
token = cli_main._pause_windows_gateways_for_update()
|
||||
assert token == {
|
||||
"resume_needed": True,
|
||||
"profiles": {},
|
||||
"unmapped_pids": [],
|
||||
"unmapped": [],
|
||||
"services": ["HermesGateway"],
|
||||
"expected_services": ["HermesGateway"],
|
||||
"restarted_services": [],
|
||||
"service_profiles": {"HermesGateway": "default"},
|
||||
}
|
||||
assert stopped == ["HermesGateway"]
|
||||
|
||||
cli_main._resume_windows_gateways_after_update(token)
|
||||
assert started == ["HermesGateway"]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_windows_gateway_service_failure_restores_every_attempted_service(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A service that times out after accepting stop is restarted too."""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
services = [
|
||||
SimpleNamespace(name="HermesGateway", service_pid=11, service_create_time=11.0, gateway_pid=101, gateway_create_time=101.0, descendant_identities=()),
|
||||
SimpleNamespace(name="HermesGatewayPicasso", service_pid=22, service_create_time=22.0, gateway_pid=202, gateway_create_time=202.0, descendant_identities=()),
|
||||
]
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_windows_gateway_services", lambda **_k: services
|
||||
)
|
||||
|
||||
def fake_stop(name, **_kwargs):
|
||||
if name == "HermesGatewayPicasso":
|
||||
raise RuntimeError("simulated stop timeout")
|
||||
|
||||
restarted = []
|
||||
monkeypatch.setattr(update_cmd, "_stop_windows_gateway_service", fake_stop)
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_restore_windows_gateway_service",
|
||||
lambda name: restarted.append(name),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="HermesGatewayPicasso"):
|
||||
cli_main._pause_windows_gateways_for_update()
|
||||
|
||||
assert restarted == ["HermesGatewayPicasso", "HermesGateway"]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_windows_gateway_service_surfaces_rollback_start_failure(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
services = [
|
||||
SimpleNamespace(name="HermesGateway", service_pid=11, service_create_time=11.0, gateway_pid=101, gateway_create_time=101.0, descendant_identities=()),
|
||||
SimpleNamespace(name="HermesGatewayPicasso", service_pid=22, service_create_time=22.0, gateway_pid=202, gateway_create_time=202.0, descendant_identities=()),
|
||||
]
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_windows_gateway_services", lambda **_k: services
|
||||
)
|
||||
|
||||
def fake_stop(name, **_kwargs):
|
||||
if name == "HermesGatewayPicasso":
|
||||
raise RuntimeError("simulated stop timeout")
|
||||
|
||||
def fake_start(name):
|
||||
if name == "HermesGateway":
|
||||
raise RuntimeError("simulated rollback start failure")
|
||||
|
||||
monkeypatch.setattr(update_cmd, "_stop_windows_gateway_service", fake_stop)
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_restore_windows_gateway_service",
|
||||
fake_start,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="rollback failures: HermesGateway"):
|
||||
cli_main._pause_windows_gateways_for_update()
|
||||
|
||||
|
||||
def test_restore_windows_gateway_service_waits_out_stop_pending(monkeypatch):
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
statuses = iter(["stop_pending", "stopped"])
|
||||
service = SimpleNamespace(status=lambda: next(statuses))
|
||||
fake_psutil = SimpleNamespace(win_service_get=lambda _name: service)
|
||||
restarted = []
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
monkeypatch.setattr(update_cmd._time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_start_windows_gateway_service",
|
||||
lambda name: restarted.append(name),
|
||||
)
|
||||
|
||||
update_cmd._restore_windows_gateway_service("HermesGateway")
|
||||
|
||||
assert restarted == ["HermesGateway"]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_windows_gateways_aborts_when_service_discovery_is_indeterminate(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_windows_gateway_services",
|
||||
lambda **_k: (_ for _ in ()).throw(RuntimeError("SCM scan indeterminate")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_gateway_pids",
|
||||
lambda **_k: (_ for _ in ()).throw(
|
||||
AssertionError("ordinary gateway teardown must not begin")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SCM scan indeterminate"):
|
||||
cli_main._pause_windows_gateways_for_update()
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_windows_gateways_aborts_when_gateway_pid_discovery_is_indeterminate(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "find_windows_gateway_services", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_gateway_pids",
|
||||
lambda **_k: (_ for _ in ()).throw(RuntimeError("PID discovery failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="PID discovery failed"):
|
||||
cli_main._pause_windows_gateways_for_update()
|
||||
|
||||
|
||||
def test_stop_windows_gateway_service_waits_for_original_descendants(
|
||||
monkeypatch,
|
||||
):
|
||||
"""SCM STOPPED is insufficient while the original process identity lives."""
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
service = SimpleNamespace(status=lambda: "stopped")
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_get=lambda _name: service,
|
||||
Process=lambda pid: SimpleNamespace(create_time=lambda: 12.5),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
monkeypatch.setattr(
|
||||
update_cmd.subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="process tree"):
|
||||
update_cmd._stop_windows_gateway_service(
|
||||
"HermesGateway",
|
||||
expected_processes=((123, 12.5),),
|
||||
timeout=0,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_resume_windows_gateway_service_failure_stays_retryable(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
token = {
|
||||
"resume_needed": True,
|
||||
"profiles": {},
|
||||
"unmapped": [],
|
||||
"services": ["HermesGateway"],
|
||||
}
|
||||
monkeypatch.setattr(cli_main, "_refresh_windows_gateway_launchers", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
update_cmd,
|
||||
"_start_windows_gateway_service",
|
||||
lambda _name: (_ for _ in ()).throw(RuntimeError("simulated start failure")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="HermesGateway"):
|
||||
cli_main._resume_windows_gateways_after_update(token)
|
||||
|
||||
assert token["resume_needed"] is True
|
||||
assert token["services"] == ["HermesGateway"]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_resume_windows_gateway_launcher_refresh_failure_stays_retryable(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
):
|
||||
token = {
|
||||
"resume_needed": True,
|
||||
"profiles": {},
|
||||
"unmapped": [],
|
||||
"services": ["HermesGateway"],
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
cli_main,
|
||||
"_refresh_windows_gateway_launchers",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("refresh failed")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="refresh failed"):
|
||||
cli_main._resume_windows_gateways_after_update(token)
|
||||
|
||||
assert token["resume_needed"] is True
|
||||
assert token["services"] == ["HermesGateway"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# venv-side launcher ancestors (the uv launcher/worker split)
|
||||
#
|
||||
@@ -370,6 +659,9 @@ def test_pause_kill_set_covers_venv_guard_abort_set(
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [worker_pid])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_windows_gateway_services", lambda **_k: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "find_profile_gateway_processes", lambda **_k: [profile_proc]
|
||||
)
|
||||
@@ -720,5 +1012,26 @@ def test_update_gate_still_aborts_on_non_gateway_concurrent(
|
||||
assert "--force" in captured
|
||||
|
||||
|
||||
def test_stop_service_refuses_pid_reuse_before_sc_stop(monkeypatch):
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
|
||||
fake_psutil = SimpleNamespace(
|
||||
win_service_get=lambda _name: SimpleNamespace(
|
||||
status=lambda: "running", pid=lambda: 11
|
||||
),
|
||||
Process=lambda _pid: SimpleNamespace(create_time=lambda: 99.0),
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
monkeypatch.setattr(update_cmd.subprocess, "run", lambda *_a, **_k: calls.append(True))
|
||||
|
||||
with pytest.raises(RuntimeError, match="identity changed"):
|
||||
update_cmd._stop_windows_gateway_service(
|
||||
"HermesGateway", expected_service_identity=(11, 11.0)
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ class TestEmptySnapshotFailClosed:
|
||||
_fleet_probe_expected_runtimes(None, [], token, [], set()) is True
|
||||
)
|
||||
|
||||
def test_incomplete_when_windows_resume_token_has_services(self):
|
||||
token = {
|
||||
"resume_needed": False,
|
||||
"profiles": {},
|
||||
"unmapped": [],
|
||||
"services": ["HermesGateway"],
|
||||
}
|
||||
assert (
|
||||
_fleet_probe_expected_runtimes(None, [], token, [], set()) is True
|
||||
)
|
||||
|
||||
def test_incomplete_when_restart_phase_touched_gateways(self):
|
||||
# The original #93410 signal still counts.
|
||||
assert (
|
||||
|
||||
@@ -87,6 +87,9 @@ def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch):
|
||||
def test_pause_skips_cold_start_plan_when_desktop_owns_lifecycle(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
hermes_gateway, "find_windows_gateway_services", lambda **_k: []
|
||||
)
|
||||
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True)
|
||||
|
||||
@@ -96,6 +99,9 @@ def test_pause_skips_cold_start_plan_when_desktop_owns_lifecycle(monkeypatch):
|
||||
def test_pause_still_cold_starts_when_autostart_and_no_desktop_owner(monkeypatch):
|
||||
monkeypatch.setattr(cli_main, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: [])
|
||||
monkeypatch.setattr(
|
||||
hermes_gateway, "find_windows_gateway_services", lambda **_k: []
|
||||
)
|
||||
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False)
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ reconciliation runs (mirrored here directly, since driving the full
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway
|
||||
import hermes_cli.main as hm
|
||||
from hermes_cli.update_cmd import _resume_windows_gateways_after_update
|
||||
@@ -77,7 +79,11 @@ def test_resume_omits_profiles_whose_relaunch_failed(monkeypatch):
|
||||
|
||||
token = _token({"default": 1111, "work": 2222})
|
||||
with patch("builtins.print"):
|
||||
_resume_windows_gateways_after_update(token)
|
||||
# Fail-closed contract: a profile whose relaunch failed
|
||||
# raises so the update is marked incomplete (the caller catches,
|
||||
# records the phase error, and exits 1 in gateway mode).
|
||||
with pytest.raises(RuntimeError, match="Could not restart every paused"):
|
||||
_resume_windows_gateways_after_update(token)
|
||||
|
||||
assert token["relaunched_profiles"] == ["default"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user