Files

264 lines
9.6 KiB
Python

"""Actual owned subprocess regressions; no devices, network or external tools."""
import contextlib
import ctypes
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "skill/scripts"))
import dependency_checks as checks
import dependency_runner as dependencies
import device_runtime as runtime
import tool_runner as runner
from test_review_regressions import process_exited
def parent_code(exit_code=None, output_bytes=0):
return (
"import os,subprocess,sys,time,json;from pathlib import Path;"
"p=subprocess.Popen([sys.executable,'-I','-B','-c','import time;time.sleep(60)'],"
"stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,"
"creationflags=subprocess.CREATE_NO_WINDOW);"
"Path(sys.argv[1]).write_text(json.dumps({'parent':os.getpid(),'child':p.pid}));"
f"sys.stdout.write('x'*{output_bytes});sys.stdout.flush();"
+ ("time.sleep(60)" if exit_code is None else f"sys.exit({exit_code})")
)
@contextlib.contextmanager
def denied_cleanup_accounting():
original = ctypes.WinDLL
def load(name, *args, **kwargs):
library = original(name, *args, **kwargs)
if name != "kernel32":
return library
def deny(*args):
ctypes.set_last_error(5)
return 0
class Proxy:
def __getattr__(self, attr):
return (
deny
if attr == "QueryInformationJobObject"
else getattr(library, attr)
)
return Proxy()
with patch.object(ctypes, "WinDLL", side_effect=load):
yield
@unittest.skipUnless(os.name == "nt", "Windows Job ownership")
class LifecycleReview(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.marker = self.root / "owned-pids.json"
def assert_tree_exited(self):
pids = json.loads(self.marker.read_text())
self.assertTrue(process_exited(pids["parent"]))
self.assertTrue(process_exited(pids["child"]))
def test_doctor_public_entry_reaps_successful_startup_descendants(self):
native = dependencies
prefix = [sys.executable, "-I", "-B", "-c", parent_code(0), str(self.marker)]
def resolve(tool_id, profile, roots):
if tool_id == "ffprobe":
return profile, prefix, {"source": "synthetic", "status": "found"}
raise native.UPSTREAM.ToolMissing(tool_id)
# Substitute only discovery; the real doctor/check/Job processes run unchanged.
with (
patch.object(native, "resolve_dependency", side_effect=resolve),
patch.object(native, "path_directories", return_value=iter(())),
):
result = native.doctor({}, [], check_versions=True)
self.assert_tree_exited()
self.assertEqual(result["execution_status"], "completed")
def test_startup_check_reaps_descendant_on_normal_exit(self):
result = checks.check_startup(
"ffprobe",
[sys.executable, "-I", "-B", "-c", parent_code(0), str(self.marker)],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["status"], "passed")
self.assert_tree_exited()
def test_check_nonzero_reaps_descendants(self):
result, output = checks.run_check(
[sys.executable, "-I", "-B", "-c", parent_code(7), str(self.marker)],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["returncode"], 7)
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_timeout_reaps_descendants(self):
result, output = checks.run_check(
[sys.executable, "-I", "-B", "-c", parent_code(), str(self.marker)],
self.root,
time.monotonic() + 2,
)
self.assertEqual(result["status"], "timed_out")
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_output_limit_stops_tree_and_discards_partial_output(self):
result, output = checks.run_check(
[
sys.executable,
"-I",
"-B",
"-c",
parent_code(output_bytes=2 * 1024 * 1024),
str(self.marker),
],
self.root,
time.monotonic() + 8,
)
self.assertEqual(result["error_code"], "process_output_limit")
self.assertNotEqual(result["status"], "passed")
self.assertEqual(output, "")
self.assert_tree_exited()
def test_check_owner_death_closes_job_and_reaps_descendants(self):
code = (
"import sys,time;from pathlib import Path;sys.path.insert(0,sys.argv[1]);"
"import dependency_checks as c;"
"c.run_check([sys.executable,'-I','-B','-c',sys.argv[2],sys.argv[3]],Path(sys.argv[3]).parent,time.monotonic()+40)"
)
worker = subprocess.Popen(
[
sys.executable,
"-I",
"-B",
"-c",
code,
str(ROOT),
parent_code(),
str(self.marker),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW,
)
try:
deadline = time.monotonic() + 2
while not self.marker.exists() and time.monotonic() < deadline:
time.sleep(0.01)
self.assertTrue(self.marker.exists())
worker.kill()
worker.wait(timeout=5)
self.assert_tree_exited()
finally:
if worker.poll() is None:
worker.kill()
worker.wait(timeout=5)
def test_real_execution_then_cleanup_error_preserves_started(self):
output = self.root / "actual-write.txt"
plan = runner.Plan(
"capture",
"dumpcap",
[
sys.executable,
"-I",
"-B",
"-c",
"import sys;from pathlib import Path;Path(sys.argv[1]).write_bytes(b'written')",
str(output),
],
5,
)
with denied_cleanup_accounting():
result = runner.execute(plan)
self.assertEqual(output.read_bytes(), b"written")
self.assertEqual(result["execution_status"], "cleanup_unconfirmed")
self.assertEqual(result["error_code"], "process_cleanup_unconfirmed")
self.assertTrue(result["may_have_started"])
def test_prelaunch_failure_remains_known_not_started(self):
plan = runner.Plan("capture", "dumpcap", [str(self.root / "missing.exe")], 5)
result = runner.execute(plan)
self.assertEqual(result["execution_status"], "launch_failed")
self.assertFalse(result["may_have_started"])
def test_unclassified_io_failure_is_unknown_and_redacted(self):
plan = runner.Plan("capture", "dumpcap", ["synthetic"], 5)
with patch.object(runner, "run_process", side_effect=OSError("private-value")):
result = runner.execute(plan)
self.assertEqual(result["execution_status"], "unknown")
self.assertTrue(result["may_have_started"])
self.assertNotIn("private-value", json.dumps(result))
@unittest.skipUnless(os.name == "nt", "Windows lifecycle receipts")
class RuntimeReceiptReview(unittest.IsolatedAsyncioTestCase):
async def test_host_receipt_never_calls_started_cleanup_failure_not_started(self):
with (
tempfile.TemporaryDirectory() as folder,
patch.dict(os.environ, {"ARTEMIS_ARTIFACTS_DIR": folder}),
):
for status, code in (
("cleanup_unconfirmed", "process_cleanup_unconfirmed"),
("unknown", "process_io_unconfirmed"),
("output_limit", "process_output_limit"),
):
payload = {
"execution_status": status,
"error_code": code,
"may_have_started": True,
}
process = Mock(returncode=1)
with (
patch.object(
runtime.asyncio,
"create_subprocess_exec",
new=AsyncMock(return_value=process),
),
patch.object(
runtime,
"read_output",
new=AsyncMock(return_value=json.dumps(payload).encode()),
),
):
result = await runtime.run_cli(
"dependency_runner.py",
["capture"],
operation="capture",
write=True,
)
receipt = result.structuredContent["artemis"]
self.assertEqual(
receipt["execution"],
"partial" if status == "output_limit" else "unknown",
)
self.assertEqual(receipt["side_effect"], "possible")
self.assertFalse(receipt["retryable"])
self.assertEqual(receipt["error_code"], code)
if __name__ == "__main__":
unittest.main()