Files
StarPilot/openpilot/starpilot/ui/tests/test_spacepilot_session.py
T
firestar5683 099cbd2809 Keep SpacePilot awake and release game mode on verified key-off
Renew the existing activity timer only during active offroad game ticks.
Observe only the exact game-owned Offroad request and restore Auto after
continuous fresh all-Panda ignition-off, closed game, stopped driving
services and healthy noOutput through the existing owner transaction.
Ordinary exit, stale data, reconnect and external mode changes never
restore Auto. UI restart does not adopt a previous game request.

142 source-bound focused cases, original timeout regression, lint/type
checks and independent source/evidence review. Synthetic host lifecycle
qualification only; no device changes or new physical/headroom claim.
2026-10-26 09:17:19 -05:00

533 lines
20 KiB
Python

"""Exclusive game admission, real drive-state requests, and unsafe lifecycle exits."""
from types import SimpleNamespace as NS
from unittest.mock import Mock
import pytest
from openpilot.common.params import Params
from openpilot.starpilot.drive_state.owner import DriveStateOwner
from openpilot.starpilot.ui.spacepilot import session
from openpilot.starpilot.ui.spacepilot.guard import DRIVING_PROCESSES, read_offroad_witness
from openpilot.starpilot.ui.tests.test_spacepilot_state import parked_ui
class Clock:
now = 10_000_000_000
offset = 2_000_000_000
def refresh(self, ui, step=100_000_000):
self.now += step
for name in ui.sm:
ui.sm.valid[name] = ui.sm.alive[name] = True
ui.sm.recv_frame[name] = 10
ui.sm.recv_time[name] = self.now / 1e9
ui.sm.logMonoTime[name] = self.now + (self.offset if name == 'pandaStates' else 0)
class Vehicle:
def __init__(self, cp):
self.cp = cp
self.fresh = True
self.closed = False
self.sample = NS(source_boot_ns=1)
self.samples = ()
def poll(self):
result, self.samples = self.samples, ()
return result
def ready(self, *_):
return self.fresh and not self.closed
def close(self):
self.closed = True
def game_ui(*, ignition=True):
ui = parked_ui()
ui.params = NS(get_bool=lambda _: not ui.started)
ui.sm['deviceState'].thermalStatus = 'ok'
ui.sm['managerState'] = NS(processes=[NS(name=name, running=True) for name in DRIVING_PROCESSES])
ui.sm['carParams'] = ui.CP
ui.sm['managerState'].processes.append(NS(name='pandad', running=True, pid=42))
ui.sm['pandaStates'][0].uptime = 100
ui.sm['pandaStates'][0].safetyModel = 'hyundai'
ui.sm['pandaStates'][0].ignitionLine = ignition
return ui
def offroad(ui):
ui.started = ui.sm['deviceState'].started = False
for process in ui.sm['managerState'].processes:
if process.name in DRIVING_PROCESSES:
process.running = False
ui.sm['pandaStates'][0].safetyModel = 'noOutput'
@pytest.fixture
def setup(monkeypatch, tmp_path):
clock = Clock()
ui = game_ui()
clock.refresh(ui, 0)
params = Params(str(tmp_path / 'params'))
owner = DriveStateOwner(params, tmp_path / 'drive-state')
owner.initialize_manager()
monkeypatch.setattr(session.time, 'monotonic_ns', lambda: clock.now)
monkeypatch.setattr(session.time, 'clock_gettime_ns', lambda _: clock.now + clock.offset)
monkeypatch.setattr(session.GameSession, '_clocks', lambda _: (clock.now, clock.now + clock.offset))
game = session.GameSession(ui, owner, native_device=True, reader_factory=Vehicle)
game.started_ns = clock.now
yield game, ui, owner, clock
game.close()
def enter(game, ui, clock, *, request=True):
for _ in range(12):
game.step()
clock.refresh(ui)
if request:
assert game.stage == session.Stage.STOPPING
assert game.requested_offroad
offroad(ui)
for _ in range(12):
game.step()
clock.refresh(ui)
assert game.stage == session.Stage.PLAYING, game.reason
def test_actual_owner_request_waits_for_processes_nooutput_then_stays_offroad(setup):
game, ui, owner, clock = setup
initial = owner.snapshot()
for _ in range(12):
assert not game.step()
clock.refresh(ui)
assert owner.snapshot().mode == 'offroad' and owner.snapshot().revision != initial.revision
assert game.stage == session.Stage.STOPPING and not game.playable()
ui.started = ui.sm['deviceState'].started = False
for _ in range(12):
game.step()
clock.refresh(ui)
assert not game.playable()
offroad(ui)
for _ in range(12):
game.step()
clock.refresh(ui)
assert game.playable()
vehicle = game.vehicle
state = owner.snapshot()
game.close()
assert vehicle.closed and game.vehicle is None
assert owner.snapshot() == state and state.mode == 'offroad'
def test_offroad_ignition_off_touch_never_creates_vehicle_reader_or_changes_mode(setup):
game, ui, owner, clock = setup
ui.sm['pandaStates'][0].ignitionLine = False
offroad(ui)
initial = owner.snapshot()
enter(game, ui, clock, request=False)
assert game.vehicle is None and game.playable()
assert owner.snapshot() == initial
assert not game.wheel.armed
def test_offroad_with_ignition_requires_independent_fresh_park(setup):
game, ui, owner, clock = setup
offroad(ui)
game.step()
game.vehicle.fresh = False
for _ in range(20):
clock.refresh(ui)
game.step()
assert not game.playable() and game.stage == session.Stage.VERIFYING
game.vehicle.fresh = True
enter(game, ui, clock, request=False)
assert game.playable()
assert owner.snapshot().mode == 'auto'
@pytest.mark.parametrize('change', ['source', 'receiver', 'future', 'invalid', 'dead'])
@pytest.mark.parametrize('service', ['deviceState', 'pandaStates', 'managerState'])
def test_active_game_exits_for_every_lost_transport(setup, service, change):
game, ui, _, clock = setup
enter(game, ui, clock)
if change == 'source':
ui.sm.logMonoTime[service] -= 2_000_000_000
elif change == 'receiver':
ui.sm.recv_time[service] -= 2
elif change == 'future':
ui.sm.logMonoTime[service] += 2_000_000_000
else:
getattr(ui.sm, 'valid' if change == 'invalid' else 'alive')[service] = False
assert not game.playable()
game.step()
assert game.stage == session.Stage.CLOSED and game.vehicle is None
@pytest.mark.parametrize('change', ['ignition', 'controls', 'safety', 'fault', 'harness', 'process', 'missing_process',
'duplicate_process', 'started', 'raw_stale', 'thermal', 'drive_mode'])
def test_unsafe_exit_and_no_automatic_restore(setup, change):
game, ui, owner, clock = setup
enter(game, ui, clock)
panda = ui.sm['pandaStates'][0]
if change == 'ignition':
panda.ignitionLine = False
elif change == 'controls':
panda.controlsAllowed = True
elif change == 'safety':
panda.safetyModel = 'hyundai'
elif change == 'fault':
panda.heartbeatLost = True
elif change == 'harness':
panda.harnessStatus = 'notConnected'
elif change == 'process':
ui.sm['managerState'].processes[0].running = True
elif change == 'missing_process':
ui.sm['managerState'].processes.pop(0) # Remove a required driving process, not the added pandad fixture.
elif change == 'duplicate_process':
ui.sm['managerState'].processes.append(ui.sm['managerState'].processes[0])
elif change == 'started':
ui.started = ui.sm['deviceState'].started = True
elif change == 'thermal':
ui.sm['deviceState'].thermalStatus = 'overheated'
elif change == 'raw_stale':
game.vehicle.fresh = False
else:
owner.request('auto', expected_revision=owner.snapshot().revision, authorized=lambda: True, override_allowed=lambda: True)
expected = owner.snapshot()
assert not game.playable()
game.step()
assert game.stage == session.Stage.CLOSED
assert owner.snapshot() == expected
def test_wheel_requires_post_admission_release_and_does_not_repeat(setup):
game, ui, _, clock = setup
enter(game, ui, clock)
def edge(pressed):
clock.refresh(ui, 140_000_000)
game.vehicle.samples = (NS(source_boot_ns=clock.now + clock.offset, buttons=(('accelCruise', pressed),)),)
return game.step()
assert not edge(True)
assert not edge(False)
assert edge(True)
assert not edge(True)
assert not edge(False)
assert edge(True)
def test_current_vehicle_identity_required_and_forced_flag_alone_never_admits(setup):
game, ui, owner, clock = setup
initial = owner.snapshot()
ui.sm.recv_frame['carParams'] = ui.started_frame
game.step()
assert game.stage == session.Stage.CLOSED and owner.snapshot() == initial
offroad(ui)
assert read_offroad_witness(ui, clock.now, clock.now + clock.offset, native_device=True) is None
assert read_offroad_witness(ui, clock.now, clock.now + clock.offset, native_device=True, preparing=True) is not None
def test_pending_transition_timeout_and_constructor_error_cleanup(setup):
game, ui, owner, clock = setup
game._reader_factory = Mock(side_effect=ValueError('Unsupported decoder'))
game.step()
assert game.stage == session.Stage.CLOSED and owner.snapshot().mode == 'auto'
timed = session.GameSession(ui, owner, native_device=True, reader_factory=Vehicle)
timed.started_ns = clock.now - session.TRANSITION_NS - 1
timed.step()
assert timed.stage == session.Stage.CLOSED and timed.vehicle is None
def test_device_resume_invalidates_session_clock(monkeypatch):
game = session.GameSession(NS(), NS(), native_device=True)
mono, boot = [10_000_000_000], [12_000_000_000]
monkeypatch.setattr(session.time, 'monotonic_ns', lambda: mono[0])
monkeypatch.setattr(session.time, 'clock_gettime_ns', lambda _: boot[0])
assert game._clocks() == (mono[0], boot[0])
boot[0] += 6_000_000
with pytest.raises(ValueError, match='resumed'):
game._clocks()
def test_offroad_witness_accepts_actual_schema_and_refuses_actual_thermal_faults(setup):
from openpilot.cereal import messaging
game, ui, _, clock = setup
offroad(ui)
ui.sm['pandaStates'][0].ignitionLine = False
for name in ('deviceState', 'pandaStates', 'managerState'):
event = messaging.new_message(name, 1 if name == 'pandaStates' else None, valid=True)
if name == 'pandaStates':
event.pandaStates[0] = vars(ui.sm[name][0])
elif name == 'managerState':
event.managerState.processes = [vars(p) for p in ui.sm[name].processes]
else:
event.deviceState = vars(ui.sm[name])
ui.sm[name] = getattr(event.as_reader(), name)
assert read_offroad_witness(ui, clock.now, clock.now + clock.offset, native_device=True) is not None
for thermal in ('overheated', 'critical'):
message = ui.sm['deviceState'].as_builder()
message.thermalStatus = thermal
ui.sm['deviceState'] = message.as_reader()
assert read_offroad_witness(ui, clock.now, clock.now + clock.offset, native_device=True) is None
@pytest.fixture
def caller(setup, monkeypatch):
"""Actual UI before-frame/native page/session/owner with synthetic vehicle feeds."""
import pyray as rl
from openpilot.selfdrive.ui import ui as caller_ui
from openpilot.starpilot.ui.spacepilot import native
from openpilot.starpilot.ui.tests.test_spacepilot_state import mature_gate, read
from openpilot.system.ui.lib.application import gui_app
game, ui, owner, clock = setup
monkeypatch.setattr(native, 'ui_state', ui)
monkeypatch.setattr(caller_ui, 'ui_state', ui)
monkeypatch.setattr(native, 'GameSession', lambda *_a, **_k: game)
monkeypatch.setattr(native, 'live_witness', lambda: read(ui, clock.now))
monkeypatch.setattr(native, '_ignition_return', None)
monkeypatch.setattr(native, 'device', Mock())
root = Mock()
active = [root]
monkeypatch.setattr(gui_app, 'get_active_widget', lambda: active[0])
page = native.SpacePilotPage(mature_gate(), lambda: active.__setitem__(0, root))
page.set_rect(rl.Rectangle(0, 0, 1072, 480))
active[0] = page
page.show_event()
ui.update = Mock()
def frame(*, advance=100_000_000, refresh=True, render=True):
if refresh:
clock.refresh(ui, advance)
else:
clock.now += advance
caller_ui.update_frame(game_mode=native.poll_game_mode)
if render and not page.closed:
page._tick()
for _ in range(12):
frame()
assert game.stage == session.Stage.STOPPING and native._ignition_return is not None
offroad(ui)
for _ in range(12):
frame()
assert game.stage == session.Stage.PLAYING
yield NS(game=game, ui=ui, owner=owner, clock=clock, native=native, page=page, frame=frame, active=active, root=root)
page.hide_event()
@pytest.mark.parametrize('exit_first', [False, True])
def test_confirmed_key_off_releases_only_game_request_even_with_screen_asleep(caller, exit_first):
from openpilot.starpilot.drive_state.resolver import should_start
c = caller
original = c.owner.snapshot()
if exit_first:
c.page._exit(unsafe=True)
assert c.owner.snapshot() == original
assert c.native._ignition_return is not None
c.ui.sm['pandaStates'][0].ignitionLine = False
c.frame()
assert c.page.closed and c.game.vehicle is None
assert c.owner.snapshot() == original # One off sample is never sufficient.
for _ in range(9):
c.frame(render=False)
assert c.owner.snapshot() == original
c.frame(render=False)
assert c.owner.snapshot().mode == 'auto'
assert c.native._ignition_return is None
assert not should_start(c.owner.snapshot().mode, {'ignition': False}, {'ready': True}, already_started=False)
assert should_start(c.owner.snapshot().mode, {'ignition': True}, {'ready': True}, already_started=False)
@pytest.mark.parametrize('change', ['empty', 'stale_source', 'stale_receiver', 'invalid', 'dead', 'future', 'reordered',
'fault', 'harness', 'panda_restart', 'pandad_restart', 'missing_process', 'topology',
'clock_resume', 'ui_stall', 'external_revision', 'manager_restart', 'no_output', 'driving_process'])
def test_key_off_unknown_or_discontinuous_evidence_never_releases(caller, change):
c = caller
p = c.ui.sm['pandaStates'][0]
p.ignitionLine = False
if change == 'empty':
c.ui.sm['pandaStates'] = []
elif change in ('stale_source', 'stale_receiver', 'future', 'reordered'):
if change == 'stale_receiver':
c.ui.sm.recv_time['pandaStates'] -= 1.
else:
c.ui.sm.logMonoTime['pandaStates'] += 1_000_000_000 if change == 'future' else -1_000_000_000 if change == 'stale_source' else -1
elif change in ('invalid', 'dead'):
getattr(c.ui.sm, 'valid' if change == 'invalid' else 'alive')['pandaStates'] = False
elif change == 'fault':
p.heartbeatLost = True
elif change == 'harness':
p.harnessStatus = 'notConnected'
elif change == 'panda_restart':
p.uptime = 0
elif change == 'pandad_restart':
next(p for p in c.ui.sm['managerState'].processes if p.name == 'pandad').pid += 1
elif change == 'missing_process':
c.ui.sm['managerState'].processes.pop()
elif change == 'topology':
c.ui.sm['pandaStates'].append(NS(**vars(p)))
elif change == 'clock_resume':
c.clock.offset += 6_000_000
elif change == 'external_revision':
c.owner.request('auto', expected_revision=c.owner.snapshot().revision, authorized=lambda: True, override_allowed=lambda: True)
c.owner.request('offroad', expected_revision=c.owner.snapshot().revision, authorized=lambda: True, override_allowed=lambda: True)
elif change == 'manager_restart':
c.owner.initialize_manager()
elif change == 'no_output':
p.safetyModel = 'hyundai'
elif change == 'driving_process':
c.ui.sm['managerState'].processes[0].running = True
expected = c.owner.snapshot()
c.frame(refresh=False, advance=400_000_000 if change == 'ui_stall' else 0)
assert c.native._ignition_return is None
for _ in range(15):
c.frame(render=False) # Reconnecting/refreshing cannot revive discarded authority.
assert c.owner.snapshot() == expected
def test_transient_off_and_multiple_pandas_require_sustained_all_off(setup, monkeypatch):
from openpilot.starpilot.ui.spacepilot.ignition import IgnitionReturn
game, ui, owner, clock = setup
ui.sm['pandaStates'].append(NS(**vars(ui.sm['pandaStates'][0])))
for _ in range(12):
game.step()
if game.ignition_return is not None:
game.ignition_return.observe()
clock.refresh(ui)
pending = game.ignition_return
assert isinstance(pending, IgnitionReturn) and not pending.finished
offroad(ui)
for p in ui.sm['pandaStates']:
p.safetyModel = 'noOutput'
ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(15):
clock.refresh(ui)
assert not pending.observe()
assert pending.off_since is None # Other Panda is still on.
ui.sm['pandaStates'][1].ignitionLine = False
for _ in range(5):
clock.refresh(ui)
assert not pending.observe()
ui.sm['pandaStates'][0].ignitionCan = True
clock.refresh(ui)
assert not pending.observe() and pending.off_since is None
ui.sm['pandaStates'][0].ignitionCan = False
for _ in range(10):
clock.refresh(ui)
assert not pending.observe()
clock.refresh(ui)
assert pending.observe()
initial = owner.snapshot()
pending.release(lambda: False)
assert owner.snapshot() == initial # An active game cannot authorize Auto.
def test_owner_rechecks_ignition_during_auto_transaction(caller, monkeypatch):
c = caller
c.ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(10):
c.frame()
original = c.owner.snapshot()
request = c.owner.request
def changed_before_write(mode, **kwargs):
assert c.page.closed
c.ui.sm['pandaStates'][0].ignitionLine = True
return request(mode, **kwargs)
monkeypatch.setattr(c.owner, 'request', changed_before_write)
c.frame(render=False)
assert c.owner.snapshot() == original
assert c.native._ignition_return is None
def test_ui_restart_does_not_adopt_existing_offroad_request(caller, monkeypatch):
c = caller
original = c.owner.snapshot()
c.page.hide_event()
monkeypatch.setattr(c.native, '_ignition_return', None) # New UI process has no game ownership.
c.ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(15):
c.frame(render=False)
assert c.owner.snapshot() == original
@pytest.mark.parametrize('service', ['pandaStates', 'deviceState', 'managerState'])
@pytest.mark.parametrize('change', ['source', 'receipt', 'invalid', 'dead', 'future'])
def test_key_off_requires_all_offroad_source_and_receipt_evidence(caller, service, change):
c = caller
original = c.owner.snapshot()
c.ui.sm['pandaStates'][0].ignitionLine = False
if change == 'source':
c.ui.sm.logMonoTime[service] -= 2_000_000_000
elif change == 'receipt':
c.ui.sm.recv_time[service] -= 2
elif change == 'future':
c.ui.sm.logMonoTime[service] += 2_000_000_000
else:
getattr(c.ui.sm, 'valid' if change == 'invalid' else 'alive')[service] = False
c.frame(refresh=False, advance=0)
assert c.native._ignition_return is None
for _ in range(12):
c.frame(render=False)
assert c.owner.snapshot() == original
def test_busy_owner_refuses_auto_once_without_retry(caller):
import fcntl
c = caller
original = c.owner.snapshot()
c.ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(10):
c.frame()
with (c.owner.root / '.lock').open('r+') as lease:
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
c.frame(render=False)
assert c.native._ignition_return is None
for _ in range(12):
c.frame(render=False)
assert c.owner.snapshot() == original
@pytest.mark.parametrize('rendering', [True, False])
def test_covered_game_is_closed_before_auto_in_actual_nav_caller(caller, monkeypatch, rendering):
from openpilot.system.ui.lib.application import gui_app
c = caller
monkeypatch.setattr(gui_app, '_nav_stack', [c.root, c.page])
monkeypatch.setattr(gui_app, 'get_active_widget', type(gui_app).get_active_widget.__get__(gui_app))
c.page.on_unsafe = lambda: gui_app.pop_widgets_to(c.root, instant=True)
modal = Mock()
gui_app.push_widget(modal)
assert gui_app.get_active_widget() is modal
assert not c.page.closed and c.game.vehicle is not None
reader = c.game.vehicle
# Reach confirmed key-off before the next nav tick (also happens when asleep).
c.ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(10):
c.frame(render=False)
assert c.owner.snapshot().mode == 'offroad' and not c.page.closed
request = c.owner.request
calls = []
def checked_request(mode, **kwargs):
calls.append((mode, c.page.closed, c.game.stage, c.game.vehicle is None, reader.closed,
c.page._tick not in gui_app._nav_stack_ticks, not c.page.flight.gates))
return request(mode, **kwargs)
monkeypatch.setattr(c.owner, 'request', checked_request)
monkeypatch.setattr(gui_app, '_should_render', rendering)
c.frame(render=gui_app._should_render)
assert calls == [('auto', True, session.Stage.CLOSED, True, True, True, True)]
assert c.owner.snapshot().mode == 'auto'
def test_failed_covered_page_cleanup_cannot_authorize_auto(caller, monkeypatch):
c = caller
c.ui.sm['pandaStates'][0].ignitionLine = False
for _ in range(10):
c.frame(render=False)
reader = c.game.vehicle
with monkeypatch.context() as patch:
patch.setattr(reader, 'close', Mock(side_effect=OSError('fixture cleanup failure')))
c.frame(render=False)
assert c.owner.snapshot().mode == 'offroad'
assert c.native._ignition_return is None
assert c.page in c.native._open_games # Incomplete cleanup remains a refusal.
c.page.hide_event()
assert c.page not in c.native._open_games