Preserve onboard preview recordings across backpressure and share the complete spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 17:47:10 +03:00
parent 9b6534287a
commit 9bba44f7c4
30 changed files with 957 additions and 494 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode()
assert "Depends: mission-core-node (>= 0.8.9)" in control
assert "Depends: mission-core-node (>= 0.8.10)" in control
assert "Replaces: mission-core-node (<< 0.8.0)" in control
+60 -10
View File
@@ -1,5 +1,6 @@
import asyncio
import queue
from uuid import uuid4
import pytest
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
@@ -36,7 +37,7 @@ def test_failed_media_offer_retires_peer_without_camera_channel(monkeypatch):
peers = NodeMediaPeers(None, Camera())
try:
with pytest.raises(ValueError):
await peers.offer({"sdp": SDP_HEADER})
await peers.offer({"sdp": SDP_HEADER, "view_id": str(uuid4())})
assert peers.items == {}
finally:
await peers.close_all()
@@ -50,23 +51,28 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
class Subscription:
def __init__(self):
self.pending = None
self.output = queue.Queue()
self.output.put(payload)
def read(self):
def next_batch(self, *, wait=True):
try:
return self.output.get(timeout=0.1)
self.pending = (1, self.output.get(timeout=0.1), None)
return self.pending
except queue.Empty:
return b""
def snapshot(self):
def snapshot(self, frame=None):
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
def close(self):
def acknowledge(self, sequence):
self.pending = None
def release(self):
pass
class Hub:
def subscribe(self):
def subscribe(self, view_id, after):
return Subscription()
class Camera:
@@ -98,11 +104,14 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
return
payloads.append(data)
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
channel.send('{"ack":1}')
received.set()
try:
await client.setLocalDescription(await client.createOffer())
answer = await peers.offer({"sdp": client.localDescription.sdp})
answer = await peers.offer({
"sdp": client.localDescription.sdp, "view_id": str(uuid4()),
})
await client.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type="answer")
)
@@ -210,14 +219,23 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
video = client.createDataChannel("camera", ordered=True)
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
batch_sequence, remaining = 0, 0
@rrd.on("message")
def rrd_message(data):
nonlocal batch_sequence, remaining
if isinstance(data, str):
value = json.loads(data)
if value["sequence"] == 1:
batch_sequence = value["sequence"]
if value["lidar"]["sequence"] == 1:
resumed.set()
elif data.startswith(b"MCF1") and remaining == 0:
remaining = int.from_bytes(data[4:], "big")
else:
ready.set()
remaining -= len(data)
if remaining == 0:
rrd.send(json.dumps({"ack": batch_sequence}))
ready.set()
@video.on("message")
def camera_message(data):
@@ -226,7 +244,9 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
try:
await client.setLocalDescription(await client.createOffer())
answer = await peers.offer({"sdp": client.localDescription.sdp})
answer = await peers.offer({
"sdp": client.localDescription.sdp, "view_id": str(uuid4()),
})
await client.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type="answer")
)
@@ -251,3 +271,33 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
assert not peers.items
asyncio.run(run())
def test_network_backpressure_longer_than_two_seconds_is_a_pause():
"""Bounded synthetic scheduler pause; no sockets, scanner or load generation."""
import time
class Channel:
readyState = "open"
sent = []
blocked = True
@property
def bufferedAmount(self):
return 1024 * 1024 if self.blocked else 0
def send(self, value):
self.sent.append(value)
async def run():
channel = Channel()
peers = object.__new__(NodeMediaPeers)
task = asyncio.create_task(peers.send(channel, b"small-synthetic-payload", lambda: True))
started = time.monotonic()
await asyncio.sleep(2.1)
assert not task.done() and channel.sent == []
channel.blocked = False
await asyncio.wait_for(task, 1)
assert time.monotonic() - started >= 2
assert channel.sent == [b"MCF1\x00\x00\x00\x17", b"small-synthetic-payload"]
asyncio.run(run())
+60
View File
@@ -78,3 +78,63 @@ def test_reopened_viewer_does_not_replay_cached_points_after_source_pause(monkey
finally:
bridge.close()
sub.thread.join(timeout=3)
def test_slow_consumer_resumes_same_recording_and_replays_unacknowledged_batch():
"""A >500ms delivery pause used to retire the recording and lose its route."""
bridge = NodeRerunBridge()
sub = bridge.subscribe("synthetic-view")
try:
batch = sub.next_batch()
assert batch and batch[1].startswith(b"RRF2")
sequence = batch[0]
# Produce several tiny frames without draining the bounded outbox.
for index in range(1, 8):
bridge.process(points(index))
time.sleep(0.12)
assert not sub.closed.is_set()
assert sub.output.qsize() <= 2
old_thread = sub.thread
sub.release()
resumed = bridge.subscribe("synthetic-view", sequence - 1)
assert resumed is sub and resumed.thread is old_thread
assert resumed.next_batch() == batch # ACK lost: resend the exact RRD.
resumed.acknowledge(sequence)
following = resumed.next_batch()
assert following[0] == sequence + 1
resumed.release()
again = bridge.subscribe("synthetic-view", following[0])
assert again is sub and again.pending is None # Delivered ACK lost at sender.
again.release()
finally:
bridge.close()
sub.thread.join(timeout=3)
assert not sub.thread.is_alive()
def test_resumption_cannot_silently_replace_expired_recording():
import pytest
bridge = NodeRerunBridge()
try:
with pytest.raises(ValueError, match="expired"):
bridge.subscribe("missing-view", 2)
assert bridge.subscribers == []
finally:
bridge.close()
def test_closing_view_releases_capacity_without_waiting_for_resume_grace():
bridge = NodeRerunBridge()
views = []
try:
for index in range(4):
sub = bridge.subscribe(f"view-{index}")
views.append(sub)
sub.release()
bridge.release_view(f"view-{index}")
assert sub.closed.is_set()
assert bridge.views == {}
finally:
bridge.close()
for sub in views:
sub.thread.join(timeout=3)