250 lines
9.1 KiB
Python
Executable File
250 lines
9.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regression checks for bounded Aetherbound Guild test-attempt cleanup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
RUNNER = ROOT / "tools" / "run_test_attempt.py"
|
|
BROWSER_TEMP = ROOT / "tools" / "browser_temp.js"
|
|
|
|
|
|
def invoke(
|
|
base: Path,
|
|
label: str,
|
|
command: list[str],
|
|
expected: int,
|
|
*,
|
|
timeout: str = "5",
|
|
git_ref: str = "",
|
|
required_markers: tuple[str, ...] = (),
|
|
) -> subprocess.CompletedProcess[str]:
|
|
arguments = [
|
|
sys.executable,
|
|
str(RUNNER),
|
|
"--label",
|
|
label,
|
|
"--cwd",
|
|
str(ROOT),
|
|
"--workspace-root",
|
|
str(base / "work"),
|
|
"--artifact-root",
|
|
str(base / "artifacts"),
|
|
"--keep-failed",
|
|
"3",
|
|
"--keep-passed",
|
|
"2",
|
|
"--timeout",
|
|
timeout,
|
|
]
|
|
if git_ref:
|
|
arguments.extend(["--git-ref", git_ref])
|
|
for marker in required_markers:
|
|
arguments.extend(["--require-marker", marker])
|
|
arguments.extend(["--", *command])
|
|
completed = subprocess.run(
|
|
arguments,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
check=False,
|
|
)
|
|
assert completed.returncode == expected, completed.stdout
|
|
return completed
|
|
|
|
|
|
def result_directories(family: Path, outcome: str) -> list[Path]:
|
|
matches: list[Path] = []
|
|
for path in family.iterdir():
|
|
result_path = path / "result.json"
|
|
if path.is_dir() and result_path.is_file():
|
|
if json.loads(result_path.read_text(encoding="utf-8"))["outcome"] == outcome:
|
|
matches.append(path)
|
|
return matches
|
|
|
|
|
|
def main() -> int:
|
|
spec = importlib.util.spec_from_file_location("abg_test_attempt_runner", RUNNER)
|
|
assert spec is not None and spec.loader is not None
|
|
runner_module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(runner_module)
|
|
original_child = runner_module._child
|
|
original_killpg = runner_module.os.killpg
|
|
try:
|
|
runner_module._child = SimpleNamespace(pid=99999998)
|
|
|
|
def deny_killpg(_pgid: int, _signal: int) -> None:
|
|
raise PermissionError(1, "synthetic denied process group")
|
|
|
|
runner_module.os.killpg = deny_killpg
|
|
assert runner_module.terminate_child() is False
|
|
finally:
|
|
runner_module.os.killpg = original_killpg
|
|
runner_module._child = original_child
|
|
|
|
with tempfile.TemporaryDirectory(prefix="abg-test-runner-selftest-") as temporary:
|
|
base = Path(temporary)
|
|
stale = base / "work" / "stale-owned"
|
|
stale.mkdir(parents=True)
|
|
(stale / ".abg-attempt-owner.json").write_text(
|
|
json.dumps({"created_by": "aetherbound-test-attempt-v1", "pid": 99999999}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
success = invoke(
|
|
base,
|
|
"lifecycle",
|
|
[
|
|
"/bin/sh",
|
|
"-c",
|
|
'printf proof > "$ABG_EVIDENCE_DIR/proof.txt"; test -d "$ABG_BROWSER_TEMP_PARENT"; test -n "$ABG_ORIGINAL_HOME"; test "$HOME" != "$ABG_ORIGINAL_HOME"',
|
|
],
|
|
0,
|
|
)
|
|
assert "ABG_TEST_ATTEMPT_PASS" in success.stdout
|
|
assert not stale.exists(), "stale owned workspace must be removed"
|
|
assert list((base / "work").iterdir()) == [], "successful attempt leaked a workspace"
|
|
|
|
family = base / "artifacts" / "lifecycle"
|
|
passes = result_directories(family, "PASS")
|
|
assert len(passes) == 1
|
|
result = json.loads((passes[0] / "result.json").read_text(encoding="utf-8"))
|
|
assert result["workspace_removed"] is True
|
|
assert result["evidence_files"] == 1
|
|
assert (passes[0] / "evidence.tar.gz").is_file()
|
|
|
|
archived = invoke(
|
|
base,
|
|
"lifecycle",
|
|
[
|
|
"/bin/sh",
|
|
"-c",
|
|
'test -f README.md; printf "archive\\n"; printf archive > "$ABG_EVIDENCE_DIR/archive.txt"',
|
|
],
|
|
0,
|
|
git_ref="HEAD",
|
|
required_markers=("archive",),
|
|
)
|
|
assert "ABG_TEST_ATTEMPT_PASS" in archived.stdout
|
|
assert list((base / "work").iterdir()) == [], "git-archive attempt leaked source or cache data"
|
|
|
|
timed_out = invoke(base, "lifecycle", ["/bin/sh", "-c", "sleep 2"], 124, timeout="0.2")
|
|
assert "ABG_TEST_ATTEMPT_TIMEOUT" in timed_out.stdout
|
|
|
|
strict_failure = invoke(
|
|
base,
|
|
"lifecycle",
|
|
["/bin/sh", "-c", "printf 'SCRIPT ERROR: synthetic parse failure\\n'"],
|
|
86,
|
|
)
|
|
assert "ABG_TEST_ATTEMPT_FAIL" in strict_failure.stdout
|
|
|
|
interrupted_process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
str(RUNNER),
|
|
"--label",
|
|
"lifecycle",
|
|
"--cwd",
|
|
str(ROOT),
|
|
"--workspace-root",
|
|
str(base / "work"),
|
|
"--artifact-root",
|
|
str(base / "artifacts"),
|
|
"--keep-failed",
|
|
"3",
|
|
"--keep-passed",
|
|
"2",
|
|
"--timeout",
|
|
"5",
|
|
"--",
|
|
"/bin/sh",
|
|
"-c",
|
|
'python3 -c \'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)\' & echo $! > "$ABG_ATTEMPT_ROOT/descendant.pid"; wait',
|
|
],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
deadline = time.monotonic() + 2
|
|
descendant_pid_path = None
|
|
while time.monotonic() < deadline:
|
|
matches = list((base / "work").glob("*/descendant.pid"))
|
|
if matches:
|
|
descendant_pid_path = matches[0]
|
|
break
|
|
time.sleep(0.02)
|
|
assert descendant_pid_path is not None, "interrupted attempt did not create its descendant"
|
|
descendant_pid = int(descendant_pid_path.read_text(encoding="utf-8"))
|
|
interrupted_process.terminate()
|
|
interrupted_output, _ = interrupted_process.communicate(timeout=5)
|
|
assert interrupted_process.returncode == 143, interrupted_output
|
|
assert "ABG_TEST_ATTEMPT_INTERRUPTED" in interrupted_output
|
|
assert list((base / "work").iterdir()) == [], "handled interruption leaked a workspace"
|
|
interrupted_results = result_directories(family, "INTERRUPTED")
|
|
assert len(interrupted_results) == 1
|
|
interrupted_result = json.loads(
|
|
(interrupted_results[0] / "result.json").read_text(encoding="utf-8")
|
|
)
|
|
assert interrupted_result["process_group_removed"] is True
|
|
try:
|
|
os.kill(descendant_pid, 0)
|
|
except ProcessLookupError:
|
|
pass
|
|
else:
|
|
raise AssertionError("handled interruption leaked a descendant process")
|
|
|
|
for _index in range(5):
|
|
invoke(base, "lifecycle", ["/bin/sh", "-c", "exit 7"], 7)
|
|
failed = sum(
|
|
len(result_directories(family, outcome))
|
|
for outcome in ("FAIL", "TIMEOUT", "INTERRUPTED", "HARNESS_ERROR")
|
|
)
|
|
assert failed == 3, "all failed evidence classes combined must be bounded to three"
|
|
journal = (family / "attempts.jsonl").read_text(encoding="utf-8").splitlines()
|
|
assert len(journal) == 10, "all attempt terminals remain in the compact append-only journal"
|
|
assert list((base / "work").iterdir()) == [], "failed attempts leaked workspaces"
|
|
|
|
browser_parent = base / "browser"
|
|
browser_parent.mkdir()
|
|
stale_browser = browser_parent / "stale-browser"
|
|
stale_browser.mkdir()
|
|
(stale_browser / ".abg-browser-owner.json").write_text(
|
|
json.dumps({"createdBy": "aetherbound-browser-temp-v1", "pid": 99999999}),
|
|
encoding="utf-8",
|
|
)
|
|
browser_check = subprocess.run(
|
|
[
|
|
"node",
|
|
"-e",
|
|
(
|
|
f'process.env.ABG_BROWSER_TEMP_PARENT={json.dumps(str(browser_parent))};'
|
|
f'const h=require({json.dumps(str(BROWSER_TEMP))}).installBrowserTemp("selftest");'
|
|
'require("node:fs").writeFileSync(require("node:path").join(h.root,"owned"),"1");'
|
|
),
|
|
],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
check=False,
|
|
)
|
|
assert browser_check.returncode == 0, browser_check.stdout
|
|
assert list(browser_parent.iterdir()) == [], "browser temp helper leaked current or stale owned roots"
|
|
|
|
print("ABG_TEST_ATTEMPT_RUNNER_OK pass=2 fail_attempts=8 retained_failures=3 archive_cleanup=true interrupt_cleanup=true strict_diagnostics=true marker_gate=true python_site_isolated=true stale_cleanup=true browser_cleanup=true descendant_cleanup=true")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|