191 lines
7.6 KiB
Python
191 lines
7.6 KiB
Python
"""POSIX shared-memory ring reader implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import mmap
|
|
import time
|
|
|
|
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
|
from python_app.orchestration.shm import atomics
|
|
from python_app.orchestration.shm.decoder import (
|
|
PREPROC_MAGIC,
|
|
RAW_MAGIC,
|
|
decode_result_collection,
|
|
decode_trace_collection,
|
|
)
|
|
from python_app.orchestration.shm.ring_layout import HEADER, MAGIC, SLOT_HEADER_SIZE, VERSION, ring_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShmRingReader:
|
|
"""Read binary payloads from a lock-free ring in `/dev/shm`."""
|
|
|
|
def __init__(self, ring_name: str, open_timeout_s: float = 2.0, open_poll_s: float = 0.01) -> None:
|
|
"""Open and validate ring by name, for example `/radar_results`."""
|
|
self.name = ring_name
|
|
self._path = ring_path(ring_name)
|
|
self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s)
|
|
|
|
with self._path.open("r+b", buffering=0) as file:
|
|
self._mmap = mmap.mmap(file.fileno(), 0)
|
|
try:
|
|
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
|
except BaseException:
|
|
# A fail-fast open (absent/incompatible ring) must not leak the fd/mapping.
|
|
self.close()
|
|
raise
|
|
logger.debug(
|
|
"Opened SHM ring reader %s (capacity=%d, slot_size=%d bytes)",
|
|
self.name,
|
|
self.capacity,
|
|
self.slot_size_bytes,
|
|
)
|
|
|
|
def close(self) -> None:
|
|
"""Close the mapping and its owned file descriptor; safe to repeat."""
|
|
self._mmap.close()
|
|
logger.debug("Closed SHM ring reader %s", self.name)
|
|
|
|
def pop_payload(self) -> bytes | None:
|
|
"""Read next payload from ring, or `None` if no unread payload exists."""
|
|
write_seq = self._read_u64(24)
|
|
read_seq = self._read_u64(32)
|
|
if read_seq >= write_seq:
|
|
return None
|
|
|
|
index = read_seq % self.capacity
|
|
slot_offset = HEADER.size + index * (SLOT_HEADER_SIZE + self.slot_size_bytes)
|
|
|
|
# Seqlock read mirroring the C++ pop: a slot is valid for read_seq R only if
|
|
# its sequence equals R+1 and is unchanged across the payload copy (i.e. the
|
|
# producer did not overwrite this slot mid-copy). Sequence and payload_size
|
|
# are read first; the slot is only accepted after the re-read confirms both.
|
|
sequence = self._read_u64(slot_offset + 8)
|
|
if sequence != read_seq + 1:
|
|
# Producer overwrote this slot before we read it. Resync to latest.
|
|
logger.debug("Ring %s: slot lapped before read, resyncing to write_seq=%d", self.name, write_seq)
|
|
atomics.advance(self._mmap, 32, write_seq)
|
|
return None
|
|
|
|
payload_size = self._read_u32(slot_offset)
|
|
# Bound payload_size against the slot before slicing so a torn/garbage size
|
|
# can never read out of the slot region; resync and skip on violation.
|
|
if payload_size > self.slot_size_bytes:
|
|
logger.debug(
|
|
"Ring %s: payload_size %d exceeds slot %d, resyncing",
|
|
self.name,
|
|
payload_size,
|
|
self.slot_size_bytes,
|
|
)
|
|
atomics.advance(self._mmap, 32, write_seq)
|
|
return None
|
|
|
|
payload_offset = slot_offset + SLOT_HEADER_SIZE
|
|
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
|
|
atomics.fence()
|
|
|
|
# Re-read the slot sequence after the copy; if it changed, the producer
|
|
# overwrote this slot mid-copy and the payload is torn — discard and resync.
|
|
if self._read_u64(slot_offset + 8) != read_seq + 1:
|
|
logger.debug("Ring %s: slot overwritten mid-copy, discarding torn payload", self.name)
|
|
atomics.advance(self._mmap, 32, write_seq)
|
|
return None
|
|
|
|
atomics.advance(self._mmap, 32, read_seq + 1)
|
|
return payload
|
|
|
|
def pop_raw_collection(self) -> SweepCollection | None:
|
|
"""Read next raw collection from ring."""
|
|
payload = self.pop_payload()
|
|
if payload is None:
|
|
return None
|
|
return decode_trace_collection(payload, RAW_MAGIC)
|
|
|
|
def pop_preprocessed_collection(self) -> SweepCollection | None:
|
|
"""Read next preprocessed collection from ring."""
|
|
payload = self.pop_payload()
|
|
if payload is None:
|
|
return None
|
|
return decode_trace_collection(payload, PREPROC_MAGIC)
|
|
|
|
def pop_result_collection(self) -> ResultCollection | None:
|
|
"""Read next processed result collection from ring."""
|
|
payload = self.pop_payload()
|
|
if payload is None:
|
|
return None
|
|
return decode_result_collection(payload)
|
|
|
|
def drop_all(self) -> int:
|
|
"""Mark all unread slots as consumed and return number of dropped payloads."""
|
|
write_seq = self._read_u64(24)
|
|
read_seq = self._read_u64(32)
|
|
if read_seq >= write_seq:
|
|
return 0
|
|
dropped = int(write_seq - read_seq)
|
|
atomics.advance(self._mmap, 32, write_seq)
|
|
logger.debug("Ring %s: dropped %d unread payload(s)", self.name, dropped)
|
|
return dropped
|
|
|
|
@property
|
|
def capacity(self) -> int:
|
|
"""Number of slots in ring."""
|
|
return self._read_u32(12)
|
|
|
|
@property
|
|
def slot_size_bytes(self) -> int:
|
|
"""Maximum payload size per slot."""
|
|
return self._read_u32(16)
|
|
|
|
def _validate_header_with_wait(self, timeout_s: float, poll_s: float) -> None:
|
|
"""Wait for ring header to contain expected magic and version."""
|
|
deadline = time.monotonic() + timeout_s
|
|
while True:
|
|
magic = self._mmap[:8]
|
|
version = self._read_u32(8)
|
|
if magic == MAGIC and version == VERSION:
|
|
capacity = self.capacity
|
|
slot_size = self.slot_size_bytes
|
|
expected_size = HEADER.size + capacity * (SLOT_HEADER_SIZE + slot_size)
|
|
if capacity > 0 and slot_size > 0 and slot_size % 8 == 0 and expected_size == len(self._mmap):
|
|
return
|
|
|
|
if time.monotonic() >= deadline:
|
|
if magic != MAGIC:
|
|
got_magic = bytes(magic).hex()
|
|
expected_magic = MAGIC.hex()
|
|
raise RuntimeError(
|
|
f"Shared memory ring magic mismatch for {self.name}: "
|
|
f"got=0x{got_magic}, expected=0x{expected_magic}"
|
|
)
|
|
if version != VERSION:
|
|
raise RuntimeError(
|
|
f"Shared memory ring version mismatch for {self.name}: "
|
|
f"got={version}, expected={VERSION}"
|
|
)
|
|
raise RuntimeError(f"Shared memory ring geometry mismatch for {self.name}")
|
|
|
|
time.sleep(poll_s)
|
|
|
|
def _wait_for_ring_file(self, timeout_s: float, poll_s: float) -> None:
|
|
"""Wait for the creator to allocate the ring before mapping its header."""
|
|
deadline = time.monotonic() + timeout_s
|
|
while True:
|
|
try:
|
|
if self._path.stat().st_size >= HEADER.size:
|
|
return
|
|
except FileNotFoundError:
|
|
pass
|
|
if time.monotonic() >= deadline:
|
|
raise FileNotFoundError(f"Shared memory ring does not exist: {self.name}")
|
|
time.sleep(poll_s)
|
|
|
|
def _read_u32(self, offset: int) -> int:
|
|
"""Read little-endian u32 at mmap offset."""
|
|
return atomics.load(self._mmap, offset, 4)
|
|
|
|
def _read_u64(self, offset: int) -> int:
|
|
"""Read little-endian u64 at mmap offset."""
|
|
return atomics.load(self._mmap, offset, 8)
|