236 lines
9.1 KiB
Python
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()
|