Refactor radar system
This commit is contained in:
@@ -13,9 +13,12 @@ Pins the agreed contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -119,6 +122,28 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
|
||||
|
||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||
def test_empty_tables_and_images_retain_their_declared_dimensions(self) -> None:
|
||||
for shape in ((0, 3), (2, 0), (0, 0)):
|
||||
for kind in (3, 4):
|
||||
with self.subTest(shape=shape, kind=kind):
|
||||
payload = ResultPayload('empty', kind, table=np.empty(shape, dtype=np.float32),
|
||||
image=np.empty(shape, dtype=np.float32),
|
||||
image_x_axis=np.zeros(shape[1], dtype=np.float32),
|
||||
image_y_axis=np.zeros(shape[0], dtype=np.float32))
|
||||
if kind == 4 and shape == (2, 0):
|
||||
with self.assertRaises(ValueError):
|
||||
serialize_result_collection(ResultCollection(1, 2, collection_payloads=[payload]))
|
||||
continue
|
||||
result = decode_result_collection(serialize_result_collection(
|
||||
ResultCollection(1, 2, collection_payloads=[payload])))
|
||||
decoded = result.collection_payloads[0]
|
||||
self.assertEqual((decoded.image if kind == 3 else decoded.table).shape, shape)
|
||||
|
||||
def test_multidimensional_trace_cannot_be_serialized_as_a_flat_trace(self) -> None:
|
||||
payload = ResultPayload('bad', 1, frequency_hz=np.ones((1, 2)), trace=np.ones((1, 2)))
|
||||
with self.assertRaises(ValueError):
|
||||
serialize_result_collection(ResultCollection(1, 2, collection_payloads=[payload]))
|
||||
|
||||
def test_all_payload_kinds_round_trip(self) -> None:
|
||||
image = np.arange(6, dtype=np.float32).reshape((2, 3))
|
||||
table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32)
|
||||
@@ -240,6 +265,26 @@ class RingRoundTripTest(_RingTestCase):
|
||||
|
||||
|
||||
class RingOverflowTest(_RingTestCase):
|
||||
def test_reader_cannot_rewind_cursor_after_producer_laps_during_commit(self) -> None:
|
||||
from python_app.orchestration.shm import atomics
|
||||
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=4, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
writer.push(b"f0")
|
||||
advance = atomics.advance
|
||||
|
||||
def lap_before_reader_commit(buffer, offset, desired):
|
||||
if buffer is reader._mmap and desired == 1:
|
||||
for i in range(1, 6):
|
||||
writer.push(f"f{i}".encode())
|
||||
advance(buffer, offset, desired)
|
||||
|
||||
with mock.patch.object(atomics, "advance", side_effect=lap_before_reader_commit):
|
||||
self.assertEqual(reader.pop_payload(), b"f0")
|
||||
self.assertEqual(reader._read_u64(32), 2)
|
||||
self.assertEqual([reader.pop_payload() for _ in range(4)], [b"f2", b"f3", b"f4", b"f5"])
|
||||
|
||||
def test_latest_wins_drops_oldest(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=4, slot_size=64)
|
||||
@@ -254,6 +299,26 @@ class RingOverflowTest(_RingTestCase):
|
||||
|
||||
|
||||
class PeekLatestTest(_RingTestCase):
|
||||
def test_peek_rejects_a_slot_while_it_is_being_overwritten(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=1, slot_size=8)
|
||||
reader = self._reader(name)
|
||||
writer.push(b"aaaaaaaa")
|
||||
observations = []
|
||||
write_size = writer._write_u32
|
||||
|
||||
def overwrite_in_progress(offset: int, value: int) -> None:
|
||||
write_size(offset, value)
|
||||
if offset == 64:
|
||||
writer._mmap[80:84] = b"bbbb"
|
||||
observations.append(reader.peek_latest_payload())
|
||||
|
||||
with mock.patch.object(writer, "_write_u32", side_effect=overwrite_in_progress):
|
||||
writer.push(b"bbbbbbbb")
|
||||
|
||||
self.assertEqual(observations, [None])
|
||||
self.assertEqual(reader.peek_latest_payload(), b"bbbbbbbb")
|
||||
|
||||
def test_peek_is_latest_and_non_consuming(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
@@ -269,6 +334,85 @@ class PeekLatestTest(_RingTestCase):
|
||||
|
||||
|
||||
class RingOpenTest(_RingTestCase):
|
||||
def test_invalid_geometry_and_names_fail_before_opening_files(self) -> None:
|
||||
for capacity, slot in ((True, 8), (1.5, 8), (1, 9), (2 ** 32, 8)):
|
||||
with self.subTest(capacity=capacity, slot=slot), self.assertRaises(ValueError):
|
||||
ShmRingWriter(self._ring_name(), capacity, slot)
|
||||
for name in ("/", "/../tmp/ring", "/nested/ring", "/bad\x00ring"):
|
||||
with self.subTest(name=name):
|
||||
with self.assertRaises(ValueError):
|
||||
ShmRingWriter(name, 1, 8)
|
||||
with self.assertRaises(ValueError):
|
||||
ShmRingReader(name, open_timeout_s=0)
|
||||
|
||||
def test_failed_mapping_closes_opened_file(self) -> None:
|
||||
from python_app.orchestration.shm import ring_writer
|
||||
|
||||
name = self._ring_name()
|
||||
opened = []
|
||||
fdopen = ring_writer.os.fdopen
|
||||
|
||||
def track_fd(fd, *args, **kwargs):
|
||||
handle = fdopen(fd, *args, **kwargs)
|
||||
opened.append(handle)
|
||||
return handle
|
||||
|
||||
self.addCleanup(lambda: (Path('/dev/shm') / name[1:]).unlink(missing_ok=True))
|
||||
with mock.patch.object(ring_writer.os, 'fdopen', side_effect=track_fd), \
|
||||
mock.patch.object(ring_writer.mmap, 'mmap', side_effect=OSError('mapping failed')), \
|
||||
self.assertRaises(OSError):
|
||||
ShmRingWriter(name, 1, 8)
|
||||
self.assertTrue(opened[0].closed)
|
||||
|
||||
def test_owner_replaces_wrong_size_without_truncating_existing_mappings(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
writer.push(b"old frame")
|
||||
path = Path("/dev/shm") / name[1:]
|
||||
old_inode = path.stat().st_ino
|
||||
|
||||
replacement = ShmRingWriter(name, capacity=1, slot_size_bytes=8)
|
||||
self.addCleanup(self._safe_close, replacement)
|
||||
|
||||
self.assertNotEqual(path.stat().st_ino, old_inode)
|
||||
self.assertEqual(reader.pop_payload(), b"old frame")
|
||||
self.assertTrue(replacement.push(b"new"))
|
||||
self.assertEqual(self._reader(name).pop_payload(), b"new")
|
||||
|
||||
def test_waits_for_created_but_unallocated_ring(self) -> None:
|
||||
name = self._ring_name()
|
||||
path = Path("/dev/shm") / name[1:]
|
||||
path.touch()
|
||||
self.addCleanup(lambda: path.unlink(missing_ok=True))
|
||||
writers = []
|
||||
|
||||
def initialize() -> None:
|
||||
time.sleep(0.02)
|
||||
writers.append(ShmRingWriter(name, capacity=1, slot_size_bytes=8))
|
||||
|
||||
creator = threading.Thread(target=initialize)
|
||||
creator.start()
|
||||
|
||||
def cleanup_creator() -> None:
|
||||
creator.join()
|
||||
for writer in writers:
|
||||
writer.close()
|
||||
|
||||
self.addCleanup(cleanup_creator)
|
||||
reader = self._reader(name)
|
||||
creator.join()
|
||||
|
||||
self.assertEqual(reader.capacity, 1)
|
||||
self.assertEqual(reader.slot_size_bytes, 8)
|
||||
|
||||
def test_header_geometry_outside_mapping_is_rejected(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=1, slot_size=8)
|
||||
writer._write_u32(12, 100)
|
||||
with self.assertRaisesRegex(RuntimeError, "geometry mismatch"):
|
||||
ShmRingReader(name)
|
||||
|
||||
def test_missing_ring_fails_fast(self) -> None:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02)
|
||||
|
||||
Reference in New Issue
Block a user