mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-13 07:53:45 +08:00
3e9f070c70
Resolve curve speed and smoothing through a dedicated learning and preference owner. Co-authored-by: whoisdomi <dcantu83@gmail.com>
258 lines
11 KiB
Python
258 lines
11 KiB
Python
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from openpilot.common.params import Params
|
|
from openpilot.starpilot.curve_speed.actions import apply_learning, learning_snapshot
|
|
from openpilot.starpilot.curve_speed.learning import LearnedCurve
|
|
from openpilot.starpilot.curve_speed.preferences import (
|
|
DOCUMENT_KEY, LEGACY_KEY, MASTER_KEY, PreferenceHost, learning_lock_path,
|
|
)
|
|
|
|
|
|
class TestLearningActions(unittest.TestCase):
|
|
def setUp(self):
|
|
self.directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.directory.cleanup)
|
|
self.params = Params(self.directory.name)
|
|
self.legacy = {'0.01': {'average': 2.2, 'count': 100}}
|
|
self.params.put(LEGACY_KEY, self.legacy, block=True)
|
|
|
|
def raw(self, key):
|
|
path = Path(self.params.get_param_path(key))
|
|
return path.read_bytes() if path.exists() else None
|
|
|
|
def apply(self, kind='curve_reset', snapshot=None, authorized=lambda: True):
|
|
snapshot = snapshot or learning_snapshot(self.params)
|
|
return apply_learning(self.params, kind, snapshot.sources, authorized=authorized)
|
|
|
|
def owner(self):
|
|
owner = PreferenceHost(self.params)
|
|
self.addCleanup(owner.close)
|
|
return owner
|
|
|
|
def test_snapshot_uses_legacy_without_adoption_and_preserves_source(self):
|
|
before = self.raw(LEGACY_KEY)
|
|
snapshot = learning_snapshot(self.params)
|
|
self.assertFalse(learning_lock_path(self.params).exists())
|
|
self.assertTrue(snapshot.valid and snapshot.has_samples and snapshot.resettable)
|
|
loaded = LearnedCurve.load(self.legacy).curve
|
|
assert snapshot.progress is not None and snapshot.comfort is not None
|
|
self.assertAlmostEqual(snapshot.progress, loaded.progress)
|
|
self.assertAlmostEqual(snapshot.comfort, loaded.average_comfort)
|
|
self.assertFalse(self.apply('curve_adopt', snapshot).committed)
|
|
self.assertIsNone(self.raw(DOCUMENT_KEY))
|
|
self.assertEqual(self.raw(LEGACY_KEY), before)
|
|
self.assertIsNone(self.raw(MASTER_KEY))
|
|
|
|
def test_reset_recovers_invalid_canonical_without_reviving_legacy(self):
|
|
Path(self.params.get_param_path(DOCUMENT_KEY)).write_bytes(b'{')
|
|
snapshot = learning_snapshot(self.params)
|
|
self.assertFalse(snapshot.valid)
|
|
self.assertIsNone(snapshot.progress)
|
|
self.assertTrue(snapshot.resettable)
|
|
before = self.raw(LEGACY_KEY)
|
|
self.assertEqual(self.apply(snapshot=snapshot).reason, 'saved')
|
|
reset = learning_snapshot(self.params)
|
|
self.assertTrue(reset.valid)
|
|
self.assertFalse(reset.has_samples)
|
|
self.assertEqual(reset.progress, 0.0)
|
|
self.assertEqual(json.loads(self.raw(DOCUMENT_KEY)), LearnedCurve().document())
|
|
self.assertEqual(self.raw(LEGACY_KEY), before)
|
|
|
|
def test_enabled_malformed_master_unreadable_source_and_no_authority_cannot_edit(self):
|
|
for master in (b'', b'false'):
|
|
with self.subTest(master=master):
|
|
Path(self.params.get_param_path(MASTER_KEY)).write_bytes(master)
|
|
self.assertFalse(learning_snapshot(self.params).resettable)
|
|
self.assertFalse(self.apply().committed)
|
|
self.assertIsNone(self.raw(DOCUMENT_KEY))
|
|
self.params.put_bool(MASTER_KEY, True, block=True)
|
|
self.assertTrue(learning_snapshot(self.params).resettable)
|
|
self.assertFalse(self.apply(authorized=lambda: False).committed)
|
|
Path(self.params.get_param_path(DOCUMENT_KEY)).write_bytes(b'x' * 32769)
|
|
snapshot = learning_snapshot(self.params)
|
|
self.assertFalse(snapshot.readable or snapshot.resettable)
|
|
self.assertFalse(self.apply(snapshot=snapshot).committed)
|
|
self.assertEqual(len(self.raw(DOCUMENT_KEY)), 32769)
|
|
|
|
def test_stale_snapshot_cannot_adopt_reset_or_turn_master_on(self):
|
|
for key, replacement in ((LEGACY_KEY, b'{}'), (DOCUMENT_KEY, b'{'), (MASTER_KEY, b'1')):
|
|
with self.subTest(changed=key):
|
|
snapshot = learning_snapshot(self.params)
|
|
Path(self.params.get_param_path(key)).write_bytes(replacement)
|
|
for kind in ('curve_reset', 'curve_adopt'):
|
|
self.assertFalse(self.apply(kind, snapshot).committed)
|
|
self.assertEqual(self.raw(key), replacement)
|
|
self.params.remove(DOCUMENT_KEY)
|
|
self.params.remove(MASTER_KEY)
|
|
self.params.put(LEGACY_KEY, self.legacy, block=True)
|
|
|
|
def test_active_learning_session_and_second_owner_are_excluded(self):
|
|
snapshot = learning_snapshot(self.params)
|
|
owner = self.owner()
|
|
host = owner.make_host(replay=True)
|
|
self.assertTrue(host.runtime.document_valid)
|
|
self.assertFalse(learning_snapshot(self.params).resettable)
|
|
self.assertEqual(self.apply(snapshot=snapshot).reason, 'learning_active')
|
|
second = self.owner()
|
|
other = second.make_host(replay=True)
|
|
self.params.put_bool(MASTER_KEY, True, block=True)
|
|
second.refresh(other, 1_000_000_000)
|
|
self.assertFalse(other.runtime.enabled or other.runtime.document_valid)
|
|
other.runtime.curve.observe(0.01, 2.0)
|
|
second.persist(other, SimpleNamespace(dirty_revision=1), 1_000_000_000)
|
|
self.assertIsNone(second.worker)
|
|
second.close()
|
|
self.params.put_bool(MASTER_KEY, False, block=True)
|
|
owner.close()
|
|
self.assertEqual(self.apply().reason, 'saved')
|
|
|
|
def test_new_owner_captures_document_after_lease_not_constructor(self):
|
|
owner = self.owner()
|
|
self.assertEqual(self.apply().reason, 'saved')
|
|
host = owner.make_host(replay=True)
|
|
self.assertEqual(host.runtime.curve.progress, 0)
|
|
self.assertEqual(host.runtime.curve.document(), LearnedCurve().document())
|
|
|
|
def test_edit_and_authority_are_rechecked_after_staging_io(self):
|
|
fsync = os.fsync
|
|
for change in ('document', 'master', 'authority'):
|
|
with self.subTest(change=change):
|
|
self.params.remove(DOCUMENT_KEY)
|
|
self.params.remove(MASTER_KEY)
|
|
authority = [True]
|
|
called = False
|
|
|
|
def intervene(fd, changed=change, allowed=authority):
|
|
nonlocal called
|
|
if not called:
|
|
called = True
|
|
if changed == 'authority':
|
|
allowed[0] = False
|
|
else:
|
|
if changed == 'document':
|
|
self.params.put(DOCUMENT_KEY, LearnedCurve().document(), block=True)
|
|
else:
|
|
self.params.put_bool(MASTER_KEY, True, block=True)
|
|
fsync(fd)
|
|
|
|
with patch('openpilot.starpilot.curve_speed.actions.os.fsync', side_effect=intervene):
|
|
result = self.apply('curve_adopt', authorized=lambda allowed=authority: allowed[0])
|
|
self.assertFalse(result.committed)
|
|
self.assertEqual(self.raw(LEGACY_KEY), json.dumps(self.legacy).encode())
|
|
|
|
def test_busy_params_lock_does_not_block_or_change_data(self):
|
|
with open(Path(self.directory.name) / '.lock', 'a') as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
self.assertEqual(self.apply().reason, 'storage_busy')
|
|
self.assertIsNone(self.raw(DOCUMENT_KEY))
|
|
self.assertEqual(list(Path(self.directory.name).glob('.tmp_curve_action_*')), [])
|
|
|
|
def test_sync_failure_distinguishes_before_and_after_commit(self):
|
|
original_sync = os.fsync
|
|
for fail_at in (1, 2):
|
|
with self.subTest(fail_at=fail_at):
|
|
self.params.remove(DOCUMENT_KEY)
|
|
calls = 0
|
|
|
|
def sync(fd, failure_call=fail_at):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == failure_call:
|
|
raise OSError('storage unavailable')
|
|
original_sync(fd)
|
|
|
|
with patch('openpilot.starpilot.curve_speed.actions.os.fsync', side_effect=sync):
|
|
result = self.apply()
|
|
self.assertEqual(result.committed, fail_at == 2)
|
|
self.assertEqual(result.reason, 'sync_failed' if fail_at == 2 else 'write_failed')
|
|
self.assertEqual(self.raw(DOCUMENT_KEY) is not None, fail_at == 2)
|
|
self.assertEqual(list(Path(self.directory.name).glob('.tmp_curve_action_*')), [])
|
|
|
|
def test_close_cancelled_writer_cannot_overwrite_later_reset(self):
|
|
prepared, release = threading.Event(), threading.Event()
|
|
original_sync = os.fsync
|
|
|
|
def sync(fd):
|
|
if threading.current_thread().name == 'curve-learning':
|
|
prepared.set()
|
|
if not release.wait(2):
|
|
raise OSError('interrupted')
|
|
original_sync(fd)
|
|
|
|
owner = self.owner()
|
|
host = owner.make_host(replay=True)
|
|
host.runtime.curve.observe(0.01, 2.8)
|
|
with patch('openpilot.starpilot.curve_speed.preferences.os.fsync', side_effect=sync):
|
|
owner.persist(host, SimpleNamespace(dirty_revision=1), 1_000_000_000)
|
|
self.assertTrue(prepared.wait(1))
|
|
writer = owner.worker
|
|
try:
|
|
owner.close()
|
|
self.assertEqual(self.apply().reason, 'saved')
|
|
finally:
|
|
release.set()
|
|
writer.join(timeout=2)
|
|
self.assertFalse(writer.is_alive())
|
|
self.assertEqual(json.loads(self.raw(DOCUMENT_KEY)), LearnedCurve().document())
|
|
self.assertTrue(host.runtime.curve.dirty)
|
|
self.assertEqual(list(Path(self.directory.name).glob('.tmp_curve_*')), [])
|
|
|
|
def test_lease_contention_stays_disabled_until_a_new_session(self):
|
|
path = learning_lock_path(self.params)
|
|
with open(path, 'a') as editor:
|
|
fcntl.flock(editor.fileno(), fcntl.LOCK_EX)
|
|
owner = self.owner()
|
|
host = owner.make_host(replay=True)
|
|
self.params.put_bool(MASTER_KEY, True, block=True)
|
|
owner.refresh(host, 1_000_000_000)
|
|
self.assertFalse(host.runtime.enabled or host.runtime.document_valid)
|
|
self.assertEqual(host.runtime.document_reason, 'learning_active')
|
|
owner.close()
|
|
replacement = self.owner()
|
|
ready = replacement.make_host(replay=True)
|
|
replacement.refresh(ready, 2_000_000_000)
|
|
self.assertTrue(ready.runtime.enabled and ready.runtime.document_valid)
|
|
|
|
def test_closed_writer_directory_sync_keeps_editor_serialized(self):
|
|
syncing, release = threading.Event(), threading.Event()
|
|
original_sync = os.fsync
|
|
writer_calls = 0
|
|
|
|
def sync(fd):
|
|
nonlocal writer_calls
|
|
if threading.current_thread().name == 'curve-learning':
|
|
writer_calls += 1
|
|
if writer_calls == 2:
|
|
syncing.set()
|
|
if not release.wait(2):
|
|
raise OSError('interrupted')
|
|
original_sync(fd)
|
|
|
|
owner = self.owner()
|
|
host = owner.make_host(replay=True)
|
|
host.runtime.curve.observe(0.01, 2.8)
|
|
with patch('openpilot.starpilot.curve_speed.preferences.os.fsync', side_effect=sync):
|
|
owner.persist(host, SimpleNamespace(dirty_revision=1), 1_000_000_000)
|
|
self.assertTrue(syncing.wait(1))
|
|
writer = owner.worker
|
|
try:
|
|
owner.close()
|
|
committed = self.raw(DOCUMENT_KEY)
|
|
self.assertIsNotNone(committed)
|
|
self.assertEqual(self.apply().reason, 'storage_busy')
|
|
self.assertEqual(self.raw(DOCUMENT_KEY), committed)
|
|
finally:
|
|
release.set()
|
|
writer.join(timeout=2)
|
|
self.assertFalse(writer.is_alive())
|
|
self.assertEqual(self.apply().reason, 'saved')
|
|
self.assertEqual(json.loads(self.raw(DOCUMENT_KEY)), LearnedCurve().document())
|