Files
hermes-agent/tests/hermes_cli/test_local_runtime_processes.py

236 lines
9.1 KiB
Python

"""Native containment tests; all processes and homes are disposable."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import psutil
import pytest
_SCRIPT = r'''
import json, os, subprocess, sys, time
from pathlib import Path
import psutil
root = Path(sys.argv[1])
role = sys.argv[2]
def record(name):
p = psutil.Process()
target = root / (name + '.json')
temp = target.with_suffix('.tmp')
temp.write_text(json.dumps({'pid': p.pid, 'created': p.create_time()}))
temp.replace(target)
record(role)
if role == 'owner':
from hermes_cli.local_runtime.processes import spawn_server
proc, job = spawn_server([sys.executable, __file__, str(root), 'router'],
close_fds=False)
(root / 'ready').write_text('ready')
while not (root / 'stop').exists():
time.sleep(.02)
job.close()
job.close()
proc.wait(timeout=10)
(root / 'closed').write_text('closed')
while not (root / 'exit').exists():
time.sleep(.02)
elif role == 'router':
subprocess.Popen([sys.executable, __file__, str(root), 'grandchild'])
time.sleep(90)
else:
time.sleep(90)
'''
def _wait(predicate, timeout=10):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(.03)
return bool(predicate())
def _read(path):
assert _wait(path.exists), f"missing child receipt: {path}"
return json.loads(path.read_text())
def _alive(identity):
try:
proc = psutil.Process(identity['pid'])
if proc.create_time() != identity['created']:
return False
try:
proc.wait(timeout=0)
return False
except psutil.TimeoutExpired:
return True
except psutil.NoSuchProcess:
return False
def _kill(identity):
if _alive(identity):
proc = psutil.Process(identity['pid'])
proc.kill()
proc.wait(timeout=10)
@pytest.mark.windows_only
@pytest.mark.parametrize('stop_mode', ['graceful', 'abrupt'])
@pytest.mark.parametrize('nested', [False, True])
def test_owner_exit_kills_router_tree_not_external(tmp_path, stop_mode, nested):
script = tmp_path / 'disposable server.py'
script.write_text(_SCRIPT)
env = dict(os.environ, HERMES_HOME=str(tmp_path / 'home'),
PYTHONPATH=str(Path(__file__).resolve().parents[2]))
launchers = []
outer_job = None
with (tmp_path / 'children.log').open('w') as log:
try:
for role in ('control', 'owner'):
cmd = [sys.executable, str(script), str(tmp_path), role]
if nested and role == 'owner':
from hermes_cli.local_runtime.processes import spawn_server
launcher, outer_job = spawn_server(cmd, env=env, stdout=log, stderr=log)
else:
launcher = subprocess.Popen(cmd, env=env, stdout=log, stderr=log)
launchers.append(launcher)
owner = _read(tmp_path / 'owner.json')
router = _read(tmp_path / 'router.json')
grandchild = _read(tmp_path / 'grandchild.json')
control = _read(tmp_path / 'control.json')
assert _wait((tmp_path / 'ready').exists)
assert all(_alive(i) for i in (owner, router, grandchild, control))
print('disposable identities:', stop_mode, owner, router, grandchild, control)
if stop_mode == 'graceful':
(tmp_path / 'stop').write_text('stop')
assert _wait((tmp_path / 'closed').exists), 'close killed the owner itself'
assert _alive(owner)
assert _wait(lambda: not _alive(router) and not _alive(grandchild))
assert _alive(control)
(tmp_path / 'exit').write_text('exit')
else:
_kill(owner) # The venv launcher is not necessarily the owner.
assert _wait(lambda: not _alive(owner))
assert _wait(lambda: not _alive(router) and not _alive(grandchild)), (
'uncontained router/grandchild survived owner exit', router, grandchild)
assert _alive(control), 'unrelated external control was terminated'
finally:
if outer_job is not None:
outer_job.close()
# Stop only identities/descendants created by this test, including red runs.
descendants = []
for launcher in launchers:
try:
descendants.extend(psutil.Process(launcher.pid).children(recursive=True))
except psutil.NoSuchProcess:
pass
for path in tmp_path.glob('*.json'):
_kill(json.loads(path.read_text()))
for proc in reversed(descendants):
_kill({'pid': proc.pid, 'created': proc.create_time()})
for launcher in launchers:
if launcher.poll() is None:
launcher.kill()
launcher.wait(timeout=10)
@pytest.mark.windows_only
@pytest.mark.parametrize('failure', ['assign', 'resume', 'popen', 'configure'])
def test_failed_setup_never_runs_child_and_releases_handles(tmp_path, monkeypatch, failure):
import ctypes
from ctypes import wintypes
from hermes_cli.local_runtime import processes
marker = tmp_path / 'child executed'
jobs, children, handles = [], [], []
real_init = processes._WindowsJob.__init__
real_assign = processes._WindowsJob.assign
def track_job(job):
jobs.append(job)
real_init(job)
handles.append(job._handle)
if failure == 'configure':
real_dll = ctypes.WinDLL
def failed_config_dll(*args, **kwargs):
api = real_dll(*args, **kwargs)
def fail_config(handle, *args):
handles.append(handle)
raise KeyboardInterrupt('injected cancellation configuring job')
api.SetInformationJobObject = fail_config
return api
monkeypatch.setattr(ctypes, 'WinDLL', failed_config_dll)
def assign(job, proc):
children.append(proc)
assert psutil.Process(proc.pid).status() == psutil.STATUS_STOPPED
assert not marker.exists()
# Query the actual kernel object, not implementation source/constants.
limits = processes._ExtendedLimits()
query = job._api.QueryInformationJobObject
query.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p,
wintypes.DWORD, ctypes.c_void_p]
query.restype = wintypes.BOOL
assert query(job._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits), None)
flags = limits.BasicLimitInformation.LimitFlags
assert flags & 0x2000 # KILL_ON_JOB_CLOSE
assert not flags & (0x800 | 0x1000) # Neither breakaway limit.
info = job._api.GetHandleInformation
info.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
info.restype = wintypes.BOOL
inherited = wintypes.DWORD()
assert info(job._handle, ctypes.byref(inherited))
assert not inherited.value & 1 # HANDLE_FLAG_INHERIT
if failure == 'assign':
# Exercise a real WinAPI rejection while the child is suspended.
assert not job._api.AssignProcessToJobObject(job._handle, None)
raise ctypes.WinError(ctypes.get_last_error())
real_assign(job, proc)
def fail_resume(self):
raise KeyboardInterrupt('injected cancellation before resume')
monkeypatch.setattr(processes._WindowsJob, '__init__', track_job)
monkeypatch.setattr(processes._WindowsJob, 'assign', assign)
monkeypatch.setattr(psutil.Process, 'resume', fail_resume)
cmd = ([str(tmp_path / 'missing.exe')] if failure == 'popen' else
[sys.executable, '-c', 'from pathlib import Path; import sys; '
'Path(sys.argv[1]).write_text("ran")', str(marker)])
try:
with pytest.raises((OSError, KeyboardInterrupt)):
processes.spawn_server(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
assert not marker.exists()
assert jobs and jobs[0]._handle is None
for proc in children:
assert proc.poll() is not None, 'failed setup left a live suspended child'
assert all(stream.closed for stream in (proc.stdin, proc.stdout, proc.stderr))
assert proc._handle.closed, 'failed setup leaked the Popen process handle'
api = ctypes.WinDLL('kernel32', use_last_error=True)
api.GetHandleInformation.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
api.GetHandleInformation.restype = wintypes.BOOL
for handle in handles:
flags = wintypes.DWORD()
assert not api.GetHandleInformation(handle, ctypes.byref(flags))
finally:
for proc in children:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=10)
for stream in (proc.stdin, proc.stdout, proc.stderr):
stream.close()
proc._handle.Close()
for job in jobs:
job.close()