"""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()