116 lines
5.8 KiB
Python
116 lines
5.8 KiB
Python
"""Windows owned-process lifetime tests; no installed process is terminated."""
|
|
import ctypes
|
|
from ctypes import wintypes as w
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import pytest
|
|
from prepare_native_watch import prepare
|
|
|
|
pytestmark = pytest.mark.skipif(sys.platform != 'win32', reason='Windows executable lifecycle')
|
|
|
|
def descendants(parent):
|
|
class Entry(ctypes.Structure):
|
|
_fields_=[('size',w.DWORD),('usage',w.DWORD),('pid',w.DWORD),('heap',ctypes.c_size_t),('module',w.DWORD),('threads',w.DWORD),('parent',w.DWORD),('priority',w.LONG),('flags',w.DWORD),('exe',w.WCHAR*260)]
|
|
k=ctypes.WinDLL('kernel32',use_last_error=True)
|
|
k.CreateToolhelp32Snapshot.restype=w.HANDLE
|
|
k.Process32FirstW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)]
|
|
k.Process32NextW.argtypes=[w.HANDLE,ctypes.POINTER(Entry)]
|
|
k.CloseHandle.argtypes=[w.HANDLE]
|
|
snap=k.CreateToolhelp32Snapshot(2,0);entry=Entry();entry.size=ctypes.sizeof(entry);rows=[]
|
|
try:
|
|
more=k.Process32FirstW(snap,ctypes.byref(entry))
|
|
while more:
|
|
rows.append((entry.pid,entry.parent));more=k.Process32NextW(snap,ctypes.byref(entry))
|
|
finally:k.CloseHandle(snap)
|
|
owned={parent}
|
|
while True:
|
|
children={pid for pid,ppid in rows if ppid in owned}
|
|
if children <= owned: return owned-{parent}
|
|
owned |= children
|
|
|
|
|
|
def assert_abrupt_cleanup(p, expected_min):
|
|
k=ctypes.WinDLL('kernel32',use_last_error=True)
|
|
k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE
|
|
k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD]
|
|
k.CloseHandle.argtypes=[w.HANDLE]
|
|
ids=descendants(p.pid)
|
|
assert len(ids)>=expected_min, ids
|
|
handles=[k.OpenProcess(0x100000,False,pid) for pid in ids]
|
|
assert all(handles)
|
|
try:
|
|
p.kill();p.wait(timeout=5)
|
|
assert all(k.WaitForSingleObject(h,5000)==0 for h in handles), ids
|
|
finally:
|
|
for h in handles:k.CloseHandle(h)
|
|
return len(ids)
|
|
|
|
|
|
def line(p):
|
|
q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start()
|
|
return q.get(timeout=20)
|
|
|
|
|
|
def candidate(tmp_path, original, digest):
|
|
return prepare(Path(__file__).parent,tmp_path/'candidate',original,digest,Path(sys.executable),tmp_path/'bridge',tmp_path/'agent')
|
|
|
|
|
|
def test_abrupt_bootstrap_exit_kills_owned_python_and_grandchild(tmp_path):
|
|
exe=Path(sys.executable)
|
|
launcher=candidate(tmp_path,exe,hashlib.sha256(exe.read_bytes()).hexdigest())
|
|
script='import subprocess,sys,time; p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(120)"]); print(p.pid,flush=True); time.sleep(120)'
|
|
p=subprocess.Popen([str(launcher),'-u','-c',script],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
|
try:
|
|
assert int(line(p))>0
|
|
assert_abrupt_cleanup(p,3)
|
|
finally:
|
|
if p.poll() is None:p.kill();p.wait(timeout=5)
|
|
p.stdin.close();p.stdout.close();p.stderr.close()
|
|
|
|
|
|
def test_pinned_original_initialize_and_abrupt_cleanup(tmp_path):
|
|
# Explicit provisioned evidence, never discovers or kills an existing app.
|
|
manifest_path=os.environ.get('NATIVE_WATCH_TEST_MANIFEST')
|
|
if not manifest_path: pytest.skip('requires explicit pinned original candidate')
|
|
manifest=json.loads(Path(manifest_path).read_text(encoding='utf-8-sig'))
|
|
launcher=candidate(tmp_path,Path(manifest['original']),manifest['original_sha256'])
|
|
p=subprocess.Popen([str(launcher),'-c','model_provider="openai"','app-server','--analytics-default-enabled'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
|
try:
|
|
p.stdin.write(json.dumps({'id':1,'method':'initialize','params':{'clientInfo':{'name':'owned-cleanup-proof','version':'1.0'},'capabilities':{'experimentalApi':True}}}).encode()+b'\n');p.stdin.flush()
|
|
response=json.loads(line(p));assert response.get('id')==1 and 'result' in response,response
|
|
assert_abrupt_cleanup(p,2)
|
|
finally:
|
|
if p.poll() is None:p.kill();p.wait(timeout=5)
|
|
p.stdin.close();p.stdout.close();p.stderr.close()
|
|
|
|
def test_compiled_launcher_fallback(tmp_path):
|
|
source=Path(__file__).parent
|
|
import hashlib
|
|
exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest()
|
|
launcher=prepare(source,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent')
|
|
args=['-c','import json,sys,os; print(json.dumps([sys.argv[1:],os.getcwd()])); sys.stderr.write("stderr\\n"); sys.exit(23)','space arg','quote"arg','trailing\\']
|
|
direct=subprocess.run([str(exe),*args],cwd=tmp_path,capture_output=True)
|
|
wrapped=subprocess.run([str(launcher),*args],cwd=tmp_path,capture_output=True)
|
|
assert (wrapped.returncode,wrapped.stdout,wrapped.stderr)==(direct.returncode,direct.stdout,direct.stderr)
|
|
manifest=launcher.with_suffix('.json');data=json.loads(manifest.read_text(encoding='utf-8'));data['original_sha256']='0'*64;manifest.write_text(json.dumps(data),encoding='utf-8')
|
|
assert subprocess.run([str(launcher),'--version'],capture_output=True).returncode==78
|
|
|
|
def test_executable_flushes_interactive_stdin_before_eof(tmp_path):
|
|
import hashlib,queue
|
|
exe=Path(sys.executable);digest=hashlib.sha256(exe.read_bytes()).hexdigest()
|
|
launcher=prepare(Path(__file__).parent,tmp_path/'candidate',exe,digest,exe,tmp_path/'bridge',tmp_path/'agent')
|
|
p=subprocess.Popen([str(launcher),'-u','-c','import sys; print(sys.stdin.readline().strip(),flush=True); sys.stdin.read()'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
|
|
q=queue.Queue();threading.Thread(target=lambda:q.put(p.stdout.readline()),daemon=True).start()
|
|
try:
|
|
p.stdin.write(b'live-before-eof\n');p.stdin.flush()
|
|
assert q.get(timeout=5).strip()==b'live-before-eof'
|
|
finally:
|
|
p.stdin.close();p.wait(timeout=5)
|