mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
648 lines
29 KiB
Python
648 lines
29 KiB
Python
"""Inert owners and one synthetic private engine; never run driving services."""
|
|
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
import json
|
|
import os
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from types import SimpleNamespace
|
|
from typing import NoReturn, cast
|
|
|
|
import pytest
|
|
|
|
from openpilot.starpilot.replay.client import ReplayUnavailable, encode, parse, request_operation
|
|
from openpilot.starpilot.replay.owner import LiveAuthority, ReplayOwner, Rejected, _group_active, serve
|
|
from openpilot.tools.replay.display_clock import RECORD
|
|
|
|
|
|
CLIENT = 'a' * 32
|
|
OTHER = 'b' * 32
|
|
ROUTE = '00000001--aaaaaaaaaa'
|
|
|
|
|
|
class Lease:
|
|
def __init__(self, catalog):
|
|
self.catalog = catalog
|
|
self.route = catalog.detail(ROUTE)
|
|
|
|
def current(self):
|
|
return self.catalog.current
|
|
|
|
def close(self):
|
|
self.catalog.released += 1
|
|
|
|
|
|
class Catalog:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.current = True
|
|
self.released = 0
|
|
|
|
def detail(self, route):
|
|
if route != ROUTE:
|
|
raise Rejected('invalid_route')
|
|
return {'routeId': route, 'playable': True, 'warnings': [],
|
|
'segments': [{'number': number, 'files': {'rlog': True, 'qlog': False, 'fcamera': True, 'qcamera': False}}
|
|
for number in (0, 2)]}
|
|
|
|
def catalog(self):
|
|
return {'schemaVersion': 1, 'routes': [self.detail(ROUTE)], 'scanIncomplete': False}
|
|
|
|
def lease(self, route):
|
|
self.detail(route)
|
|
return Lease(self)
|
|
|
|
|
|
FIXTURE = '''import json,sys
|
|
s={'event':'status','state':'paused','position':0.,'start':0.,'end':120.,'rate':1.,'paused':True,'epoch':1,
|
|
'frameReady':True,'mediaReady':False,'lastRequestId':None,'error':None}
|
|
print(json.dumps(s),flush=True)
|
|
for line in sys.stdin:
|
|
c=json.loads(line)
|
|
if c['op']=='pause': s.update(paused=c['paused'],state='paused' if c['paused'] else 'playing')
|
|
elif c['op']=='seek': s.update(position=c['position'],epoch=s['epoch']+1)
|
|
elif c['op']=='rate': s['rate']=c['rate']
|
|
elif c['op']=='stop': break
|
|
s['lastRequestId']=c['requestId']
|
|
print(json.dumps(s),flush=True)
|
|
'''
|
|
|
|
|
|
def wait_for(predicate):
|
|
deadline = time.monotonic() + 4
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(.01)
|
|
raise AssertionError('owner did not reach expected state')
|
|
|
|
|
|
@pytest.fixture
|
|
def owner(tmp_path):
|
|
catalog = Catalog(tmp_path / 'logs')
|
|
catalog.root.mkdir()
|
|
allowed = [True]
|
|
children = []
|
|
|
|
def spawn(command, **kwargs):
|
|
# Assert isolation before replacing the production worker with an inert fixture.
|
|
env = kwargs['env']
|
|
assert env['OPENPILOT_PREFIX'].startswith('replay-')
|
|
assert Path(env['PARAMS_ROOT']).is_relative_to(tmp_path)
|
|
assert env['SP_REPLAY_CONTEXT'] == '1'
|
|
assert 'SP_HOST_RUNTIME' not in env
|
|
clock = Path(env['SP_REPLAY_CLOCK_PATH'])
|
|
info = clock.stat()
|
|
assert stat.S_IMODE(info.st_mode) == 0o600 and info.st_size == RECORD.size
|
|
assert clock.read_bytes() == bytes(RECORD.size)
|
|
assert kwargs['start_new_session'] is True
|
|
child = subprocess.Popen([sys.executable, '-u', '-c', FIXTURE], **kwargs)
|
|
child.replay_command = command
|
|
children.append(child)
|
|
return child
|
|
|
|
value = ReplayOwner(catalog, root=tmp_path / 'replay', authority=lambda: allowed[0], spawn=spawn)
|
|
yield value, catalog, allowed, children
|
|
value.close()
|
|
|
|
|
|
def start(value, request_id='1' * 32):
|
|
return value.dispatch({'op': 'start', 'routeId': ROUTE, 'target': 'native', 'clientId': CLIENT,
|
|
'requestId': request_id, 'expectedOwnerId': value.owner_id})
|
|
|
|
|
|
def control(value, action, field, item, request_id='2' * 32):
|
|
state = value.dispatch({'op': 'status', 'clientId': CLIENT})
|
|
return {'op': 'control', 'sessionId': state['sessionId'], 'clientId': CLIENT, 'requestId': request_id,
|
|
'expectedRevision': state['revision'], 'action': action, field: item}
|
|
|
|
|
|
def test_catalog_and_detail_reuse_validated_dates_without_starting_analysis(owner, tmp_path, monkeypatch):
|
|
from openpilot.starpilot.galaxy.drive_stats import DriveStatsOwner
|
|
value, catalog, _, _ = owner
|
|
monkeypatch.setattr('openpilot.starpilot.galaxy.drive_stats.galaxy_storage_root', lambda: tmp_path)
|
|
def unexpected_analysis(_):
|
|
raise AssertionError('Listing dates must not start analysis')
|
|
monkeypatch.setattr(DriveStatsOwner, 'start', unexpected_analysis)
|
|
record = {'routeId': ROUTE, 'startTime': 1_792_008_330, 'complete': False, 'ignored': False,
|
|
'segmentCount': 2, 'sourceSignature': 'fixture'}
|
|
path = tmp_path / 'drive-stats.json'
|
|
def save():
|
|
path.write_text(json.dumps({'schemaVersion': 1, 'routes': {ROUTE: record}}))
|
|
save()
|
|
loads = []
|
|
load = DriveStatsOwner._load
|
|
def counted_load(reader):
|
|
loads.append(reader)
|
|
load(reader)
|
|
monkeypatch.setattr(DriveStatsOwner, '_load', counted_load)
|
|
original = catalog.catalog
|
|
monkeypatch.setattr(catalog, 'catalog', lambda: {**original(), 'routes': [catalog.detail(ROUTE),
|
|
{'routeId': '00000002--bbbbbbbbbb', 'fileTime': 1_792_008_360}]})
|
|
result = value.dispatch({'op': 'catalog'})
|
|
assert len(loads) == 1
|
|
assert result['routes'][0]['startTime'] == record['startTime']
|
|
assert result['routes'][1]['startTime'] is None
|
|
assert result['routes'][1]['fileTime'] == 1_792_008_360
|
|
record['startTime'] += 60
|
|
save()
|
|
assert value.dispatch({'op': 'detail', 'routeId': ROUTE})['startTime'] == record['startTime']
|
|
record['startTime'] = 'invalid'
|
|
save()
|
|
assert value.dispatch({'op': 'catalog'})['routes'][0]['startTime'] is None
|
|
path.write_text('{')
|
|
assert value.dispatch({'op': 'detail', 'routeId': ROUTE})['playable']
|
|
assert value.worker is None
|
|
|
|
|
|
@pytest.mark.parametrize('segment', [True, -1, 1000000, '2', 1])
|
|
def test_start_rejects_invalid_or_missing_segment_before_spawning(owner, segment):
|
|
value, _, _, children = owner
|
|
with pytest.raises(Rejected):
|
|
value.dispatch({'op': 'start', 'routeId': ROUTE, 'target': 'native', 'clientId': CLIENT,
|
|
'requestId': '1' * 32, 'expectedOwnerId': value.owner_id, 'segment': segment})
|
|
assert not children
|
|
|
|
|
|
def test_start_forwards_selected_segment_to_worker(owner):
|
|
value, _, _, children = owner
|
|
value.dispatch({'op': 'start', 'routeId': ROUTE, 'target': 'native', 'clientId': CLIENT,
|
|
'requestId': '1' * 32, 'expectedOwnerId': value.owner_id, 'segment': 2})
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
command = children[0].replay_command
|
|
assert command[command.index('--start-segment') + 1] == '2'
|
|
|
|
|
|
def test_explicit_actions_retries_and_no_takeover(owner):
|
|
value, catalog, _, children = owner
|
|
first = start(value)
|
|
assert first['owned'] and first['state'] == 'preparing'
|
|
assert start(value)['sessionId'] == first['sessionId']
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
assert len(children) == 1
|
|
frame_path = Path('/dev/shm') / ('msgq_replay-' + first['sessionId'][:24]) / 'frame'
|
|
assert value.dispatch({'op': 'status', 'clientId': CLIENT})['framePath'] == str(frame_path)
|
|
command = control(value, 'pause', 'paused', False)
|
|
accepted = value.dispatch(command)
|
|
wait_for(lambda: value.pending is None)
|
|
assert value.status['state'] == 'playing'
|
|
assert value.dispatch(command)['revision'] == accepted['revision']
|
|
observer = value.dispatch({'op': 'status', 'clientId': OTHER})
|
|
assert observer['owned'] is False and 'framePath' not in observer
|
|
with pytest.raises(Rejected, match='another viewer'):
|
|
value.dispatch(dict(command, clientId=OTHER, requestId='3' * 32))
|
|
with pytest.raises(Rejected, match='Another viewer'):
|
|
value.dispatch({'op': 'start', 'routeId': ROUTE, 'target': 'web', 'clientId': OTHER,
|
|
'requestId': '4' * 32, 'expectedOwnerId': value.owner_id})
|
|
value.dispatch({'op': 'stop', 'sessionId': first['sessionId'], 'clientId': CLIENT, 'requestId': '5' * 32})
|
|
wait_for(lambda: value.worker is None)
|
|
assert children[0].poll() is not None and catalog.released == 1
|
|
assert not list((value.root / 'sessions').iterdir())
|
|
assert not frame_path.parent.exists()
|
|
|
|
|
|
def test_live_revocation_kills_only_fixture_and_releases_sources(owner):
|
|
value, catalog, allowed, children = owner
|
|
start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
allowed[0] = False
|
|
wait_for(lambda: value.worker is None)
|
|
assert value.status['errorCode'] == 'not_offroad'
|
|
assert children[0].poll() is not None and catalog.released == 1
|
|
with pytest.raises(Rejected, match='Turn off'):
|
|
start(value, '6' * 32)
|
|
|
|
|
|
def test_renderer_exit_reaps_inert_descendant_before_releasing_sources(owner, tmp_path):
|
|
value, catalog, _, children = owner
|
|
pid_file = tmp_path / 'descendant.pid'
|
|
fixture = '\n'.join(('import subprocess,sys',
|
|
'args=[sys.executable,"-c","import time; time.sleep(120)"]',
|
|
'p=subprocess.Popen(args, stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)',
|
|
f'open({str(pid_file)!r},"w").write(str(p.pid))', 'sys.exit(1)'))
|
|
|
|
def spawn(command, **kwargs):
|
|
child = subprocess.Popen([sys.executable, '-c', fixture], **kwargs)
|
|
children.append(child)
|
|
return child
|
|
|
|
value.spawn = spawn
|
|
start(value)
|
|
wait_for(lambda: value.worker is None)
|
|
assert pid_file.exists()
|
|
assert value.status['errorCode'] == 'worker_failed'
|
|
assert not _group_active(children[0].pid)
|
|
assert catalog.released == 1
|
|
|
|
|
|
def test_restart_cleans_only_marked_dead_sessions(owner):
|
|
value, _, _, _ = owner
|
|
sid = 'c' * 32
|
|
directory = value.root / 'sessions' / sid
|
|
directory.mkdir(mode=0o700)
|
|
(directory / 'session.json').write_text(json.dumps({'version': 1, 'ownerPid': 2**30, 'ownerBirth': 1,
|
|
'workerPid': None, 'workerBirth': None}))
|
|
ipc = Path('/dev/shm') / ('msgq_replay-' + sid[:24])
|
|
ipc.mkdir(mode=0o700)
|
|
(ipc / 'fixture').write_bytes(b'inert')
|
|
unknown = value.root / 'sessions' / ('d' * 32)
|
|
unknown.mkdir(mode=0o700)
|
|
(unknown / 'keep').write_bytes(b'unknown')
|
|
try:
|
|
value.recover()
|
|
assert not directory.exists() and not ipc.exists()
|
|
assert (unknown / 'keep').read_bytes() == b'unknown'
|
|
# A live previous owner must never be scavenged even if its socket is missing.
|
|
marked = value.root / 'sessions' / ('e' * 32)
|
|
marked.mkdir(mode=0o700)
|
|
value._record_session(marked)
|
|
with pytest.raises(Rejected, match='still stopping'):
|
|
value.recover()
|
|
finally:
|
|
import shutil
|
|
if ipc.exists():
|
|
shutil.rmtree(ipc)
|
|
|
|
|
|
@pytest.mark.parametrize('flattened', [False, True])
|
|
@pytest.mark.parametrize('arguments,birth_matches,group_matches,owner_alive,error', [
|
|
(['-m', 'openpilot.starpilot.replay.render'], True, True, False, None),
|
|
(['-m', 'tools.diagnostics.replay_render'], True, True, False, None),
|
|
(['-m', 'other.openpilot.starpilot.replay.render'], True, True, False, 'stop_unconfirmed'),
|
|
(['-m', 'openpilot.starpilot.replay.render.extra'], True, True, False, 'stop_unconfirmed'),
|
|
(['--argument', 'openpilot.starpilot.replay.render'], True, True, False, 'stop_unconfirmed'),
|
|
(['-c', 'unrelated', '-m', 'openpilot.starpilot.replay.render'], True, True, False, 'stop_unconfirmed'),
|
|
(['-m', 'tools.diagnostics.replay_render'], False, True, False, 'stop_unconfirmed'),
|
|
(['-m', 'tools.diagnostics.replay_render'], True, False, False, 'stop_unconfirmed'),
|
|
(['-m', 'tools.diagnostics.replay_render'], True, True, True, 'busy'),
|
|
(["'malformed"], True, True, False, 'stop_unconfirmed'),
|
|
])
|
|
def test_recovery_requires_known_invocation_and_original_identity(
|
|
owner, monkeypatch, flattened, arguments, birth_matches, group_matches, owner_alive, error):
|
|
value, _, _, _ = owner
|
|
directory = value.root / 'sessions' / ('f' * 32)
|
|
directory.mkdir(mode=0o700)
|
|
old_owner, worker = 2**30, 2**30 + 1
|
|
(directory / 'session.json').write_text(json.dumps({'version': 1, 'ownerPid': old_owner, 'ownerBirth': 111,
|
|
'workerPid': worker, 'workerBirth': 222}))
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner._process_birth',
|
|
lambda pid: (111 if owner_alive else None) if pid == old_owner else (222 if birth_matches else 223))
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner._group_active', lambda _: False)
|
|
argv = ['/usr/local/venv/bin/python3', *arguments, '--session-dir', str(directory)]
|
|
# The device's process title was one space-separated field followed by NUL.
|
|
command = ((' ' if flattened else '\0').join(argv) + '\0').encode()
|
|
original_read = Path.read_bytes
|
|
monkeypatch.setattr(Path, 'read_bytes', lambda path: command if path == Path(f'/proc/{worker}/cmdline') else original_read(path))
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner.os.getpgid', lambda pid: worker if group_matches else worker + 1)
|
|
killed = []
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner.os.killpg', lambda pid, sig: killed.append((pid, sig)))
|
|
if error:
|
|
with pytest.raises(Rejected) as caught:
|
|
value.recover()
|
|
assert caught.value.code == error and not killed and directory.exists()
|
|
else:
|
|
value.recover()
|
|
assert killed == [(worker, 9)] and not directory.exists()
|
|
|
|
|
|
def test_real_authority_requires_fresh_parked_no_output_evidence(tmp_path, monkeypatch):
|
|
mono, boot = 1_000_000_000_000, 1_120_000_000_000
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner.time.monotonic_ns', lambda: mono)
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner.time.clock_gettime_ns', lambda _: boot)
|
|
params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key))
|
|
(tmp_path / 'IsOffroad').write_bytes(b'1')
|
|
(tmp_path / 'IsEngaged').write_bytes(b'0')
|
|
panda = SimpleNamespace(pandaType='dos', safetyModel='noOutput', controlsAllowed=False,
|
|
ignitionLine=False, ignitionCan=False)
|
|
|
|
class Snapshot(dict):
|
|
pass
|
|
|
|
sm = Snapshot(deviceState=SimpleNamespace(started=False), pandaStates=[panda])
|
|
sm.seen = dict.fromkeys(sm, True)
|
|
sm.alive = dict.fromkeys(sm, True)
|
|
sm.valid = dict.fromkeys(sm, True)
|
|
sm.logMonoTime = {'deviceState': mono - 100_000_000, 'pandaStates': boot - 100_000_000}
|
|
sm.recv_time = dict.fromkeys(sm, (mono - 10_000_000) / 1e9)
|
|
sm.device_offset_ns = boot - mono
|
|
sm.after_mono_ns = mono - 2_000_000_000
|
|
value = object.__new__(LiveAuthority)
|
|
value.params = params
|
|
value.evidence = SimpleNamespace(snapshot=lambda: sm)
|
|
assert value.allowed()
|
|
for field, bad in (('ignitionLine', True), ('ignitionCan', True), ('controlsAllowed', True),
|
|
('safetyModel', 'elm327'), ('pandaType', 'unknown')):
|
|
previous = getattr(panda, field)
|
|
setattr(panda, field, bad)
|
|
assert not value.allowed()
|
|
setattr(panda, field, previous)
|
|
sm['deviceState'].started = True
|
|
assert not value.allowed()
|
|
sm['deviceState'].started = False
|
|
sm.alive['pandaStates'] = False
|
|
assert not value.allowed()
|
|
sm.alive['pandaStates'] = True
|
|
sm.device_offset_ns = 0
|
|
assert not value.allowed() # Evidence from before a suspend cannot authorize.
|
|
sm.device_offset_ns = boot - mono
|
|
sm.after_mono_ns = sm.logMonoTime['deviceState']
|
|
assert not value.allowed()
|
|
sm.after_mono_ns = mono - 2_000_000_000
|
|
(tmp_path / 'IsEngaged').write_bytes(b'1')
|
|
assert not value.allowed()
|
|
(tmp_path / 'IsEngaged').write_bytes(b'0')
|
|
(tmp_path / 'DoShutdown').write_bytes(b'1')
|
|
assert not value.allowed()
|
|
monkeypatch.setenv('SP_HOST_RUNTIME', '1')
|
|
monkeypatch.setenv('SP_HOST_PARKED', '1')
|
|
value.evidence = SimpleNamespace(snapshot=lambda: None)
|
|
assert not value.allowed() # Desktop shortcuts are never authority for real replay.
|
|
|
|
|
|
def test_display_snapshot_never_exceeds_renderer_bounds(owner, tmp_path):
|
|
value, _, _, _ = owner
|
|
saved = tmp_path / 'saved'
|
|
saved.mkdir()
|
|
value.params = SimpleNamespace(get_param_path=lambda key: str(saved / key))
|
|
output = tmp_path / 'snapshot'
|
|
output.mkdir()
|
|
(saved / 'OnroadCustomizations').write_bytes(b'x' * (48 * 1024))
|
|
(saved / 'DeveloperSidebar').write_bytes(b'0')
|
|
value._display_snapshot(output)
|
|
path = output / 'display.json'
|
|
import base64
|
|
assert base64.b64decode(json.loads(path.read_bytes())['values']['DeveloperSidebar']) == b'0'
|
|
assert len(path.read_bytes()) <= 128 * 1024
|
|
assert all(len(raw) <= 64 * 1024 for raw in json.loads(path.read_bytes())['values'].values())
|
|
path.unlink()
|
|
(saved / 'IsMetric').write_bytes(b'x' * (48 * 1024))
|
|
with pytest.raises(Rejected, match='settings unavailable'):
|
|
value._display_snapshot(output)
|
|
assert not path.exists() # Invalid context never reaches renderer startup.
|
|
|
|
|
|
def test_disconnect_source_change_and_restart_identity(owner):
|
|
value, catalog, _, _ = owner
|
|
with pytest.raises(Rejected, match='restarted'):
|
|
value.dispatch({'op': 'start', 'routeId': ROUTE, 'target': 'native', 'clientId': CLIENT,
|
|
'requestId': '1' * 32, 'expectedOwnerId': '0' * 32})
|
|
start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
value.lease_at -= 16
|
|
wait_for(lambda: value.worker is None)
|
|
assert value.status['errorCode'] == 'viewer_disconnected'
|
|
start(value, '6' * 32)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
catalog.current = False
|
|
wait_for(lambda: value.worker is None)
|
|
assert value.status['errorCode'] == 'route_changed'
|
|
|
|
|
|
def test_strict_revision_numbers_and_request_identity(owner):
|
|
value, _, _, _ = owner
|
|
start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
for action, key, bad in (('seek', 'position', float('nan')), ('seek', 'position', True), ('rate', 'rate', 8), ('pause', 'paused', 1)):
|
|
with pytest.raises(Rejected, match='invalid request'):
|
|
value.dispatch(control(value, action, key, bad))
|
|
command = control(value, 'seek', 'position', 30)
|
|
accepted = value.dispatch(command)
|
|
wait_for(lambda: value.pending is None)
|
|
assert value.status['position'] == 30 and value.status['epoch'] == 2
|
|
with pytest.raises(Rejected, match='request changed'):
|
|
value.dispatch(dict(command, position=60))
|
|
with pytest.raises(Rejected, match='Replay changed'):
|
|
value.dispatch(dict(control(value, 'pause', 'paused', False, '7' * 32), expectedRevision=accepted['revision'] - 1))
|
|
|
|
|
|
def test_accepted_seek_cannot_restore_media_from_unacknowledged_worker_status(owner, tmp_path):
|
|
value, _, _, _ = owner
|
|
value.client_id = CLIENT
|
|
value.status.update(sessionId='c' * 32, target='web', state='playing', revision=1, epoch=1, end=120.,
|
|
frameReady=True, mediaReady=True)
|
|
published = {**value.status, 'event': 'status', 'error': None, 'lastRequestId': None}
|
|
command = control(value, 'seek', 'position', 30.)
|
|
accepted = value.dispatch(command)
|
|
assert accepted['state'] == 'seeking' and not accepted['mediaReady']
|
|
for status in (published, {**published, 'epoch': 2, 'state': 'buffering', 'frameReady': False, 'mediaReady': False}):
|
|
value._observe(status, tmp_path)
|
|
assert value.status['state'] == 'seeking'
|
|
assert not value.status['frameReady'] and not value.status['mediaReady']
|
|
assert value.pending == command['requestId']
|
|
with pytest.raises(Rejected, match='epoch changed'):
|
|
value.dispatch({'op': 'media', 'sessionId': accepted['sessionId'], 'clientId': CLIENT,
|
|
'epoch': value.status['epoch'], 'sequence': 0})
|
|
value._observe({**published, 'epoch': 2, 'position': 30., 'lastRequestId': command['requestId']}, tmp_path)
|
|
assert value.status['state'] == 'playing' and value.status['mediaReady'] and value.status['frameReady']
|
|
assert value.pending is None
|
|
|
|
|
|
@pytest.mark.parametrize('ranges', [None, [[0]], [[0, True]], [[0, float('nan')]], [[20, 10]],
|
|
[[0, 101]], [[0, 20], [10, 30]]])
|
|
def test_worker_seek_ranges_reject_malformed_or_overlapping_bounds(owner, tmp_path, ranges):
|
|
value, _, _, _ = owner
|
|
status = {**value._idle(), 'event': 'status', 'state': 'paused', 'end': 100., 'seekRanges': ranges}
|
|
with pytest.raises(Rejected, match='worker failed'):
|
|
value._observe(status, tmp_path)
|
|
|
|
|
|
def test_gap_seek_is_rejected_without_losing_owned_viewer(owner):
|
|
value, _, _, children = owner
|
|
start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
value.status['seekRanges'] = [[0., 40.], [80., 120.]]
|
|
epoch, revision = value.status['epoch'], value.status['revision']
|
|
with pytest.raises(Rejected, match='no recorded camera'):
|
|
value.dispatch(control(value, 'seek', 'position', 60))
|
|
state = value.dispatch({'op': 'status', 'clientId': CLIENT})
|
|
assert state['owned'] and state['state'] == 'paused'
|
|
assert (state['epoch'], state['revision']) == (epoch, revision)
|
|
assert state['frameReady'] and children[0].poll() is None
|
|
|
|
|
|
def test_real_machine_uses_owner_created_clock_and_cleans_it(tmp_path, monkeypatch):
|
|
from openpilot.common.basedir import BASEDIR
|
|
from openpilot.starpilot.galaxy.route_inventory import RouteInventory
|
|
from openpilot.tools.replay.display_clock import DisplayClockReader
|
|
from openpilot.tools.replay.tests.test_machine import segment
|
|
|
|
binary = Path(BASEDIR) / 'openpilot/tools/replay/replay'
|
|
if not binary.is_file():
|
|
cast(Callable[[str], NoReturn], pytest.skip)('Build the isolated replay engine first')
|
|
routes = tmp_path / 'routes'
|
|
routes.mkdir()
|
|
segment(routes, 0, [.1, .2, 10], compressed=True)
|
|
(routes / '00000001--1234567890--0').rename(routes / (ROUTE + '--0'))
|
|
original = {path.name: path.read_bytes() for path in (routes / (ROUTE + '--0')).iterdir()}
|
|
unrelated = tmp_path / 'unrelated-clock'
|
|
unrelated.write_bytes(b'untouched-clock')
|
|
monkeypatch.setenv('SP_REPLAY_CLOCK_PATH', str(unrelated))
|
|
children, clocks, readers = [], [], []
|
|
|
|
def spawn(command, **kwargs):
|
|
env = dict(kwargs['env'])
|
|
clock = Path(env['SP_REPLAY_CLOCK_PATH'])
|
|
info = clock.stat()
|
|
assert stat.S_ISREG(info.st_mode) and stat.S_IMODE(info.st_mode) == 0o600
|
|
assert info.st_uid == os.getuid() and info.st_nlink == 1 and info.st_size == RECORD.size
|
|
assert clock.read_bytes() == bytes(RECORD.size)
|
|
assert clock != unrelated
|
|
reader = DisplayClockReader(env)
|
|
assert not reader.sample().valid
|
|
readers.append(reader)
|
|
clocks.append(clock)
|
|
# Replace only the GPU compositor with the real machine engine; production
|
|
# owner creates every namespace prerequisite before this callback runs.
|
|
env['SP_REPLAY_PARENT_PID'] = str(os.getpid())
|
|
kwargs['env'] = env
|
|
child = subprocess.Popen([str(binary), '--machine', '--no-vipc', '--data_dir', str(routes), ROUTE], **kwargs)
|
|
children.append(child)
|
|
return child
|
|
|
|
value = ReplayOwner(RouteInventory(routes), root=tmp_path / 'replay', authority=lambda: True, spawn=spawn)
|
|
try:
|
|
started = start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
reader = readers[0]
|
|
initial = reader.sample()
|
|
assert initial.valid and initial.paused and initial.epoch == value.status['epoch']
|
|
assert initial.now_ns == 37_100_000_000 and initial.boot_ns == 40_100_000_000
|
|
value.dispatch(control(value, 'seek', 'position', .2))
|
|
wait_for(lambda: value.pending is None and value.status['epoch'] > initial.epoch)
|
|
sought = reader.sample()
|
|
assert sought.valid and sought.paused and sought.epoch > initial.epoch
|
|
assert sought.now_ns == 37_200_000_000
|
|
value.dispatch({'op': 'stop', 'sessionId': started['sessionId'], 'clientId': CLIENT, 'requestId': '5' * 32})
|
|
wait_for(lambda: value.worker is None)
|
|
assert children[0].poll() == 0
|
|
assert not reader.sample().valid
|
|
assert not clocks[0].parent.exists()
|
|
assert not list((value.root / 'sessions').iterdir())
|
|
restarted = start(value, '6' * 32)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
assert restarted['sessionId'] != started['sessionId'] and clocks[1] != clocks[0]
|
|
assert readers[1].sample().valid and not reader.sample().valid
|
|
value.dispatch({'op': 'stop', 'sessionId': restarted['sessionId'], 'clientId': CLIENT, 'requestId': '7' * 32})
|
|
wait_for(lambda: value.worker is None)
|
|
assert children[1].poll() == 0 and not clocks[1].parent.exists()
|
|
assert unrelated.read_bytes() == b'untouched-clock'
|
|
assert {path.name: path.read_bytes() for path in (routes / (ROUTE + '--0')).iterdir()} == original
|
|
finally:
|
|
value.close()
|
|
for reader in readers:
|
|
reader.close()
|
|
|
|
|
|
def test_protocol_bounds_and_duplicate_owner_keeps_live_socket(owner):
|
|
value, _, _, _ = owner
|
|
for raw in (b'{"op":"status","op":"stop"}', b'{"value":NaN}', b'[]'):
|
|
with pytest.raises(ValueError):
|
|
parse(raw, 4096)
|
|
with pytest.raises(ValueError):
|
|
encode({'value': 'x' * 5000}, 4096)
|
|
stop = threading.Event()
|
|
thread = threading.Thread(target=serve, args=(value,), kwargs={'stop': stop}, daemon=True)
|
|
thread.start()
|
|
path = value.root / 'owner.sock'
|
|
try:
|
|
wait_for(path.exists)
|
|
assert request_operation({'op': 'status'}, socket_path=path)['result']['ownerId'] == value.owner_id
|
|
with pytest.raises(BlockingIOError):
|
|
serve(value, stop=threading.Event())
|
|
assert path.exists()
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer:
|
|
peer.connect(str(path))
|
|
peer.sendall(b'\x7f\xff\xff\xff')
|
|
assert peer.recv(4096)
|
|
assert request_operation({'op': 'catalog'}, socket_path=path)['ok'] is True
|
|
finally:
|
|
stop.set()
|
|
thread.join(timeout=2)
|
|
assert not thread.is_alive() and not path.exists()
|
|
with pytest.raises(ReplayUnavailable):
|
|
request_operation({'op': 'status'}, socket_path=path)
|
|
|
|
|
|
def test_storage_symlink_is_never_repurposed(tmp_path):
|
|
outside = tmp_path / 'outside'
|
|
outside.mkdir()
|
|
linked = tmp_path / 'linked'
|
|
linked.symlink_to(outside, target_is_directory=True)
|
|
with pytest.raises(Rejected, match='storage'):
|
|
ReplayOwner(Catalog(tmp_path), root=linked)
|
|
assert not list(outside.iterdir())
|
|
|
|
|
|
def test_media_batch_keeps_owner_and_authority_checks(owner):
|
|
from openpilot.starpilot.replay.media import MediaStore, web_media_dir
|
|
value, _, allowed, _ = owner
|
|
state = start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
directory = web_media_dir(state['sessionId'])
|
|
directory.mkdir(mode=0o700)
|
|
media = MediaStore(directory, state['sessionId'], 1280, 640)
|
|
media.begin(1, 'avc1.42c01f', b'init')
|
|
media.publish(b'first', position=0., keyframe=True)
|
|
media.publish(b'second', position=.05, keyframe=False)
|
|
value.status.update(target='web', mediaReady=True)
|
|
request = {'op': 'media', 'sessionId': state['sessionId'], 'clientId': CLIENT, 'epoch': 1, 'sequence': 1, 'count': 8}
|
|
assert [row['sequence'] for row in value.dispatch(request)['fragments']] == [1, 2]
|
|
assert not (value.session_dir / 'media.json').exists()
|
|
for invalid in (0, 9, True):
|
|
with pytest.raises(Rejected, match='invalid request'):
|
|
value.dispatch({**request, 'count': invalid})
|
|
with pytest.raises(Rejected):
|
|
value.dispatch({**request, 'clientId': OTHER})
|
|
with pytest.raises(Rejected):
|
|
value.dispatch({**request, 'epoch': 2})
|
|
allowed[0] = False
|
|
with pytest.raises(Rejected, match='not offroad'):
|
|
value.dispatch(request)
|
|
wait_for(lambda: value.worker is None)
|
|
assert not directory.parent.exists()
|
|
|
|
|
|
def test_complete_segment_mapping_fits_worker_protocol(owner, tmp_path):
|
|
from openpilot.starpilot.replay.client import MAX_WORKER_STATUS
|
|
value, _, _, _ = owner
|
|
value.status['target'] = 'web'
|
|
ranges = [[i * 60.123456789, (i + 1) * 60.123456789] for i in range(512)]
|
|
status = {**value._idle(), 'event': 'status', 'state': 'paused', 'end': ranges[-1][1],
|
|
'seekRanges': ranges, 'segmentRanges': [[i * 2, *row] for i, row in enumerate(ranges)]}
|
|
encoded = encode(status, MAX_WORKER_STATUS)
|
|
assert len(encoded) > 16384
|
|
value._observe(parse(encoded[4:], MAX_WORKER_STATUS), tmp_path)
|
|
assert value.status['segmentRanges'][-1][0] == 1022
|
|
with pytest.raises(Rejected):
|
|
value._observe({**status, 'segmentRanges': [[0, 0, 1]]}, tmp_path)
|
|
|
|
|
|
def test_timeline_pages_are_owned_and_optional_failures_keep_controls(owner):
|
|
value, _, _, _ = owner
|
|
start(value)
|
|
wait_for(lambda: value.status['state'] == 'paused')
|
|
path = value.session_dir / 'cache/timeline.jsonl'
|
|
path.write_text(json.dumps({'schemaVersion': 1, 'rowOffset': 0, 'totalRows': 1, 'source': 'qlog',
|
|
'rows': [[0, 1, 'chill']]}) + '\n')
|
|
path.chmod(0o600)
|
|
value._observe({**value.status, 'event': 'status', 'timelineState': 'ready',
|
|
'seekRanges': [[0, 120]], 'segmentRanges': [[0, 0, 120]], 'error': None}, value.session_dir)
|
|
request = {'op': 'timeline', 'sessionId': value.status['sessionId'], 'clientId': CLIENT, 'cursor': 0}
|
|
page = value.dispatch(request)
|
|
assert page['rows'] == [[0, 1, 'chill']] and page['nextCursor'] is None
|
|
assert not any('Path' in key for key in page)
|
|
with pytest.raises(Rejected):
|
|
value.dispatch({**request, 'clientId': OTHER})
|
|
path.write_text('broken\n')
|
|
with pytest.raises(Rejected, match='timeline unavailable'):
|
|
value.dispatch(request)
|
|
assert value.status['timelineState'] == 'unavailable'
|
|
value.dispatch(control(value, 'pause', 'paused', False))
|
|
wait_for(lambda: value.status['state'] == 'playing')
|
|
assert value.status['errorCode'] is None
|