Files
pulse/tests/integration/scripts/owned-run.py
T
pulse-triage[bot] 57beb6010f test: reap isolated browser writers before credential cleanup
Shell-only signal traps could leave browser descendants writing sensitive state after cleanup, and simultaneous runs shared the same cookie cache. Give the Linux qualification runner an invocation-owned cookie root and a child-subreaper supervisor so catchable interruption waits for writers before removing owned artifacts. Preserve normal reports for inspection and fail closed if writers cannot be reaped. Focused fixtures cover all three signals, late detached writers, concurrent isolation and forced termination; no real-browser cleanup or release readiness is claimed.

Change-source: pulse-maintainer
2026-09-05 16:31:12 +01:00

107 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Linux qualification supervisor: reap writers before deleting owned auth/artifacts.
Never accepts output paths from the environment. SIGKILL of this supervisor or
host failure still needs operator cleanup; catchable signals are handled here.
"""
import ctypes
import os
from pathlib import Path
import secrets
import shutil
import signal
import subprocess
import sys
import time
def supervise(command, root):
if sys.platform != 'linux':
raise RuntimeError('isolated integration runner requires Linux child-subreaper support')
# Adopt orphaned browser grandchildren, so waiting for the shell is not
# mistaken for waiting for every process which can still write artifacts.
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
raise OSError(ctypes.get_errno(), 'cannot become child subreaper')
run_id = 'pulse-e2e-' + secrets.token_hex(16)
auth = root / 'tmp' / 'playwright-auth' / run_id
report = root / 'playwright-report' / run_id
results = root / 'test-results' / run_id
auth.mkdir(parents=True, mode=0o700)
env = {**os.environ, 'PULSE_E2E_RUN_ID': run_id,
'PULSE_E2E_COOKIE_STATE_PATH': str(auth / 'shared-cookie-session.json'),
'PULSE_E2E_REPORT_DIR': str(report), 'PULSE_E2E_RESULTS_DIR': str(results)}
interrupted = 0
def on_signal(signum, _frame):
nonlocal interrupted
interrupted = interrupted or signum
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, on_signal)
child = None
drained = False
status = 1
try:
child = subprocess.Popen(command, env=env, start_new_session=True)
stopping_at = None
main_done = False
sent = set()
group_sent = set()
while True:
try:
pid, raw = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
drained = True
break
if pid:
if pid == child.pid:
code = os.waitstatus_to_exitcode(raw)
child.returncode = code
status = code if code >= 0 else 128 - code
main_done = True
continue
if interrupted or main_done:
if stopping_at is None:
stopping_at = time.monotonic()
elapsed = time.monotonic() - stopping_at
sig = signal.SIGKILL if elapsed >= 5 else signal.SIGTERM
# The group catches live descendants; adopted children also
# cover grandchildren that started their own sessions.
if sig not in group_sent:
try:
os.killpg(child.pid, sig)
except ProcessLookupError:
pass
group_sent.add(sig)
sent.add((child.pid, sig))
children = Path(f'/proc/self/task/{os.getpid()}/children').read_text().split()
for adopted in children:
key = (int(adopted), sig)
if key not in sent:
try:
os.kill(key[0], sig)
except ProcessLookupError:
pass
sent.add(key)
if elapsed >= 15:
raise RuntimeError('writers did not exit; retaining owned artifacts for cleanup')
time.sleep(0.05)
finally:
if child is None or drained:
shutil.rmtree(auth)
if interrupted:
for directory in (report, results):
if directory.exists():
shutil.rmtree(directory)
else:
print(f'Cleanup incomplete for {run_id}; artifacts retained', file=sys.stderr)
return 128 + interrupted if interrupted else status
if __name__ == '__main__':
root = Path(__file__).resolve().parent.parent
sys.exit(supervise(['bash', sys.argv[1], '--owned-run', *sys.argv[2:]], root))