Files
2026-10-19 00:47:35 -05:00

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