fix(device-test): contain tool processes and add reviewed preview
This commit is contained in:
1 parent
c98d7527a0
commit
7d31e09cb7
59 files changed
+23722
No files matched your search
@@ -0,0 +1,263 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user