mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 09:03:46 +08:00
238 lines
12 KiB
Python
238 lines
12 KiB
Python
"""Exports keep recorded coverage, remain private, and become ready only after cleanup."""
|
|
from types import SimpleNamespace
|
|
from typing import NoReturn
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openpilot.starpilot.galaxy.recording_media import _ffmpeg_binary
|
|
from openpilot.starpilot.replay.export import MAX_EXPORT_BYTES, ExportSource, ExportVideo, disk_budget, export_ranges
|
|
from openpilot.starpilot.replay.owner import ReplayOwner, Rejected
|
|
from openpilot.starpilot.replay.tests.test_owner import CLIENT, OTHER, ROUTE, Catalog, wait_for
|
|
|
|
|
|
@pytest.fixture
|
|
def packet(tmp_path, monkeypatch):
|
|
monkeypatch.setattr('openpilot.starpilot.replay.export.shutil.disk_usage', lambda directory: SimpleNamespace(total=100 * 1024 ** 3, free=90 * 1024 ** 3))
|
|
binary = _ffmpeg_binary()
|
|
output = tmp_path / 'frame.ts'
|
|
subprocess.run([str(binary), '-nostdin', '-hide_banner', '-loglevel', 'error', '-f', 'rawvideo', '-pixel_format', 'yuv420p',
|
|
'-video_size', '32x18', '-framerate', '20', '-i', 'pipe:0', '-frames:v', '1', '-c:v', 'libx264',
|
|
'-preset', 'ultrafast', '-tune', 'zerolatency', '-x264-params', 'aud=1:repeat-headers=1:annexb=1',
|
|
'-f', 'mpegts', str(output)], input=bytes([16]) * (32 * 18) + bytes([128]) * (32 * 18 // 2), check=True)
|
|
probe = subprocess.run([str(binary.with_name('ffprobe')), '-v', 'error', '-show_packets', '-show_data', '-select_streams', 'v:0',
|
|
'-of', 'json', str(output)], check=True, capture_output=True)
|
|
data = json.loads(probe.stdout)['packets'][0]['data']
|
|
return bytes.fromhex(''.join(re.findall(r'^[0-9a-f]+: ([0-9a-f ]+?) ', data, re.MULTILINE)))
|
|
|
|
|
|
def test_recorded_ranges_use_actual_segment_ids_and_reject_overlap():
|
|
status = {'start': 0, 'end': 93, 'segmentRanges': [[2, 0, .15], [7, 92.75, 93]]}
|
|
assert export_ranges(status, 7) == [(92.75, 93)]
|
|
assert export_ranges(status, None) == [(0, .15), (92.75, 93)]
|
|
assert export_ranges({**status, 'segmentRanges': [[2, 0, 1], [7, 1, 2]]}, None) == [(0, 2)]
|
|
for rows in ([[2, 0, 1], [7, .5, 2]], [[2, 0, 1], [2, 2, 3]], [[7, 0, float('nan')]], []):
|
|
with pytest.raises(ValueError):
|
|
export_ranges({**status, 'segmentRanges': rows}, 7)
|
|
with pytest.raises(ValueError):
|
|
export_ranges(status, 1)
|
|
|
|
|
|
@pytest.mark.parametrize(('written', 'timeout'), [(0, 30), (8 * 1024 ** 3, 522), (MAX_EXPORT_BYTES, 2058)])
|
|
def test_export_mp4_preserves_coverage_and_scales_finalization_time(tmp_path, packet, written, timeout):
|
|
writer = ExportVideo(tmp_path, 32, 18, [(0, .15), (92.75, 93)], MAX_EXPORT_BYTES)
|
|
commands = []
|
|
engine = SimpleNamespace(command=lambda command: commands.append(command))
|
|
try:
|
|
for position in (0, .05, .1):
|
|
writer.frame(packet, position=position, keyframe=True)
|
|
assert not writer.advance(engine, {'position': .15, 'paused': False}, False)
|
|
assert commands[-1]['op'] == 'pause' and commands[-1]['paused']
|
|
assert not writer.advance(engine, {'position': .15, 'paused': True}, True), 'drain readback before advancing'
|
|
assert not writer.advance(engine, {'position': .15, 'paused': True}, False)
|
|
assert commands[-1]['op'] == 'seek' and commands[-1]['position'] == 92.75
|
|
for position in (92.75, 92.8, 92.85, 92.9, 92.95):
|
|
writer.frame(packet, position=position, keyframe=True)
|
|
assert writer.advance(engine, {'position': 93, 'paused': True}, False)
|
|
# Exercise large-export deadlines without writing multi-gigabyte fixtures.
|
|
writer.written = max(writer.written, written)
|
|
with patch('openpilot.starpilot.replay.export.subprocess.run', wraps=subprocess.run) as run:
|
|
writer.finalize()
|
|
assert run.call_args_list[0].kwargs['timeout'] == timeout
|
|
finally:
|
|
writer.close()
|
|
assert not (tmp_path / 'export.part').exists()
|
|
probe = subprocess.run([str(_ffmpeg_binary().with_name('ffprobe')), '-v', 'error', '-show_packets', '-show_format',
|
|
'-of', 'json', str(tmp_path / 'export.mp4')], check=True, capture_output=True)
|
|
info = json.loads(probe.stdout)
|
|
assert float(info['format']['duration']) == pytest.approx(.4)
|
|
assert [float(row['pts_time']) for row in info['packets']] == pytest.approx([i * .05 for i in range(8)])
|
|
|
|
|
|
def test_partial_video_and_storage_shortage_cannot_be_finalized(tmp_path, packet, monkeypatch):
|
|
writer = ExportVideo(tmp_path, 32, 18, [(0, 2)], 1024 ** 2)
|
|
try:
|
|
with pytest.raises(ValueError, match='beginning'):
|
|
writer.frame(packet, position=1, keyframe=True)
|
|
writer.frame(packet, position=0, keyframe=True)
|
|
with pytest.raises(ValueError, match='lost video'):
|
|
writer.frame(packet, position=.8, keyframe=True)
|
|
with pytest.raises(ValueError, match='end'):
|
|
writer.advance(SimpleNamespace(command=lambda command: None), {'position': 2, 'paused': True}, False)
|
|
monkeypatch.setattr('openpilot.starpilot.replay.export.shutil.disk_usage', lambda directory: SimpleNamespace(total=100 * 1024 ** 3, free=10 * 1024 ** 3))
|
|
assert disk_budget(tmp_path) == 0
|
|
writer.disk_check_at = 0
|
|
with pytest.raises(OSError, match='free recording storage'):
|
|
writer.write(b'x')
|
|
finally:
|
|
writer.close()
|
|
assert not (tmp_path / 'export.mp4').exists()
|
|
|
|
|
|
def _reject_engine_resume(_command) -> NoReturn:
|
|
__tracebackhide__ = True
|
|
raise pytest.fail.Exception('an ended engine must not resume')
|
|
|
|
|
|
def test_export_finishes_at_actual_engine_eof(tmp_path, packet):
|
|
from openpilot.tools.replay.tests.test_machine import BINARY, Machine
|
|
if not BINARY.is_file():
|
|
raise pytest.skip.Exception('build the replay test binary first')
|
|
with Machine([(0, [.1, .2, .3], True)]) as engine:
|
|
initial = engine.wait(lambda status: status['state'] == 'paused')
|
|
engine.command_status('pause', 'a4', paused=False)
|
|
ended = engine.wait(lambda status: status['state'] == 'ended')
|
|
assert ended['position'] < ended['end']
|
|
stopped = SimpleNamespace(command=_reject_engine_resume)
|
|
writer = ExportVideo(tmp_path, 32, 18, [(initial['start'], initial['end'])], 1024 ** 2)
|
|
try:
|
|
for position in (.1, .2, .3):
|
|
writer.frame(packet, position=position, keyframe=True)
|
|
assert not writer.advance(stopped, ended, True), 'the last readback must drain before finalization'
|
|
assert writer.advance(stopped, ended, False)
|
|
writer.finalize()
|
|
finally:
|
|
writer.close()
|
|
assert (tmp_path / 'export.mp4').is_file()
|
|
|
|
|
|
def test_early_engine_eof_cannot_hide_missing_export_coverage(tmp_path, packet):
|
|
writer = ExportVideo(tmp_path, 32, 18, [(0, 2)], 1024 ** 2)
|
|
engine = SimpleNamespace(command=_reject_engine_resume)
|
|
try:
|
|
writer.frame(packet, position=0, keyframe=True)
|
|
with pytest.raises(ValueError, match='end of the selected coverage'):
|
|
writer.advance(engine, {'state': 'ended', 'position': .1, 'end': 2, 'paused': True}, False)
|
|
finally:
|
|
writer.close()
|
|
assert not (tmp_path / 'export.mp4').exists()
|
|
|
|
|
|
def export_request(owner, **extra):
|
|
return {'op': 'export-start', 'routeId': ROUTE, 'clientId': CLIENT, 'requestId': 'd' * 32,
|
|
'expectedOwnerId': owner.owner_id, **extra}
|
|
|
|
|
|
@pytest.fixture
|
|
def exporting_owner(tmp_path, monkeypatch):
|
|
monkeypatch.setattr('openpilot.starpilot.replay.owner.disk_budget', lambda directory: 1024 ** 3)
|
|
catalog = Catalog(tmp_path / 'logs')
|
|
catalog.root.mkdir()
|
|
gate = tmp_path / 'finish'
|
|
allowed = [True]
|
|
children = []
|
|
script = '''import json,os,pathlib,sys,time
|
|
folder=pathlib.Path(sys.argv[1]); gate=pathlib.Path(sys.argv[2])
|
|
s={'event':'status','state':'paused','position':0.,'start':0.,'end':1.,'rate':1.,'paused':True,'epoch':1,
|
|
'frameReady':True,'mediaReady':False,'lastRequestId':None,'error':None,'exportState':'rendering','exportProgress':.1}
|
|
print(json.dumps(s),flush=True)
|
|
while not gate.exists(): time.sleep(.01)
|
|
p=folder/'export.mp4'; p.write_bytes(b'finished-video'); p.chmod(0o600)
|
|
s.update(exportState='ready',exportProgress=1.,error='recording_gap')
|
|
print(json.dumps(s),flush=True)
|
|
'''
|
|
def spawn(command, **kwargs):
|
|
directory = command[command.index('--session-dir') + 1]
|
|
assert '--export-budget' in command
|
|
children.append(subprocess.Popen([sys.executable, '-u', '-c', script, directory, str(gate)], **kwargs))
|
|
return children[-1]
|
|
owner = ReplayOwner(catalog, root=tmp_path / 'replay', authority=lambda: allowed[0], spawn=spawn)
|
|
yield owner, catalog, gate, allowed
|
|
owner.close()
|
|
assert all(child.poll() is not None for child in children)
|
|
|
|
|
|
def test_export_survives_viewer_lease_and_publishes_only_after_cleanup(exporting_owner):
|
|
owner, catalog, gate, _ = exporting_owner
|
|
snapshot = b'{"version":1,"values":{}}'
|
|
owner.view_display = CLIENT, ROUTE, snapshot
|
|
started = owner.dispatch(export_request(owner, segment=2))
|
|
sid = started['sessionId']
|
|
wait_for(lambda: owner.status['state'] == 'paused')
|
|
owner.lease_at = -1000
|
|
assert (owner.root / 'sessions' / sid / 'display.json').read_bytes() == snapshot
|
|
with pytest.raises(Rejected, match='unavailable'):
|
|
owner.dispatch({'op': 'export-file', 'exportId': sid, 'clientId': CLIENT})
|
|
time.sleep(.2)
|
|
assert owner.worker is not None, 'exports do not use the preview lease'
|
|
gate.touch()
|
|
wait_for(lambda: owner.worker is None)
|
|
result = owner.dispatch({'op': 'status', 'clientId': CLIENT})['export']
|
|
assert result['state'] == 'ready' and result['segment'] == 2 and result['owned']
|
|
assert catalog.released == 1 and not (owner.root / 'sessions' / sid).exists()
|
|
assert not owner.dispatch({'op': 'status', 'clientId': OTHER})['export']['owned']
|
|
with pytest.raises(Rejected):
|
|
owner.dispatch({'op': 'export-file', 'exportId': sid, 'clientId': OTHER})
|
|
receipt = owner.dispatch({'op': 'export-file', 'exportId': sid, 'clientId': CLIENT})
|
|
source = ExportSource(owner.root, sid, receipt['bytes'])
|
|
try:
|
|
assert os.read(source.fd, 100) == b'finished-video'
|
|
with pytest.raises(BlockingIOError):
|
|
fd = os.open(owner.root / 'exports' / sid / 'replay.mp4', os.O_RDONLY)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
finally:
|
|
os.close(fd)
|
|
with pytest.raises(Rejected, match='downloading'):
|
|
owner.dispatch({'op': 'export-clear', 'routeId': ROUTE})
|
|
assert not owner._expire_export(replace=True)
|
|
owner.export['expires'] = time.monotonic() - 1
|
|
assert owner.dispatch({'op': 'status', 'clientId': CLIENT})['export']['expiresInSeconds'] == 0
|
|
finally:
|
|
source.close()
|
|
owner.worker = object()
|
|
owner.status['purpose'] = 'view'
|
|
try:
|
|
assert 'export' not in owner.dispatch({'op': 'status', 'clientId': CLIENT}), 'an unrelated preview cannot extend the export cache lifetime'
|
|
finally:
|
|
owner.worker = None
|
|
owner.dispatch({'op': 'export-clear', 'routeId': ROUTE})
|
|
assert owner.export is None and not list((owner.root / 'exports').iterdir())
|
|
|
|
|
|
def test_cancelled_export_never_becomes_downloadable(exporting_owner):
|
|
owner, catalog, _, _ = exporting_owner
|
|
started = owner.dispatch(export_request(owner))
|
|
wait_for(lambda: owner.status['state'] == 'paused')
|
|
owner.dispatch({'op': 'stop', 'sessionId': started['sessionId'], 'clientId': CLIENT, 'requestId': 'e' * 32})
|
|
wait_for(lambda: owner.worker is None)
|
|
assert owner.export['state'] == 'cancelled'
|
|
assert catalog.released == 1 and not list((owner.root / 'exports').iterdir())
|
|
|
|
|
|
def test_lost_authority_fails_export_and_releases_sources(exporting_owner):
|
|
owner, catalog, _, allowed = exporting_owner
|
|
owner.dispatch(export_request(owner))
|
|
wait_for(lambda: owner.status['state'] == 'paused')
|
|
allowed[0] = False
|
|
wait_for(lambda: owner.worker is None)
|
|
assert owner.export['state'] == 'failed' and owner.export['error'] == 'not_offroad'
|
|
assert catalog.released == 1
|