Files
StarPilot/openpilot/selfdrive/ui/tests/test_arabesque_browser.py
T
2026-11-02 11:13:02 -06:00

1082 lines
44 KiB
Python

"""Exercise protocol bounds, frame ownership and the real tool lifecycle without a browser installation."""
import array
import ast
import fcntl
import json
import mmap
import os
import runpy
import socket
import sys
from types import SimpleNamespace as NS
from threading import Event, Thread
from unittest.mock import Mock
from openpilot.starpilot.ui.raylib import rl
import pytest
from openpilot.selfdrive.ui.layouts.arabesque.tools import browser, browser_session as runtime
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
from openpilot.system.ui.widgets import Widget
def test_sealed_frames_cannot_resize_and_roundtrip_fds():
fd = runtime.sealed_slot(4096)
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
assert fcntl.fcntl(fd, runtime.GET_SEALS) == runtime.SEALS
with pytest.raises(OSError):
os.ftruncate(fd, 0)
with mmap.mmap(fd, 4096) as writer:
writer[:4] = b'BGRX'
runtime.packet(left, {'type': 'init'}, [fd])
message, descriptors = runtime.receive(right, 1)
try:
assert message == {'type': 'init'}
assert not os.get_inheritable(descriptors[0])
with mmap.mmap(descriptors[0], 4096, access=mmap.ACCESS_READ) as reader:
assert reader[:4] == b'BGRX'
finally:
os.close(descriptors[0])
finally:
os.close(fd)
left.close()
right.close()
@pytest.mark.parametrize('payload', [b'[]', b'{', b'{"type":7}', b'{"type":"x","pad":"' + b'x' * 4096 + b'"}'],
ids=['array', 'invalid-json', 'invalid-type', 'oversized'])
def test_malformed_or_truncated_packets_are_rejected(payload):
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
left.send(payload)
with pytest.raises(ValueError):
runtime.receive(right)
finally:
left.close()
right.close()
def test_unexpected_descriptor_is_closed():
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
fd = runtime.sealed_slot(4096)
before = len(os.listdir('/proc/self/fd'))
try:
left.sendmsg([b'{"type":"x"}'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [fd]))])
with pytest.raises(ValueError):
runtime.receive(right)
assert len(os.listdir('/proc/self/fd')) == before
finally:
os.close(fd)
left.close()
right.close()
@pytest.mark.parametrize('values', [(True, 600, 20), (1601, 600, 20), (1440, -1, 20), (1440, 650, 31), (1440, 650, 0)])
def test_geometry_is_bounded(values):
with pytest.raises(ValueError):
runtime.geometry(*values)
def test_transient_launcher_has_no_host_data_or_display_privileges():
command = runtime.launch_command('arabesque-firefox-test', 'test', 1440, 650, 20, 'shm')
assert command[:6] == ['sudo', '-n', '/usr/bin/timeout', '--kill-after=1', '15', '/usr/bin/systemd-run']
assert '--property=PrivateTmp=yes' in command
assert '--property=ProtectSystem=strict' in command
assert not any(part.startswith('--property=ProtectKernelTunables=') for part in command)
binds = next(part for part in command if part.startswith('--property=BindReadOnlyPaths='))
assert f'{runtime.RUNTIME}/isolation/devices:/dev' in binds
assert all(f'{runtime.RUNTIME}/isolation/empty:{path}' in binds for path in ('/home', '/root', '/run/user'))
assert '/sys /proc/sys ' in binds
assert ('--property=TemporaryFileSystem=/data:ro /persist:ro /mnt:ro /media:ro /cache:ro /rwtmp:ro /var:ro '
+ '/dev/shm:rw,nosuid,nodev,noexec,mode=1777') in command
assert '--property=CPUAffinity=0-5' in command
assert '--property=KillMode=mixed' in command
assert '--property=Restart=no' in command
assert '--property=WatchdogSec=4' in command and '--property=LimitNPROC=1024' in command
assert '--reuid=61234' in command and '--clear-groups' in command and '--no-new-privs' in command
assert not any('NO_SANDBOX' in part or 'disable-sandbox' in part for part in command)
def test_first_use_preparer_is_temporary_supervised_and_resource_limited():
command = runtime.preparation_command('arabesque-firefox-test', 'test')
assert '--collect' in command
assert '--property=WatchdogSec=4' in command
assert '--property=RuntimeMaxSec=900' in command
assert '--property=CapabilityBoundingSet=CAP_CHOWN' in command
assert f'--property=ExecStartPre=-+/usr/bin/mkdir -m 0755 {runtime.ROOT}' in command
assert '--property=ProtectSystem=strict' in command
assert '--property=ReadWritePaths=/data' in command
assert '--property=CPUAffinity=0-5' in command
assert '--property=Nice=10' in command and '--property=MemoryMax=1G' in command
def test_probe_and_profile_job_bind_only_their_intended_data():
identity = 'a' * 32
candidate = runtime.ROOT / 'updates' / identity / 'candidate'
probe = runtime.ROOT / 'updates' / identity / 'probe-state'
command = runtime.launch_command('test', 'test', 320, 240, 10, 'shm', 'qualify', identity)
readonly = next(value for value in command if value.startswith('--property=BindReadOnlyPaths='))
writable = next(value for value in command if value.startswith('--property=BindPaths='))
assert f'{candidate}:{runtime.RUNTIME}' in readonly
assert f'{candidate}/isolation/devices:/dev' in readonly
assert f'{candidate}/isolation/empty:/home' in readonly
assert f'{probe}:{runtime.STATE}' in writable
assert 'profile-backup' not in ' '.join(command)
assert '--property=PrivateNetwork=yes' not in command # Abstract Unix owner sockets share the host network namespace.
assert not any(value.startswith('--property=ProtectKernelTunables=') for value in command)
command = runtime.launch_command('test', 'test', 320, 240, 10, 'auto', 'copy', identity)
readonly = next(value for value in command if value.startswith('--property=BindReadOnlyPaths='))
writable = next(value for value in command if value.startswith('--property=BindPaths='))
assert str(runtime.STATE) in readonly
assert str(runtime.STATE) not in writable
assert f'{runtime.ROOT}/updates:/tmp/arabesque-updates' in writable
assert '--property=RuntimeMaxSec=900' in command
@pytest.mark.parametrize('purpose', ['browse', 'qualify', 'copy', 'restore-copy', 'cleanup'])
def test_restore_scratch_is_only_writable_to_its_maintenance_jobs(purpose):
command = runtime.launch_command('test', 'test', 320, 240, 10, 'auto', purpose, '' if purpose == 'browse' else 'a' * 32)
writable = next(value for value in command if value.startswith('--property=BindPaths='))
paths = next(value for value in command if value.startswith('--property=ReadWritePaths='))
assert str(runtime.ROOT / 'recovery-state') not in ' '.join(command)
if purpose in ('restore-copy', 'cleanup'):
optional = '-' if purpose == 'cleanup' else ''
assert f'{optional}{runtime.ROOT}/restore-state:/tmp/arabesque-restore-state' in writable
assert f'{optional}/tmp/arabesque-restore-state' in paths
assert str(runtime.STATE) not in writable
else:
assert str(runtime.ROOT / 'restore-state') not in ' '.join(command)
@pytest.mark.parametrize('cancel', [False, True])
def test_first_use_prepares_without_frames_and_waits_before_launching(tmp_path, monkeypatch, cancel):
root = tmp_path / 'firefox'
monkeypatch.setattr(runtime, 'ROOT', root)
monkeypatch.setattr(runtime, 'RUNTIME', root / 'runtime')
monkeypatch.setattr(runtime, 'STATE', root / 'state')
monkeypatch.setattr(runtime.BrowserSession, 'pending_stop', None)
launch = Mock(return_value=['prepare'])
monkeypatch.setattr(runtime, 'preparation_command', launch)
monkeypatch.setattr(runtime.subprocess, 'Popen', Mock())
monkeypatch.setattr(runtime, 'stop_unit', lambda *args: None)
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), 0, 0))
monkeypatch.setattr(runtime, 'sealed_slot', Mock(side_effect=AssertionError('Frames allocated before preparation')))
session = runtime.BrowserSession(1440, 650, 20)
client = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
client.settimeout(2)
try:
assert not session.error and session._preparing and not session.buffers
launch.assert_called_once()
client.connect(session._listener.getsockname())
session.poll()
assert runtime.receive(client)[0] == {'type': 'prepare', 'operation': 'install', 'target': ''}
runtime.packet(client, {'type': 'status', 'message': 'Downloading Firefox… 50%'})
session.poll()
assert session.status == 'Downloading Firefox… 50%'
runtime.packet(client, {'type': 'prepared'})
session.poll()
start = Mock()
monkeypatch.setattr(session, '_start', start)
session.poll()
start.assert_not_called()
if cancel:
session.close()
session.stopped.set()
session.poll()
assert start.call_count == (0 if cancel else 1)
finally:
client.close()
session.close()
def test_prepared_runtime_does_not_start_preparation(tmp_path, monkeypatch):
root = tmp_path / 'firefox'
app = root / 'runtime/firefox/firefox'
app.parent.mkdir(parents=True)
app.write_bytes(b'firefox')
(root / 'runtime/ready.json').write_text(json.dumps(runtime.load_setup().approved()))
(root / 'runtime/ready.json').chmod(0o644)
(root / 'state').mkdir()
for path in (root, root / 'runtime', app, root / 'state'):
path.chmod(0o700 if path == root / 'state' else 0o755)
owners = {root: 0, root / 'runtime': 0, root / 'state': runtime.BROWSER_UID, app: 0, root / 'runtime/ready.json': 0}
original_stat = runtime.Path.stat
def stat(path, *args, **kwargs):
info = original_stat(path, *args, **kwargs)
if path in owners:
values = list(info)
values[4] = owners[path]
return os.stat_result(values)
return info
monkeypatch.setattr(runtime.Path, 'stat', stat)
monkeypatch.setattr(runtime.Path, 'lstat', lambda path: stat(path, follow_symlinks=False))
monkeypatch.setattr(runtime, 'ROOT', root)
monkeypatch.setattr(runtime, 'RUNTIME', root / 'runtime')
monkeypatch.setattr(runtime, 'STATE', root / 'state')
monkeypatch.setattr(runtime.BrowserSession, 'pending_stop', None)
prepare, launch = Mock(), Mock(return_value=['browser'])
monkeypatch.setattr(runtime, 'preparation_command', prepare)
monkeypatch.setattr(runtime, 'launch_command', launch)
monkeypatch.setattr(runtime.subprocess, 'Popen', Mock())
monkeypatch.setattr(runtime, 'stop_unit', lambda *args: None)
session = runtime.BrowserSession(1440, 650, 20)
try:
assert not session.error and not session._preparing and len(session.buffers) == 2
prepare.assert_not_called()
launch.assert_called_once()
finally:
session.close()
@pytest.mark.parametrize('finish', ['success', 'close', 'eof', 'lease'])
def test_preparation_progress_completion_and_owner_cleanup(monkeypatch, finish):
client, worker = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
client.settimeout(3)
connection = Mock(wraps=worker)
connection.connect = Mock()
setup, complete, installer_finished = Mock(), Event(), Event()
def prepare():
try:
setup.report('Downloading Firefox… 50%')
complete.wait(3)
setup.report('Prepared')
finally:
installer_finished.set()
setup.prepare.side_effect = prepare
monkeypatch.setattr(runtime.socket, 'socket', Mock(return_value=connection))
monkeypatch.setattr(runtime.signal, 'signal', Mock())
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), os.getuid(), os.getgid()))
monkeypatch.setattr(runtime, 'notify', Mock())
monkeypatch.setattr(runtime, 'LEASE_SECONDS', 0.15)
monkeypatch.setattr(runtime.sys, 'argv', ['prepare', 'address', str(os.getuid()), str(os.getpid())])
monkeypatch.setattr(runtime.importlib.util, 'spec_from_file_location', Mock(return_value=Mock(loader=Mock())))
monkeypatch.setattr(runtime.importlib.util, 'module_from_spec', Mock(return_value=setup))
runtime.packet(client, {'type': 'prepare', 'operation': 'install', 'target': ''})
thread = Thread(target=runtime.preparation_main, daemon=True)
try:
thread.start()
message, _ = runtime.receive(client)
assert message['type'] == 'status'
if finish == 'success':
complete.set()
while message['type'] == 'status':
message, _ = runtime.receive(client)
assert message == {'type': 'prepared'}
elif finish == 'close':
runtime.packet(client, {'type': 'close'})
elif finish == 'eof':
client.close()
thread.join(timeout=2)
assert not thread.is_alive()
assert setup.ROOT == runtime.ROOT
runtime.notify.assert_any_call('READY=1\nWATCHDOG=1')
finally:
complete.set()
thread.join(timeout=3)
assert installer_finished.wait(1)
client.close()
worker.close()
@pytest.mark.parametrize('finish', ['success', 'close', 'lease'])
def test_update_controller_retains_lock_and_owner_lease_between_jobs(tmp_path, monkeypatch, finish):
client, worker = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
client.settimeout(2)
connection = Mock(wraps=worker)
connection.connect = Mock()
setup, completed = Mock(), Event()
lock_path = tmp_path / 'lock'
def maintain(operation, target, job):
try:
assert operation == 'update' and target == 'a' * 64
with lock_path.open('w+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
for kind in ('copy', 'qualify', 'cleanup'):
job(kind, 'b' * 32, lock.fileno(), [])
finally:
completed.set()
setup.maintain.side_effect = maintain
monkeypatch.setattr(runtime, 'load_setup', lambda: setup)
monkeypatch.setattr(runtime.socket, 'socket', Mock(return_value=connection))
monkeypatch.setattr(runtime.signal, 'signal', Mock())
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), os.getuid(), os.getgid()))
monkeypatch.setattr(runtime, 'notify', Mock())
monkeypatch.setattr(runtime, 'LEASE_SECONDS', 0.3)
monkeypatch.setattr(runtime.sys, 'argv', ['prepare', 'address', str(os.getuid()), str(os.getpid())])
runtime.packet(client, {'type': 'prepare', 'operation': 'update', 'target': 'a' * 64})
thread = Thread(target=runtime.preparation_main, daemon=True)
try:
thread.start()
kinds = []
while True:
message, fds = runtime.receive(client, None)
if message['type'] == 'status':
try:
runtime.packet(client, {'type': 'lease'})
except (BrokenPipeError, ConnectionResetError):
pass # The controller may already have queued prepared and closed.
continue
if message == {'type': 'prepared'}:
assert kinds == ['copy', 'qualify', 'cleanup']
break
assert message['type'] == 'job' and len(fds) == 1
kinds.append(message['job'])
try:
assert os.path.samestat(os.fstat(fds[0]), lock_path.stat())
fcntl.flock(fds[0], fcntl.LOCK_EX | fcntl.LOCK_NB)
with lock_path.open('rb') as other:
with pytest.raises(BlockingIOError):
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
finally:
for fd in fds:
os.close(fd)
if finish == 'success':
runtime.packet(client, {'type': 'lease'})
runtime.packet(client, {'type': 'job_done'})
else:
if finish == 'close':
runtime.packet(client, {'type': 'close'})
break
thread.join(timeout=2)
assert not thread.is_alive() and completed.is_set()
runtime.notify.assert_any_call('READY=1\nWATCHDOG=1')
finally:
client.close()
thread.join(timeout=2)
worker.close()
@pytest.mark.parametrize('finish', ['success', 'close', 'lease'])
def test_profile_service_remains_responsive_and_releases_its_lock(tmp_path, monkeypatch, finish):
client, worker = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
client.settimeout(2)
connection = Mock(wraps=worker)
connection.connect = Mock()
lock_path = tmp_path / 'lock'
complete, exited = Event(), Event()
def copy(purpose, identity, garbage, report):
try:
report('Copying Firefox profile...')
complete.wait(2)
report('Finished')
finally:
exited.set()
real_path = runtime.Path
monkeypatch.setattr(runtime, 'Path', lambda path: lock_path if path == '/tmp/arabesque-lock' else real_path(path))
monkeypatch.setattr(runtime, 'profile_job', copy)
monkeypatch.setattr(runtime, 'check_isolation', isolation := Mock())
monkeypatch.setattr(runtime.socket, 'socket', Mock(return_value=connection))
monkeypatch.setattr(runtime.signal, 'signal', Mock())
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), os.getuid(), os.getgid()))
monkeypatch.setattr(runtime, 'notify', Mock())
monkeypatch.setattr(runtime, 'LEASE_SECONDS', 0.3)
monkeypatch.setattr(runtime.sys, 'argv', ['copy', 'address', str(os.getuid()), str(os.getpid()), 'host-mount',
'320', '240', '10', 'auto', 'copy', 'b' * 32])
with lock_path.open('w+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
runtime.packet(client, {'type': 'init', 'width': 320, 'height': 240, 'fps': 10,
'purpose': 'copy', 'transaction': 'b' * 32, 'garbage': []}, [lock.fileno()])
thread = Thread(target=runtime.maintenance_main, daemon=True)
try:
thread.start()
assert runtime.receive(client)[0]['type'] == 'status'
if finish == 'success':
complete.set()
message = runtime.receive(client)[0]
while message['type'] == 'status':
message = runtime.receive(client)[0]
assert message == {'type': 'done'}
elif finish == 'close':
runtime.packet(client, {'type': 'close'})
thread.join(timeout=1.5)
assert not thread.is_alive()
isolation.assert_called_once_with('host-mount', readonly_state=True)
finally:
complete.set()
thread.join(timeout=2)
assert exited.wait(1)
client.close()
worker.close()
def test_take_latest_frame_releases_old_slot_after_bounded_handoff():
session = runtime.BrowserSession.__new__(runtime.BrowserSession)
session._job = None
session._frames, session.buffers, session.release = [0, 1], [b'old', b'new'], Mock()
session.ready = True
assert session.take_frame() == (1, b'new')
session.release.assert_called_once_with(0)
assert session.take_frame() is None
def test_input_is_bounded_and_modifiers_are_explicit():
x, held = Mock(), set()
runtime.inject(x, {'type': 'input', 'event': 'motion', 'a': 3, 'b': 9}, held, 1440, 650)
x.motion.assert_called_once_with(3, 9)
runtime.inject(x, {'type': 'input', 'event': 'key', 'a': 0x61, 'b': 1}, held, 1440, 650)
x.key.assert_called_once_with(0x61, ctrl=True, alt=False, shift=False)
runtime.inject(x, {'type': 'input', 'event': 'button', 'a': 1, 'b': True}, held, 1440, 650)
assert held == {1}
for event, a, b in (('motion', 1440, 0), ('motion', -1, 0), ('button', 7, True), ('button', 1, 1), ('key', 0x61, 8)):
with pytest.raises(ValueError):
runtime.inject(x, {'type': 'input', 'event': event, 'a': a, 'b': b}, held, 1440, 650)
@pytest.fixture
def tool(monkeypatch):
monkeypatch.setattr(browser, 'device', NS(awake=True))
monkeypatch.setattr('openpilot.system.ui.widgets.device', NS(awake=True))
monkeypatch.setattr(gui_app, '_mouse_events', [])
for key in ('get_key_pressed', 'get_char_pressed'):
monkeypatch.setattr(rl, key, lambda: 0)
monkeypatch.setattr(rl, 'set_exit_key', Mock())
monkeypatch.setattr(browser, 'gui_label', Mock())
for key in ('draw_rectangle_rec', 'draw_rectangle_rounded', 'draw_circle_lines'):
monkeypatch.setattr(rl, key, Mock())
sessions = []
setup = Mock()
setup.inspect.return_value = {'action': 'browse', 'target': {}}
monkeypatch.setattr(browser, 'load_setup', lambda: setup)
def create(width, height, fps, **kwargs):
session = Mock(width=width, height=height, fps=fps, ready=True, interactive=True, generation=0, error='', status='shm', **kwargs)
session.stopped = Event()
session.stopped.set()
sessions.append(session)
return session
create.pending_stop = None
monkeypatch.setattr(browser, 'BrowserSession', create)
permitted = Mock(return_value=True)
view = browser.Browser(permitted)
view.set_rect(rl.Rectangle(0, 104, 2160, 976))
yield NS(view=view, sessions=sessions, permitted=permitted, setup=setup)
view.hide_event()
def touch(view, x, y, *, pressed=False, released=False):
gui_app._mouse_events = [MouseEvent(MousePos(x, y), 0, pressed, released, pressed, 0)]
Widget.render(view)
def test_loading_status_and_pan_controls_have_readable_labels(tool):
view = tool.view
view.show_event()
tool.sessions[0].status = 'Downloading Firefox… 50%'
view._keyboard = True
view._update_layout_rects()
view._render(view.rect)
labels = [call.args[1] for call in browser.gui_label.call_args_list]
assert labels[0] == 'Downloading Firefox... 50%'
assert 'Up' in labels and 'Down' in labels
def test_no_runtime_until_open_and_none_after_close_or_sleep(tool):
view, sessions = tool.view, tool.sessions
assert sessions == []
view.show_event()
first = sessions[0]
view.poll_runtime(True)
first.poll.assert_called_once_with()
view.poll_runtime(False)
first.close.assert_called_once()
assert view._session is None and view._texture is None and view._shader is None
view.poll_runtime(False)
assert len(sessions) == 1
view.poll_runtime(True)
assert len(sessions) == 2
view.hide_event()
sessions[1].close.assert_called_once()
view.hide_event()
sessions[1].close.assert_called_once()
assert view._prepare not in gui_app._render_prepare
def test_scroll_is_not_a_click_and_pointer_disable_releases(tool):
view = tool.view
view.show_event()
session = tool.sessions[0]
touch(view, 600, 400, pressed=True)
assert not any(c.args[0].get('event') == 'button' for c in session.send.call_args_list)
touch(view, 600, 250)
touch(view, 600, 250, released=True)
buttons = [c.args[0] for c in session.send.call_args_list if c.args[0].get('event') == 'button']
assert buttons and all(p['a'] in (4, 5) for p in buttons)
session.send.reset_mock()
view._pointer = True
touch(view, 600, 400, pressed=True)
assert any(c.args[0] == {'type': 'input', 'event': 'button', 'a': 1, 'b': True} for c in session.send.call_args_list)
view.set_enabled(False)
session.reset_input.assert_called_once()
touch(view, 600, 400, released=True)
assert view._gesture is None
def test_keyboard_geometry_pans_without_distorting_or_mapping_chrome(tool):
view = tool.view
view.show_event()
assert view._point(MousePos(100, 120)) is None
factor = view._factor
view._keyboard = True
view._update_layout_rects()
assert view._factor == factor
assert view._point(MousePos(100, 1000)) is None
view._scroll_y = 10000
view._update_layout_rects()
assert 0 < view._scroll_y < view._session.height
def test_fast_wake_waits_for_old_process_group_and_keeps_geometry(tool):
view = tool.view
view.show_event()
old = tool.sessions[0]
height = old.height
view._keyboard = True
view._update_layout_rects()
old.stopped.clear()
view.poll_runtime(False)
view.poll_runtime(True)
assert len(tool.sessions) == 1 and view._session is None
old.stopped.set()
view.poll_runtime(True)
assert len(tool.sessions) == 2 and tool.sessions[-1].height == height
def test_new_browser_waits_for_previous_widget_cleanup(tool):
tool.view.show_event()
old = tool.sessions[0]
old.stopped.clear()
old.close.side_effect = lambda: setattr(browser.BrowserSession, 'pending_stop', old.stopped)
tool.view.hide_event()
view = browser.Browser(tool.permitted)
view.set_rect(rl.Rectangle(0, 104, 2160, 976))
try:
view.show_event()
view.poll_runtime(True)
assert len(tool.sessions) == 1 and view._session is None
old.stopped.set()
view.poll_runtime(True)
assert len(tool.sessions) == 2 and view._session is tool.sessions[-1]
finally:
view.hide_event()
def test_failed_frame_release_does_not_return_a_closed_mapping():
session = runtime.BrowserSession.__new__(runtime.BrowserSession)
session._job = None
session._frames, session.buffers, session.ready = [0, 1], [b'old', b'new'], True
def disconnect(slot):
session.ready = False
session.buffers = []
session.release = Mock(side_effect=disconnect)
assert session.take_frame() is None
def test_stop_command_and_launcher_are_reaped_even_on_failure(monkeypatch):
stopped, launcher = Event(), Mock()
monkeypatch.setattr(runtime.subprocess, 'run', Mock(side_effect=OSError('supervisor unavailable')))
runtime.stop_unit('arabesque-firefox-test', launcher, stopped)
launcher.wait.assert_called_once_with(timeout=12)
assert stopped.is_set()
def test_controller_stop_event_waits_for_its_job_cleanup(monkeypatch):
stopped, child, reaped = Event(), Event(), Event()
launcher = Mock()
launcher.wait.side_effect = lambda **_: reaped.set()
monkeypatch.setattr(runtime.subprocess, 'run', Mock())
thread = Thread(target=runtime.stop_unit, args=('controller', launcher, stopped, child), daemon=True)
try:
thread.start()
assert reaped.wait(1)
assert not stopped.is_set()
finally:
child.set()
thread.join(timeout=1)
assert stopped.is_set()
def test_firefox_quits_cleanly_before_using_signals(monkeypatch):
process, x = Mock(), Mock()
process.poll.side_effect = [None, None, 0, 0]
monkeypatch.setattr(runtime.time, 'sleep', Mock())
runtime.stop_firefox(process, x)
x.key.assert_called_once_with(ord('q'), ctrl=True)
process.terminate.assert_not_called()
@pytest.mark.parametrize('broken_display', [True, False])
def test_firefox_stop_still_forces_exit_on_display_error_or_hang(monkeypatch, broken_display):
process, x = Mock(), Mock()
process.poll.return_value = None
if broken_display:
x.key.side_effect = RuntimeError('X display closed')
monkeypatch.setattr(runtime.time, 'monotonic', Mock(side_effect=[0, 2]))
runtime.stop_firefox(process, x)
process.terminate.assert_called_once()
process.wait.assert_called_once_with(timeout=1)
def test_tab_probe_uses_cgroup_without_proc_children_and_matches_parent(tmp_path, monkeypatch):
proc = tmp_path / 'proc'
roles = {10: ('firefox', b'firefox\0'), 20: ('forkserver', b'firefox -contentproc forkserver\0'),
30: ('file:// Content', b'firefox -contentproc -isForBrowser -parentPid 10 5 tab\0')}
for pid, (name, command) in roles.items():
base = proc / str(pid)
base.mkdir(parents=True)
(base / 'cmdline').write_bytes(command)
(base / 'status').write_text(f'Name:\t{name}\nUid:\t{os.getuid()}\nSeccomp:\t2\nNoNewPrivs:\t1\nCapEff:\t0000\n')
(proc / '10/cgroup').write_text('0::/test.service\n')
cgroup = tmp_path / 'cgroup'
(cgroup / 'test.service').mkdir(parents=True)
(cgroup / 'test.service/cgroup.procs').write_text('10\n20\n30\n')
real_path = runtime.Path
def fake_path(path):
if str(path).startswith('/proc/'):
return proc / str(path)[6:]
return cgroup if str(path) == '/sys/fs/cgroup' else real_path(path)
monkeypatch.setattr(runtime, 'Path', fake_path)
assert runtime.content_sandboxed(10)
(proc / '30/status').write_text(f'Name:\tfile:// Content\nUid:\t{os.getuid()}\nSeccomp:\t2\nCapEff:\t0000\n')
monkeypatch.setattr(runtime, 'no_new_privileges', lambda: 0)
assert not runtime.content_sandboxed(10)
monkeypatch.setattr(runtime, 'no_new_privileges', lambda: 1)
assert runtime.content_sandboxed(10)
(proc / '30/cmdline').write_bytes(b'firefox -contentproc -isForBrowser -parentPid 11 5 tab\0')
assert not runtime.content_sandboxed(10)
(proc / '30/cmdline').write_bytes(b'firefox -contentproc -parentPid 10 5 utility\0')
assert not runtime.content_sandboxed(10)
def test_browser_poll_follows_fresh_state_before_any_render():
path = browser.__file__
from pathlib import Path
path = Path(path).parents[3] / 'ui.py'
tree = ast.parse(path.read_text())
tree.body = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == 'update_frame']
events = []
namespace: dict = {'gui_app': NS(measure_frame_phase=lambda _, callback: callback()), 'ui_state': NS(update=lambda: events.append('state'))}
exec(compile(ast.fix_missing_locations(tree), str(path), 'exec'), namespace)
namespace['update_frame'](arabesque=lambda: events.append('browser'))
assert events == ['state', 'browser']
def test_diagnostic_entry_cannot_start_without_fresh_offroad(monkeypatch):
authority = Mock()
authority.offroad.return_value = False
monkeypatch.setitem(sys.modules, 'openpilot.common.params', NS(Params=Mock()))
monkeypatch.setitem(sys.modules, 'openpilot.starpilot.galaxy.settings', NS(LiveContextSource=Mock(return_value=authority)))
monkeypatch.setattr(sys, 'argv', [runtime.__file__, '--seconds', '1'])
monkeypatch.setattr(sys, 'path', list(sys.path))
launch = Mock()
monkeypatch.setattr(runtime.subprocess, 'Popen', launch)
with pytest.raises(SystemExit, match='fresh off-road authority'):
runpy.run_path(runtime.__file__, run_name='__main__')
launch.assert_not_called()
authority.close.assert_called_once()
def test_wake_touch_and_revoked_authority_never_inject(tool):
view = tool.view
view.show_event()
view.poll_runtime(False)
view.poll_runtime(True)
touch(view, 600, 400, pressed=True)
assert view._gesture is None
assert not tool.sessions[-1].send.called
tool.permitted.return_value = False
view.poll_runtime(True)
assert view._session is None
tool.sessions[-1].close.assert_called_once()
def test_commit_exact_unicode_without_terminal_normalization(tool):
view = tool.view
view.show_event()
view._text('é€\u0301😀')
assert [c.args[0]['a'] for c in tool.sessions[0].send.call_args_list] == [0xe9, 0x010020ac, 0x01000301, 0x0101f600]
def click_action(view, key):
view._render(view.rect)
bounds = next(bounds for bounds, _, value in view._actions if value == key)
pos = MousePos(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
view._handle_mouse_press(pos)
view._handle_mouse_release(pos)
@pytest.mark.parametrize('choice', ['update', 'later'])
def test_offer_starts_no_worker_and_later_survives_sleep_until_new_open(tool, choice):
tool.setup.inspect.return_value = {'action': 'update', 'target': {'version': '153.7.0esr'}, 'current': {'version': '153.6.0esr'}}
view = tool.view
view.show_event()
assert not tool.sessions
assert view._offer
click_action(view, choice)
assert len(tool.sessions) == 1
assert tool.sessions[0].operation == ('update' if choice == 'update' else 'browse')
view.poll_runtime(False)
view.poll_runtime(True)
assert tool.sessions[-1].operation == 'browse'
view.hide_event()
fresh = browser.Browser(tool.permitted)
fresh.set_rect(view.rect)
try:
fresh.show_event()
assert fresh._offer and fresh._session is None
finally:
fresh.hide_event()
@pytest.mark.parametrize('choice', ['update', 'later'])
def test_queued_double_tap_consumes_offer_once_and_close_keeps_its_owner(tool, choice):
tool.setup.inspect.return_value = {'action': 'update', 'target': {'version': '153.7.0esr'}, 'current': {'version': '153.6.0esr'}}
view = tool.view
view.show_event()
view._render(view.rect)
bounds = next(bounds for bounds, _, key in view._actions if key == choice)
position = MousePos(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
gui_app._mouse_events = [MouseEvent(position, 0, pressed, not pressed, pressed, 0) for pressed in (True, False, True, False)]
Widget.render(view)
assert len(tool.sessions) == 1 and view._offer is None
assert tool.sessions[0].operation == ('update' if choice == 'update' else 'browse')
view.hide_event()
tool.sessions[0].close.assert_called_once()
def test_restore_requires_confirmation_and_does_not_start_while_asleep(tool):
tool.setup.inspect.return_value = {'action': 'browse', 'target': {}, 'restorable': True}
view = tool.view
view.show_event()
tool.sessions[0].error = 'Firefox did not start'
tool.sessions[0].interactive = False
click_action(view, 'restore')
assert view._confirm_restore and len(tool.sessions) == 1
view.poll_runtime(False)
view.poll_runtime(True)
assert len(tool.sessions) == 1
click_action(view, 'confirm-restore')
view.poll_runtime(True)
assert tool.sessions[-1].operation == 'restore'
def test_offer_cannot_silently_switch_to_a_different_checkout_bundle(tool):
tool.setup.bundle_id.side_effect = lambda target: target['version']
tool.setup.inspect.return_value = {'action': 'update', 'target': {'version': '153.7.0esr'}, 'current': {'version': '153.6.0esr'}}
tool.view.show_event()
tool.setup.inspect.return_value = {'action': 'update', 'target': {'version': '153.8.0esr'}, 'current': {'version': '153.6.0esr'}}
click_action(tool.view, 'update')
assert not tool.sessions and not tool.view._offer
assert 'update changed' in tool.view._message
def test_probe_frames_can_upload_while_browser_input_and_display_stay_blocked(tool, monkeypatch):
view = tool.view
view.show_event()
session = tool.sessions[0]
session.interactive = False
session.take_frame.return_value = (0, bytearray(session.width * session.height * 4))
shader = NS(id=23)
texture = NS(id=24)
monkeypatch.setattr(rl, 'load_shader_from_memory', lambda *_: shader)
monkeypatch.setattr(rl, 'is_shader_valid', lambda _: True)
monkeypatch.setattr(rl, 'rl_get_shader_id_default', lambda: 1)
monkeypatch.setattr(rl, 'load_texture_from_image', lambda _: texture)
monkeypatch.setattr(rl, 'is_texture_valid', lambda _: True)
monkeypatch.setattr(rl, 'set_texture_filter', Mock())
monkeypatch.setattr(rl, 'unload_texture', Mock())
monkeypatch.setattr(rl, 'unload_shader', Mock())
monkeypatch.setattr(rl, 'draw_texture_pro', draw := Mock())
view._input('key', ord('a'), 0)
view._update_state()
touch(view, 600, 400, pressed=True)
session.send.assert_not_called()
view._prepare()
session.uploaded.assert_called_once_with(0)
session.release.assert_called_once_with(0)
view._render(view.rect)
draw.assert_not_called()
# A transition must discard the verified probe texture before ordinary Firefox becomes interactive.
session.generation += 1
session.ready = False
view._prepare()
assert view._texture is None
rl.unload_texture.assert_called_once_with(texture)
def test_failed_gpu_upload_cannot_acknowledge_the_candidate(tool, monkeypatch):
tool.view.show_event()
session = tool.sessions[0]
session.interactive = False
session.take_frame.return_value = (0, bytearray(session.width * session.height * 4))
monkeypatch.setattr(rl, 'load_shader_from_memory', Mock())
monkeypatch.setattr(rl, 'is_shader_valid', lambda _: False)
tool.view._prepare()
session.uploaded.assert_not_called()
session.fail.assert_called_once_with('Firefox color shader failed')
session.release.assert_called_once_with(0)
tool.view._shader = None
@pytest.fixture
def controller_owner(tmp_path, monkeypatch):
root = tmp_path / 'not-installed'
monkeypatch.setattr(runtime, 'ROOT', root)
monkeypatch.setattr(runtime, 'RUNTIME', root / 'runtime')
monkeypatch.setattr(runtime, 'STATE', root / 'state')
monkeypatch.setattr(runtime, 'preparation_command', lambda *_: ['controller'])
monkeypatch.setattr(runtime, 'launch_command', lambda *_: ['job'])
monkeypatch.setattr(runtime.subprocess, 'Popen', lambda *a, **kw: Mock(poll=lambda: None))
monkeypatch.setattr(runtime, 'stop_unit', lambda unit, process, stopped: stopped.set())
return root
@pytest.mark.parametrize('purpose', ['update', 'copy', 'cleanup', 'restore-copy'])
@pytest.mark.parametrize('unread_lease', [False, True])
def test_completed_peer_can_close_before_the_next_owner_heartbeat(controller_owner, monkeypatch, purpose, unread_lease):
maintenance = purpose != 'update'
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), runtime.BROWSER_UID if maintenance else 0, 0))
session = runtime.BrowserSession(320, 240, 10, operation=purpose, transaction='a' * 32,
lock_fd=runtime.sealed_slot(1) if maintenance else None)
peer = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
peer.settimeout(1)
try:
peer.connect(session._listener.getsockname())
session.poll()
_, descriptors = runtime.receive(peer, 1 if maintenance else 0)
for fd in descriptors:
os.close(fd)
if unread_lease:
runtime.packet(session._socket, {'type': 'lease'})
runtime.packet(peer, {'type': 'done' if maintenance else 'prepared'})
peer.close()
session._heartbeat -= 1
session.poll()
assert not session.error
assert session._finished if maintenance else session._next_start
finally:
peer.close()
session.close()
@pytest.mark.parametrize('unread_lease', [False, True])
def test_peer_closing_without_completion_still_fails(controller_owner, monkeypatch, unread_lease):
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), 0, 0))
session = runtime.BrowserSession(320, 240, 10, operation='update')
peer = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
peer.settimeout(1)
try:
peer.connect(session._listener.getsockname())
session.poll()
runtime.receive(peer)
if unread_lease:
runtime.packet(session._socket, {'type': 'lease'})
peer.close()
session._heartbeat -= 1
session.poll()
assert session.error and not session._next_start
finally:
peer.close()
session.close()
def test_controller_survives_qualification_and_commits_only_after_upload_and_job_stop(controller_owner, tmp_path, monkeypatch):
monkeypatch.setattr(runtime, 'credentials', Mock(side_effect=[(os.getpid(), 0, 0), (os.getpid(), runtime.BROWSER_UID, runtime.BROWSER_UID)]))
session = runtime.BrowserSession(320, 240, 10, operation='update', target='a' * 64)
control, job = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET), socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
control.settimeout(1)
job.settimeout(1)
with (tmp_path / 'lock').open('w+') as lock:
try:
control.connect(session._listener.getsockname())
session.poll()
assert runtime.receive(control)[0]['operation'] == 'update'
control_unit = session._unit
runtime.packet(control, {'type': 'job', 'job': 'qualify', 'transaction': 'a' * 32, 'garbage': []}, [lock.fileno()])
session.poll()
job.connect(session._job._listener.getsockname())
session.poll()
init, fds = runtime.receive(job, 3)
assert init['purpose'] == 'qualify'
for fd in fds:
os.close(fd)
runtime.packet(job, {'type': 'ready', 'capture': 'shm'})
runtime.packet(job, {'type': 'frame', 'slot': 0})
session.poll()
assert session.ready and not session.interactive and session._unit == control_unit
slot, _ = session.take_frame()
session.poll()
assert session._job is not None and not session._job._uploaded
session.uploaded(slot)
session.release(slot)
session.poll()
assert session._unit == control_unit and not session.ready
message, _ = runtime.receive(control)
while message == {'type': 'lease'}:
message, _ = runtime.receive(control)
assert message == {'type': 'job_done'}
assert session._job is None
runtime.packet(control, {'type': 'prepared'})
session.poll()
assert session._next_start and session._operation == 'browse'
finally:
session.close()
control.close()
job.close()
@pytest.mark.parametrize('purpose,expired', [('copy', False), ('qualify', True)])
def test_maintenance_can_exceed_45_seconds_but_qualification_needs_gpu_ack(controller_owner, monkeypatch, purpose, expired):
lock = runtime.sealed_slot(1)
monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), runtime.BROWSER_UID, runtime.BROWSER_UID))
session = runtime.BrowserSession(320, 240, 10, operation=purpose, transaction='b' * 32, lock_fd=lock)
peer = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
peer.settimeout(1)
try:
peer.connect(session._listener.getsockname())
session.poll()
_, fds = runtime.receive(peer, 1 if purpose == 'copy' else 3)
for fd in fds:
os.close(fd)
session._started -= 60
if purpose == 'qualify':
session.ready = True
session.poll()
assert bool(session.error) == expired
if not expired:
runtime.packet(peer, {'type': 'status', 'message': 'Copying Firefox profile...'})
session.poll()
assert session.status == 'Copying Firefox profile...'
finally:
session.close()
peer.close()
def test_diagnostics_never_install_or_recover(controller_owner, monkeypatch):
setup = Mock()
setup.inspect.return_value = {'action': 'install'}
monkeypatch.setattr(runtime, 'load_setup', lambda: setup)
launch = Mock()
monkeypatch.setattr(runtime.subprocess, 'Popen', launch)
session = runtime.BrowserSession(320, 240, 10, operation='browse')
assert 'Open Firefox in Arabesque' in session.error
launch.assert_not_called()
def test_controller_close_includes_active_job_in_pending_stop(controller_owner, monkeypatch):
session = runtime.BrowserSession(320, 240, 10, operation='update', target='a' * 64)
child = Mock(stopped=Event())
session._job = child
monkeypatch.setattr(runtime, 'Thread', stopper := Mock())
session.close()
child.close.assert_called_once()
assert stopper.call_args.kwargs['args'][-1] is child.stopped
assert runtime.BrowserSession.pending_stop is session.stopped
session.stopped.set()
@pytest.mark.skipif(os.environ.get('ARABESQUE_GPU_TEST') != '1', reason='Needs a disposable Xvfb with GLX')
def test_browser_status_and_pan_labels_have_actual_font_glyphs(monkeypatch):
from openpilot.system.ui.lib import text_measure
from openpilot.system.ui.lib.application import font_fallback, multilang
monkeypatch.setattr(text_measure, '_cache', text_measure.LRUCache(1024))
monkeypatch.setattr(gui_app, '_fonts', {})
monkeypatch.setattr(gui_app, '_fallback_fonts', {})
monkeypatch.setattr(rl, '_orig_draw_text_ex', rl.draw_text_ex, raising=False)
monkeypatch.setattr(rl, 'draw_text_ex', rl.draw_text_ex)
rl.init_window(1080, 488, 'Firefox status font check')
try:
gui_app._load_fonts()
gui_app._patch_text_functions()
view = browser.Browser(lambda: True)
view.set_rect(rl.Rectangle(0, 0, 1080, 488))
view._session = Mock(width=1440, height=650, error='', status='Downloading Firefox… 50%')
view._keyboard = True
view._update_layout_rects()
draw_label = browser.gui_label
labels = []
def record_label(rect, text, *args, **kwargs):
labels.append(text)
draw_label(rect, text, *args, **kwargs)
monkeypatch.setattr(browser, 'gui_label', record_label)
for language in ('en', 'ja', 'ko', 'th', 'zh-CHS', 'zh-CHT'):
monkeypatch.setattr(multilang, '_language', language)
labels.clear()
rl.begin_drawing()
try:
view._render(view.rect)
finally:
rl.end_drawing()
font = font_fallback(gui_app.font(browser.DEFAULT_THEME.font_weight))
for text in labels:
for character in set(text):
index = rl.get_glyph_index(font, ord(character))
assert font.glyphs[index].value == ord(character), f'{language}: missing {character!r} in {text!r}'
for bounds, text, key in view._buttons:
if key in ('pan-up', 'pan-down'):
assert text_measure.measure_text_cached(font, text, round(28 * view._scale)).x <= bounds.width
finally:
for font in (*gui_app._fonts.values(), *gui_app._fallback_fonts.values()):
rl.unload_font(font)
rl.close_window()
@pytest.mark.skipif(os.environ.get('ARABESQUE_GPU_TEST') != '1', reason='Needs a disposable Xvfb with GLX')
def test_real_texture_swizzle_alpha_and_reuse():
rl.init_window(320, 240, 'Arabesque frame check')
view = browser.Browser(lambda: True)
fd = runtime.sealed_slot(320 * 240 * 4)
buffer = mmap.mmap(fd, 320 * 240 * 4)
session = Mock(ready=True, interactive=True, generation=0, width=320, height=240, error='')
session.take_frame.return_value = (0, buffer)
view._session = session
try:
texture_id = None
for colors in ((0xff0000, 0x00ff00, 0x0000ff, 0xffffff), (0xffffff, 0x0000ff, 0x00ff00, 0xff0000)):
row = b''.join(color.to_bytes(4, 'little') * 80 for color in colors)
buffer[:] = row * 240
view._prepare()
assert not session.fail.called
assert view._texture is not None
if texture_id is not None:
assert view._texture.id == texture_id
texture_id = view._texture.id
rl.begin_drawing()
rl.clear_background(rl.BLACK)
rl.begin_shader_mode(view._shader)
bounds = rl.Rectangle(0, 0, 320, 240)
rl.draw_texture_pro(view._texture, bounds, bounds, rl.Vector2(0, 0), 0, rl.WHITE)
rl.end_shader_mode()
image = rl.load_image_from_screen()
try:
for i, expected in enumerate(colors):
actual = rl.get_image_color(image, i * 80 + 40, 120)
assert (actual.r, actual.g, actual.b, actual.a) == (expected >> 16, expected >> 8 & 255, expected & 255, 255)
finally:
rl.unload_image(image)
rl.end_drawing()
assert session.release.call_count == 2
finally:
view.hide_event()
buffer.close()
os.close(fd)
rl.close_window()