Preserve onboard preview recordings across backpressure and share the complete spatial scene
This commit is contained in:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user