"""EXPERIMENTAL applied-state/input headless campaign. See README.""" import argparse from PIL import Image, ImageDraw import sys from budget import Budget import json import os from pathlib import Path import re import shutil import signal import subprocess import tempfile import time parser = argparse.ArgumentParser() parser.add_argument('lab', type=Path) parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) parser.add_argument('--cycles', type=int, default=3) parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0) parser.add_argument('--test-hold', action='store_true') parser.add_argument('--test-timeout', action='store_true') parser.add_argument('--moves', type=int, default=18) parser.add_argument('--max-seconds', type=int, default=120) parser.add_argument('--max-captures', type=int, default=16) parser.add_argument('--max-bytes', type=int, default=30_000_000) args = parser.parse_args() if not (1 <= args.max_seconds <= 120 and 1 <= args.max_captures <= 16 and 1 <= args.max_bytes <= 30_000_000): parser.error('static R90 run bounds exceeded') if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40: parser.error('cycle/move bounds exceeded') lab = args.lab.resolve() if not (lab / 'nested-client').is_file(): parser.error('prepared LAB required') budget = Budget(lab) budget.reserve(args.mode) run = lab / 'runs' / f'{args.mode}-static-r90-{time.time_ns()}' run.mkdir(parents=True, mode=0o700) (run / 'fixture-source.py').write_text(Path(__file__).read_text()) # WHY: five asymmetric markers identify whether PNG uses logical or buffer axes. # CONTRACT: predeclared logical centers and distinct exact RGB codes. marker_path = run / 'marker.png' marker = Image.new('RGB', (800, 600), (30, 30, 46)) draw = ImageDraw.Draw(marker) for name, box, color in ( ('TL', (15, 15, 44, 44), (255, 32, 32)), ('TR', (755, 15, 784, 44), (32, 255, 32)), ('BL', (15, 555, 44, 584), (32, 32, 255)), ('BR', (755, 555, 784, 584), (255, 255, 32)), ('C', (385, 285, 414, 314), (192, 32, 255))): draw.rectangle(box, fill=color) marker.save(marker_path) runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp')) if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108: raise RuntimeError('IPC socket path exceeds sockaddr_un') (run / 'runtime-path').write_text(str(runtime) + '\n') (run / 'config').write_text( 'output HEADLESS-1 resolution 750x1000 scale 1.25 transform 90 position -800 0\n' 'output HEADLESS-2 resolution 800x600 position 0 0\n' 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' 'focus_follows_mouse no\n') (run / 'config').write_text((run / 'config').read_text() + f'output * bg {marker_path} fill\n') env = os.environ.copy() env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman') for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): env.pop(key, None) compositor_env = env.copy() compositor_env['ASI_MODE'] = str(args.asi_mode) if args.test_hold or args.test_timeout: compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1' readback_dir = None if args.asi_mode: readback_dir = lab / 'buffers' / run.name readback_dir.mkdir(parents=True, mode=0o700) compositor_env['BI_OUTPUT_DIR'] = str(readback_dir) (run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode, 'readback_dir': str(readback_dir) if readback_dir else None}) + '\n') binary = lab / 'standard-build/sway/sway' if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic' if args.mode != 'control': compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') files, children, events = [], [], [] started = time.monotonic() capture_count = 0 capture_bytes = 0 def record(kind, **fields): entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields} events.append(entry) with (run / 'events.jsonl').open('a') as out: out.write(json.dumps(entry) + '\n') return entry def spawn(argv, log_name, *, stdin=False, debug=False): log = (run / log_name).open('w') files.append(log) child_env = env.copy() if debug: child_env['WAYLAND_DEBUG'] = '1' proc = subprocess.Popen(argv, env=child_env, stdin=subprocess.PIPE if stdin else None, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, text=stdin) children.append(proc) return proc def wait(predicate, seconds=12): deadline = time.monotonic() + seconds while time.monotonic() < deadline: if time.monotonic() - started > args.max_seconds: raise RuntimeError('run duration bound exceeded') result = predicate() if result: return result if compositor.poll() is not None: raise RuntimeError(f'compositor exited {compositor.returncode}') if 'pointer' in globals() and pointer.poll() is not None: raise RuntimeError(f'pointer exited {pointer.returncode}') if 'client' in globals() and client.poll() is not None: raise RuntimeError(f'client exited {client.returncode}') time.sleep(.04) raise RuntimeError('timeout waiting for event') def ipc(*argv): completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, text=True, capture_output=True, check=True, timeout=8) result = json.loads(completed.stdout) record('ipc', argv=list(argv), result=result) return result def command(value): result = ipc(value) if not isinstance(result, list) or not result or not all(x.get('success') for x in result): raise RuntimeError(f'IPC command rejected: {value}: {result}') def find(node, predicate): if isinstance(node, dict): if predicate(node): return node for child in node.get('nodes', []) + node.get('floating_nodes', []): found = find(child, predicate) if found: return found return None def tree(): return ipc('-t', 'get_tree') def workspace(): return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1') def owner(): return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe') def client_text(): return (run / 'client.log').read_text() def count(marker): return client_text().count(marker) def configured(name): matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)', client_text()) if not matches: raise RuntimeError(f'no {name} role configure') return tuple(map(int, matches[-1])) def snapshot(tag): global capture_count, capture_bytes t = tree() outputs = ipc('-t', 'get_outputs') (run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n') (run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n') expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)} actual = {o['name']: tuple(o['rect'][k] for k in ('x','y','width','height')) for o in outputs if o['active']} rotated = True h1 = next(o for o in outputs if o['name'] == 'HEADLESS-1') h2 = next(o for o in outputs if o['name'] == 'HEADLESS-2') wanted_mode = (750, 1000) if rotated else (1000, 750) if (actual != expected or h1['scale'] != 1.25 or h2['scale'] != 1 or h2['transform'] != 'normal' or h1['transform'] != ('90' if rotated else 'normal') or (h1['current_mode']['width'], h1['current_mode']['height']) != wanted_mode): raise RuntimeError(f'applied rotation geometry gate failed: {actual} {h1}') record('state', tag=tag, workspaces=ipc('-t','get_workspaces'), workspace=workspace(), owner=owner(), outputs=outputs) for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): if capture_count >= args.max_captures: raise RuntimeError('capture count bound exceeded') path = run / f'{side}-{tag}.png' before = time.monotonic_ns() subprocess.run(['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)], env=env, check=True, stdout=subprocess.DEVNULL, timeout=10) after = time.monotonic_ns() capture_count += 1 capture_bytes += path.stat().st_size if capture_bytes > args.max_bytes: raise RuntimeError('capture byte bound exceeded') out = next(o for o in outputs if o['name']==output) dimensions = list(Image.open(path).size) record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode'], physical_expected=[out['current_mode']['width'],out['current_mode']['height']]) record('capture', tag=tag, output=output, path=path.name, before_ns=before, after_ns=after, size=path.stat().st_size) def inject(action, x, y): outputs = [o for o in ipc('-t','get_outputs') if o['active']] min_x = min(o['rect']['x'] for o in outputs) min_y = min(o['rect']['y'] for o in outputs) max_x = max(o['rect']['x']+o['rect']['width'] for o in outputs) max_y = max(o['rect']['y']+o['rect']['height'] for o in outputs) if not any(o['rect']['x']<=xbefore) def client_command(value): marker = f'COMMAND {value} DONE' before = count(marker) record('client_request', value=value) client.stdin.write(value + '\n') client.stdin.flush() wait(lambda: count(marker) > before) def scroll(target): current = workspace()['mosaic']['rows'][0]['scroll_x'] command(f'layout mosaic scroll {target-current:g}') wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target) record('camera_observed', target=target, owner_id=owner()['id']) def open_chain(cycle): a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP') o = owner() scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100) inject('move', top_x, 150) inject('left', top_x, 150) wait(lambda: count('POPUP_A MAP') > a_before) snapshot(f'{cycle}-S1-A') a_cfg = configured('POPUP_A') a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40) a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35) inject('move', a_x, a_y) inject('left', a_x, a_y) wait(lambda: count('POPUP_B MAP') > b_before) if readback_dir: (readback_dir / 'enable').touch() snapshot(f'{cycle}-S2-B') b_cfg = configured('POPUP_B') b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35) b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) inject('move', b_x, b_y) inject('right', b_x, b_y) time.sleep(.12) snapshot(f'{cycle}-S3-visible') record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x, a_configure=a_cfg, b_configure=b_cfg, top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y]) def cycle_once(number): label = f'cycle-{number:03d}' record('cycle_start', cycle=number) snapshot(f'{label}-S0-reference') open_chain(label) if args.mode == 'private_mosaic': scroll(1280) snapshot(f'{label}-S4-hidden-immediate') time.sleep(.18) snapshot(f'{label}-S4-hidden-stable') inject('move', -700, 190) time.sleep(.1) snapshot(f'{label}-S4-hidden-hover') scroll(400) snapshot(f'{label}-S5-return-immediate') time.sleep(.18) snapshot(f'{label}-S5-return-stable') client_command('close_b') snapshot(f'{label}-S6-close-B-immediate') time.sleep(.18) snapshot(f'{label}-S6-close-B-stable') a_cfg = configured('POPUP_A') o = owner() scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20) a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20) before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text())) inject('move', a_x, a_y) inject('right', a_x, a_y) wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text())) > before_a) snapshot(f'{label}-S6-A-interaction') client_command('close_a') snapshot(f'{label}-S7-close-A-immediate') time.sleep(.18) snapshot(f'{label}-S7-close-A-stable') record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None) compositor_log = (run / 'compositor.log').open('w') files.append(compositor_log) compositor = None failure = None try: compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'], env=compositor_env, stdout=compositor_log, stderr=subprocess.STDOUT, start_new_session=True) def sockets(): s = next(iter(runtime.glob('sway-ipc.*.sock')), None) w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None) return (s, w) if s and w else None sock, way = wait(sockets) env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name) time.sleep(1.0) snapshot('startup') pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True) wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) command('workspace 1') command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith') spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0', '-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log') wait(lambda: len(workspace()['nodes']) >= 1) client = spawn([str(lab / 'nested-client'), 'rot_cross'], 'client.log', stdin=True, debug=True) wait(lambda: owner() and 'TOPLEVEL MAP' in client_text()) for name in ('C', 'D'): spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0', '/usr/bin/sleep', '900'], f'{name}.log') time.sleep(.25) command('workspace 2') spawn(['stdbuf', '-oL', 'wev'], 'wev.log') wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev')) command('workspace 1') command('[app_id="org.mosaic.NestedGrabProbe"] focus') if args.mode == 'private_mosaic': scroll(640) record('initial_camera', value=640) time.sleep(.25) # EXPERIMENTAL fragment inserted inside the inherited bounded runner's try. # WHY: persistence observations precede reference clicks that can dismiss grabs. # CONTRACT: four reserved dynamic actions per mode; no additional camera pair. dynamic_stage = 'D0' initial_ws_id = workspace()['id'] initial_owner_id = owner()['id'] ref_right = find(tree(), lambda n: n.get('app_id') == 'wev')['id'] command('workspace 1') left_proc = spawn(['stdbuf','-oL','wev'], 'wev-left.log') wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)) ref_left = find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)['id'] command('shadows disable'); command('blur disable') def closed(): client_command('close_a') time.sleep(.12) def calibrate(tag): outputs = {o['name']: o for o in ipc('-t','get_outputs') if o['active']} active = {w['output']:w for w in ipc('-t','get_workspaces') if w['visible']} for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]: name = active[out]['name'] command(f'[con_id={con_id}] move container to workspace {name}') command(f'[con_id={con_id}] floating enable') command(f'[con_id={con_id}] border none') command(f'[con_id={con_id}] resize set 200 150') ox=outputs[out]['rect']['x']+50; oy=400 command(f'[con_id={con_id}] move absolute position {ox} {oy}') time.sleep(.25) # WHY: after return both references can temporarily share workspace 1. # CONTRACT: relocate BOTH before scoring input, avoiding mutual occlusion. for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]: ox=outputs[out]['rect']['x']+50; oy=400 text=lambda: (run/logname).read_text() # Force a different surface, then accept actual enter or motion. inject('move', outputs['HEADLESS-2' if out=='HEADLESS-1' else 'HEADLESS-1']['rect']['x']+10,10) before=len(text());inject('move',ox+20,oy+30) pattern=r'(?:enter:[^\n]*|motion:[^\n]*)x, y: ([\d.]+), ([\d.]+)' wait(lambda: re.search(pattern,text()[before:])) ex,ey=map(float,re.findall(pattern,text()[before:])[-1]) if ex != 20 or not 0 <= ey < 100: raise RuntimeError(f'calibration interior mismatch {tag} {out}: {ex},{ey}') before=len(text());inject('move',ox+60,oy+50) wait(lambda: f'x, y: {ex+40:.6f}, {ey+20:.6f}' in text()[before:]) before=len(text()) inject('left',ox+60,oy+50);inject('right',ox+60,oy+50) wait(lambda: 'button: 272' in text()[before:] and 'button: 273' in text()[before:]) record('pointer_calibration',tag=tag,output=out,con_id=con_id,global_point=[ox+60,oy+50],local_expected=[ex+40,ey+20],surface_origin=[ox+20-ex,oy+30-ey],passed=True) for x in (-2,-1,0,1,2):inject('move',x,550) inject('move',outputs['HEADLESS-1']['rect']['x']+110,450) command('workspace 1') def stable(tag, expected_owner): # CONTRACT: repeated geometry and paired settled captures are evidence, # not proof that intermediate configurations each had a presentation. time.sleep(.35) before=ipc('-t','get_outputs') time.sleep(.12) after=ipc('-t','get_outputs') if before != after: raise RuntimeError(f'unstable output IPC at {tag}') ws=workspace();o=owner() current=next(w for w in ipc('-t','get_workspaces') if w['id']==initial_ws_id) if ws['id']!=initial_ws_id or o['id']!=initial_owner_id or current['output']!=expected_owner: raise RuntimeError(f'workspace/owner identity mismatch at {tag}') snapshot(tag) time.sleep(.16);snapshot(tag+'-repeat') record('identity',tag=tag,workspace_id=ws['id'],owner_id=o['id'],output=current['output'],layout=ws['layout'],order=[n['id'] for n in ws['nodes']],mosaic=ws.get('mosaic'),focused=current['focused']) def owner_hit(tag): o=owner();ws=workspace() scroll_x=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0 point=[int(o['rect']['x']-scroll_x+o['window_rect']['x']+100),150] before=len(client_text());inject('move',*point);inject('right',*point) delivered='POINTER_BUTTON target=TOPLEVEL' in client_text()[before:] record('owner_hit',tag=tag,point=point,delivered=delivered,events=client_text()[before:]) if not delivered:raise RuntimeError(f'visible owner input failed {tag}') # WHY: exact colors identify PNG orientation before interpreting output pixels. # CONTRACT: marker positions/colors were fixed before the first transition. colors = {'TL':(255,32,32),'TR':(32,255,32),'BL':(32,32,255), 'BR':(255,255,32),'C':(192,32,255)} def marker_gate(tag, rotated): im = Image.open(run / f'left-{tag}.png').convert('RGB') points = {name: [] for name in colors} reverse = {v:k for k,v in colors.items()} for y in range(im.height): for x in range(im.width): name = reverse.get(im.getpixel((x,y))) if name: points[name].append((x,y)) centers = {} for name, positions in points.items(): if len(positions) < 100: raise RuntimeError(f'{tag}: marker {name} absent ({len(positions)})') centers[name] = [sum(p[0] for p in positions)/len(positions), sum(p[1] for p in positions)/len(positions)] logical = (im.size == (1000,750) and centers['TL'][0] < centers['TR'][0] and centers['TL'][1] < centers['BL'][1]) physical = (im.size == (750,1000) and centers['TL'][0] > centers['BL'][0] and centers['TL'][1] < centers['TR'][1]) frame = 'logical-oriented PNG' if logical else 'physical-buffer-oriented PNG' if physical else 'unknown' record('marker_calibration', tag=tag, dimensions=list(im.size), centers=centers, counts={k:len(v) for k,v in points.items()}, png_frame=frame) if (rotated and not (logical or physical)) or (not rotated and not logical): raise RuntimeError(f'{tag}: PNG frame not established') return frame # CONTRACT: static R90 from compositor startup; no transform command. marker_gate('startup', True) calibrate('static-R90') command('[app_id="org.mosaic.NestedGrabProbe"] focus') snapshot('reference') owner_hit('static-R90') open_chain('rot-cross') o=owner(); ws=workspace(); a_cfg=configured('POPUP_A'); b_cfg=configured('POPUP_B') sx=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0 base_x=o['rect']['x']-sx+o['window_rect']['x'] base_y=o['rect']['y']+o['window_rect']['y'] a_box=[base_x+a_cfg[0],base_y+a_cfg[1],base_x+a_cfg[0]+a_cfg[2],base_y+a_cfg[1]+a_cfg[3]] b_box=[a_box[0]+b_cfg[0],a_box[1]+b_cfg[1],a_box[0]+b_cfg[0]+b_cfg[2],a_box[1]+b_cfg[1]+b_cfg[3]] record('applied_popup_geometry',owner_id=o['id'], owner_rect=o['rect'],window_rect=o['window_rect'], camera=sx,a_configure=a_cfg,b_configure=b_cfg,a_box=a_box,b_box=b_box, a_h1=[max(-800,a_box[0]),a_box[1],min(0,a_box[2]),a_box[3]], a_h2=[max(0,a_box[0]),a_box[1],min(800,a_box[2]),a_box[3]], b_h1=[max(-800,b_box[0]),b_box[1],min(0,b_box[2]),b_box[3]], b_h2=[max(0,b_box[0]),b_box[1],min(800,b_box[2]),b_box[3]]) if not (b_box[0] < 0 < b_box[2] and b_box[2]>=16): record('geometry_gate',result='NOT EXERCISED',box=b_box) else: record('geometry_gate',result='CROSSING',box=b_box) # WHY: evaluate a shared y within B and scan both sides of the boundary. y=int((max(0,b_box[1])+min(600,b_box[3]))/2) for x in range(-3,4): before=len(client_text()); inject('move',x,y) record('border_hover',point=[x,y],client_events=client_text()[before:]) inside_x=max(-30,int((max(-800,b_box[0])+min(0,b_box[2]))/2)) before=len(client_text()); inject('move',inside_x,y); inject('right',inside_x,y) record('visible_b_button',point=[inside_x,y],client_events=client_text()[before:]) snapshot('after-visible-button') # A left/right roles may dismiss on exterior click. Preserve it last. if b_box[2]>0: outside_x=min(30,int(b_box[2]/2)) before=len(client_text()); inject('move',outside_x,y); inject('right',outside_x,y) record('forbidden_b_button',point=[outside_x,y],client_events=client_text()[before:]) time.sleep(.15) snapshot('after-forbidden-button') except Exception as exc: failure = repr(exc) record('failure', error=failure) finally: for proc in children + ([compositor] if compositor is not None else []): if proc.poll() is None: try: os.killpg(proc.pid, signal.SIGTERM) except ProcessLookupError: pass for proc in children + ([compositor] if compositor is not None else []): try: proc.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=5) (run / 'exit-status.json').write_text(json.dumps({ 'failure': failure, 'compositor': None if compositor is None else compositor.returncode, 'children': [{'argv': p.args, 'returncode': p.returncode} for p in children], 'captures': capture_count, 'capture_bytes': capture_bytes, 'duration_seconds': time.monotonic() - started}, indent=2) + '\n') for file in files: file.close() shutil.rmtree(runtime) record('cleanup', runtime_removed=not runtime.exists()) print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}') if failure: raise SystemExit(1)