"""Real GUI service fault/restart acceptance, inside a disposable Computer container. Run via scripts/test-runtime-gui.py; never targets an existing Computer. """ import json import os from pathlib import Path import signal import resource import subprocess import tempfile import time source = Path('/fixture/runtime_probe.py') def probe(slot): # Match the production helper's limits and clean environment, so a healthy # service cannot pass only because the acceptance process has more resources. def limits(): resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024,) * 2) resource.setrlimit(resource.RLIMIT_CPU, (20, 20)) resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) result = subprocess.run(['python3', '-I', str(source), json.dumps(slot)], env={'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/lazyboy', 'LANG': 'C.UTF-8'}, capture_output=True, timeout=8, preexec_fn=limits, check=True) assert len(result.stdout) <= 4096 return json.loads(result.stdout) def main(): assert os.getuid() == 1000 processes = {} with tempfile.TemporaryDirectory(prefix='runtime-gui-') as directory: root = Path(directory) env = dict(os.environ, DISPLAY=':8', HOME=directory, DBUS_SESSION_BUS_ADDRESS=f'unix:path={root / "bus"}') commands = { 'bus': ['dbus-daemon', '--session', '--nofork', f'--address=unix:path={root / "bus"}'], 'x': ['Xvfb', ':8', '-screen', '0', '1024x768x24', '-ac', '-nolisten', 'tcp'], 'wm': ['xfwm4', '--replace', '--compositor=off'], 'browser': ['chromium', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--disable-background-networking', '--remote-debugging-port=9229', f'--user-data-dir={root / "profile"}', 'about:blank'], 'rfb': ['x11vnc', '-display', ':8', '-forever', '-shared', '-nopw', '-listen', '127.0.0.1', '-rfbport', '5907'], 'viewer': ['websockify', '--web=/usr/share/novnc', '127.0.0.1:6087', '127.0.0.1:5907'], } def start(name): with (root / f'{name}.log').open('ab') as log: processes[name] = subprocess.Popen(commands[name], env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) def stop(name): process = processes.pop(name) try: os.killpg(process.pid, signal.SIGTERM) process.wait(timeout=5) except ProcessLookupError: pass except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=5) def expect(label, desktop, browser, viewer): wanted = dict(version=1, slot=7, runner=True, desktop=desktop, browser=browser, viewer=viewer) deadline = time.monotonic() + 20 while True: actual = probe(7) if actual == wanted: print(json.dumps({'case': label, 'observation': actual}), flush=True) return if time.monotonic() >= deadline: logs = {p.name: p.read_text(errors='replace')[-5000:] for p in root.glob('*.log')} raise AssertionError((label, wanted, actual, logs)) time.sleep(.2) try: assert probe(None) == dict(version=1, slot=None, runner=True, desktop=None, browser=None, viewer=None) expect('cold', False, False, False) start('x') deadline = time.monotonic() + 10 while subprocess.run(['xdpyinfo', '-display', ':8'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode: assert time.monotonic() < deadline, 'Xvfb startup timeout' time.sleep(.1) start('bus') deadline = time.monotonic() + 5 while not (root / 'bus').exists(): assert time.monotonic() < deadline, 'session bus startup timeout' time.sleep(.1) for name in ['wm', 'browser', 'rfb', 'viewer']: start(name) expect('all-real-services', True, True, True) for service, expected in [('browser', (True, False, True)), ('viewer', (True, True, False)), ('rfb', (True, True, False)), ('wm', (False, True, True))]: stop(service) expect(f'{service}-stopped', *expected) start(service) expect(f'{service}-restarted', True, True, True) assert probe(None)['desktop'] is None, 'unassigned probe must not borrow the live screen' finally: for name in list(reversed(processes)): stop(name) if __name__ == '__main__': main()