cbccbd14b5
resume(reset=True) 与暂停中未领取的请求共享目标,领取后另分配;变异验证两项断言有效。SDK 窄套件 103 项通过。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
777 lines
28 KiB
Python
777 lines
28 KiB
Python
"""StreamPipeline: staging, snapshot immutability, error propagation, shutdown."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys import FrameSnapshot, PauseLevel, ProducedFrame, StreamPipeline
|
|
|
|
|
|
class FakeSensor:
|
|
"""Minimal Sensor stand-in with the same mutable-buffer behaviour.
|
|
|
|
It deliberately reuses one image buffer (like ``Sensor._img_gray_buf``) so the
|
|
pipeline's private-copy contract is actually exercised.
|
|
"""
|
|
|
|
class info:
|
|
VRAW = "vraw"
|
|
VNORMAL = "vnormal"
|
|
VSHEAR = "vshear"
|
|
DEPTH = "depth"
|
|
CENTROID = "centroid"
|
|
CONTOUR = "contour"
|
|
|
|
def __init__(self, *, fail_at: int | None = None, flow_delay: float = 0.0,
|
|
hhd_delay: float = 0.0):
|
|
self.img = None
|
|
self.flow = None
|
|
self.vnormal = None
|
|
self.vshear = None
|
|
self.depth_map = None
|
|
self.centroid = (0, 0)
|
|
self.contour = np.empty(0)
|
|
self.ftor = 0.0
|
|
self._force_raw = (0.0, 0.0, 0.0)
|
|
self.fnormal = self.fshearx = self.fsheary = 0.0
|
|
self.force_unit = "raw"
|
|
self._img_buf = np.zeros((8, 8), dtype=np.uint8)
|
|
self._flow_buf = np.zeros((8, 8, 2), dtype=np.float32)
|
|
self._flow_delay = flow_delay
|
|
self._hhd_delay = hhd_delay
|
|
self._fail_at = fail_at
|
|
self._flow_calls = 0
|
|
self._seen_images: list[np.ndarray] = []
|
|
self._seen_flow_values: list[float] = []
|
|
self.contact_calls = 0
|
|
#: Mirrors ``Sensor.reset()``, which rebases ``initial_img`` on the current frame. Records
|
|
#: the frame sequence at each call so a test can prove the reset did not race the flow
|
|
#: stage's read of that buffer.
|
|
self.img_size_raw = None
|
|
self.reset_calls = 0
|
|
self.reset_at_frame: list[int] = []
|
|
|
|
def make_frame(self, seq: int) -> np.ndarray:
|
|
# writes INTO the shared buffer, exactly like cv2.resize(dst=...)
|
|
self._img_buf.fill(seq % 251)
|
|
self.img = self._img_buf
|
|
return self._img_buf
|
|
|
|
def compute_flow(self, img=None, **kwargs) -> None:
|
|
image = self.img if img is None else img
|
|
self._flow_calls += 1
|
|
if self._fail_at is not None and self._flow_calls == self._fail_at:
|
|
raise RuntimeError("flow exploded")
|
|
self._seen_images.append(np.array(image, copy=True))
|
|
time.sleep(self._flow_delay)
|
|
# rewrite the shared buffer, like the real DIS/EMA path does
|
|
self._flow_buf.fill(float(self._flow_calls))
|
|
self.flow = self._flow_buf
|
|
|
|
def compute_hhd(self, flow=None, **kwargs) -> None:
|
|
# Mirrors Sensor.compute_hhd, which derives the forces from the field it
|
|
# is given - so this only yields frame N's forces if the pipeline passes
|
|
# frame N's flow explicitly.
|
|
time.sleep(self._hhd_delay)
|
|
field = self.flow if flow is None else flow
|
|
value = (float(field[0, 0, 0])
|
|
if isinstance(field, np.ndarray) and field.size else 0.0)
|
|
self._seen_flow_values.append(value)
|
|
self.fnormal = value * 10.0
|
|
self.fshearx = value
|
|
self.fsheary = -value
|
|
self._force_raw = (self.fnormal, self.fshearx, self.fsheary)
|
|
self.ftor = value
|
|
self.vnormal = self._flow_buf
|
|
self.vshear = self._flow_buf
|
|
|
|
def compute_contact(self) -> None:
|
|
self.contact_calls += 1
|
|
self.centroid = (1, 2)
|
|
self.contour = np.zeros((1, 1, 2), dtype=np.int32)
|
|
self.depth_map = np.zeros((8, 8), dtype=np.float32)
|
|
|
|
def reset(self) -> None:
|
|
self.reset_calls += 1
|
|
self.reset_at_frame.append(self._flow_calls)
|
|
|
|
def read_info(self, *args):
|
|
source = {
|
|
self.info.VRAW: lambda: self.flow.copy(),
|
|
self.info.VNORMAL: lambda: self.vnormal.copy(),
|
|
self.info.VSHEAR: lambda: self.vshear.copy(),
|
|
self.info.DEPTH: lambda: self.depth_map.copy(),
|
|
self.info.CENTROID: lambda: np.array(self.centroid),
|
|
self.info.CONTOUR: lambda: self.contour.copy(),
|
|
}
|
|
return tuple(source[a]() for a in args)
|
|
|
|
|
|
def make_producer(sensor: FakeSensor, count: int):
|
|
state = {"i": 0}
|
|
|
|
def produce():
|
|
if state["i"] >= count:
|
|
return None
|
|
frame = sensor.make_frame(state["i"])
|
|
state["i"] += 1
|
|
return frame
|
|
|
|
return produce
|
|
|
|
|
|
def run_all(sensor: FakeSensor, count: int, **kwargs):
|
|
"""Drive the pipeline with an exhaustive consumer: every frame, in order."""
|
|
frames: list = []
|
|
kwargs.setdefault("depth", 2)
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, count),
|
|
on_frame=frames.append, **kwargs)
|
|
pipe.start()
|
|
deadline = time.perf_counter() + 10.0
|
|
while len(frames) < count and time.perf_counter() < deadline:
|
|
if pipe.error is not None:
|
|
break
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
return pipe, frames
|
|
|
|
|
|
def test_on_frame_receives_every_frame_in_order():
|
|
sensor = FakeSensor()
|
|
pipe, frames = run_all(sensor, 12)
|
|
assert len(frames) == 12
|
|
assert [s.seq for s in frames] == list(range(12))
|
|
assert pipe.stats.produced == 12
|
|
assert pipe.stats.published == 12
|
|
|
|
|
|
def test_producer_metadata_stays_with_its_image_through_the_queues():
|
|
sensor = FakeSensor(flow_delay=0.002)
|
|
frames = []
|
|
produced = 0
|
|
|
|
def produce():
|
|
nonlocal produced
|
|
if produced == 12:
|
|
return None
|
|
value = produced
|
|
produced += 1
|
|
return ProducedFrame(sensor.make_frame(value), {"source_seq": value})
|
|
|
|
pipe = StreamPipeline(sensor, produce, on_frame=frames.append)
|
|
pipe.start()
|
|
deadline = time.perf_counter() + 10.0
|
|
while len(frames) < 12 and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
assert len(frames) == 12
|
|
for snap in frames:
|
|
assert snap.source_metadata["source_seq"] == int(snap.image[0, 0])
|
|
|
|
|
|
def test_forces_come_from_the_matching_frame():
|
|
"""Each snapshot must carry the forces computed from its own frame's flow."""
|
|
sensor = FakeSensor()
|
|
_, frames = run_all(sensor, 10)
|
|
# frame i's flow value is (i + 1); the fake derives the force from self.flow,
|
|
# so a mismatched pairing would show up as a repeated or skipped value.
|
|
assert sensor._seen_flow_values == [float(i + 1) for i in range(10)]
|
|
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
|
|
assert [s.shear_x for s in frames] == [float(i + 1) for i in range(10)]
|
|
|
|
|
|
def test_stages_do_not_read_a_rewritten_image_buffer():
|
|
"""The producer reuses one buffer; no stage may observe a later frame."""
|
|
sensor = FakeSensor(flow_delay=0.004)
|
|
run_all(sensor, 8)
|
|
seen = sensor._seen_images
|
|
assert len(seen) == 8
|
|
for i, arr in enumerate(seen):
|
|
assert np.all(arr == i % 251), f"frame {i} was overwritten before flow ran"
|
|
|
|
|
|
def test_snapshot_arrays_are_private_copies():
|
|
sensor = FakeSensor()
|
|
_, frames = run_all(sensor, 4, include=("image", "flow", "vnormal"))
|
|
first = frames[0]
|
|
assert isinstance(first, FrameSnapshot)
|
|
assert first.image is not None and first.flow is not None
|
|
saved = first.flow.copy()
|
|
# the SDK keeps rewriting its buffers; the snapshot must not change
|
|
sensor._flow_buf.fill(999.0)
|
|
sensor._img_buf.fill(7)
|
|
assert np.array_equal(first.flow, saved)
|
|
assert not np.any(first.flow == 999.0)
|
|
|
|
|
|
def test_include_filters_which_arrays_are_copied():
|
|
sensor = FakeSensor()
|
|
_, frames = run_all(sensor, 3, include=("vnormal",))
|
|
assert frames[0].vnormal is not None
|
|
assert frames[0].image is None and frames[0].flow is None
|
|
assert frames[0].depth is None and frames[0].contour is None
|
|
|
|
|
|
def test_rejects_unknown_include_field():
|
|
sensor = FakeSensor()
|
|
with pytest.raises(ValueError):
|
|
StreamPipeline(sensor, make_producer(sensor, 1), include=("floww",))
|
|
|
|
|
|
def test_contact_stage_runs_when_requested():
|
|
sensor = FakeSensor()
|
|
_, frames = run_all(sensor, 5, contact=True,
|
|
include=("depth", "centroid", "contour"))
|
|
assert sensor.contact_calls == 5
|
|
assert frames[-1].depth is not None
|
|
assert frames[-1].centroid is not None
|
|
assert frames[-1].contour is not None
|
|
|
|
|
|
def test_stage_exception_surfaces_on_stop():
|
|
sensor = FakeSensor(fail_at=3)
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
|
|
pipe.start()
|
|
time.sleep(0.4)
|
|
with pytest.raises(RuntimeError, match="flow exploded"):
|
|
pipe.stop()
|
|
|
|
|
|
def test_stage_exception_surfaces_on_wait():
|
|
sensor = FakeSensor(fail_at=2)
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
|
|
pipe.start()
|
|
with pytest.raises(RuntimeError):
|
|
for _ in range(50):
|
|
pipe.wait(timeout=0.1)
|
|
pipe.stop()
|
|
|
|
|
|
def test_stop_is_idempotent():
|
|
sensor = FakeSensor()
|
|
pipe, _ = run_all(sensor, 4)
|
|
pipe.stop() # second stop must not raise
|
|
assert not pipe.running
|
|
assert pipe.latest() is not None
|
|
|
|
|
|
def test_stop_while_running_does_not_hang():
|
|
"""Stopping mid-stream must unblock the consumer threads.
|
|
|
|
The producer stops delivering sentinels once the done flag is set, so a
|
|
bare blocking Queue.get() in a consumer would wait forever.
|
|
"""
|
|
sensor = FakeSensor(flow_delay=0.002)
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 100000), depth=2)
|
|
pipe.start()
|
|
assert pipe.wait(timeout=2.0) is not None
|
|
t0 = time.perf_counter()
|
|
pipe.stop(timeout=5.0)
|
|
elapsed = time.perf_counter() - t0
|
|
assert elapsed < 2.0, f"stop() took {elapsed:.2f}s; a stage was still blocked"
|
|
assert not pipe.running
|
|
|
|
|
|
def test_context_manager_stops_threads():
|
|
sensor = FakeSensor()
|
|
frames: list = []
|
|
with StreamPipeline(sensor, make_producer(sensor, 6),
|
|
on_frame=frames.append) as pipe:
|
|
deadline = time.perf_counter() + 5.0
|
|
while len(frames) < 6 and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
assert len(frames) == 6
|
|
assert not pipe.running
|
|
|
|
|
|
def test_wait_exposes_a_latest_wins_view():
|
|
sensor = FakeSensor()
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 5))
|
|
pipe.start()
|
|
last = None
|
|
while True:
|
|
snap = pipe.wait(timeout=2.0)
|
|
if snap is None:
|
|
break
|
|
if last is not None:
|
|
assert snap.seq > last.seq
|
|
last = snap
|
|
# the pipeline has drained; the final snapshot is the last frame
|
|
assert last is not None and last.normal_force == 50.0
|
|
assert pipe.latest() is last
|
|
# nothing new is coming, so a further poll returns immediately
|
|
assert pipe.wait(timeout=0.2) is None
|
|
pipe.stop()
|
|
|
|
|
|
def test_wait_timeout_returns_none_without_new_frames():
|
|
sensor = FakeSensor()
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 1))
|
|
pipe.start()
|
|
assert pipe.wait(timeout=1.0) is not None
|
|
t0 = time.perf_counter()
|
|
assert pipe.wait(timeout=0.1) is None
|
|
assert time.perf_counter() - t0 < 1.0
|
|
pipe.stop()
|
|
|
|
|
|
def test_stats_track_stage_times_and_age():
|
|
sensor = FakeSensor(flow_delay=0.002, hhd_delay=0.001)
|
|
pipe, _ = run_all(sensor, 15)
|
|
d = pipe.stats.as_dict()
|
|
assert d["published"] == 15
|
|
assert d["flow_ms"] >= 1.5 # the injected delay shows up
|
|
assert d["hhd_ms"] >= 0.8
|
|
assert d["age_ms"] > 0.0
|
|
assert d["fps"] > 0.0
|
|
assert d["produce_ms"] >= 0.0
|
|
|
|
|
|
def test_depth_does_not_change_results():
|
|
for depth in (1, 2, 4):
|
|
sensor = FakeSensor()
|
|
_, frames = run_all(sensor, 10, depth=depth)
|
|
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
|
|
|
|
|
|
def test_for_finger_runtime_maps_eof_to_none():
|
|
"""``for_finger_runtime`` drives ``grab_and_process`` and maps EOF to None."""
|
|
sensor = FakeSensor()
|
|
state = {"i": 0}
|
|
|
|
class FakeRuntime:
|
|
tactile = type("T", (), {"raw_sensor": sensor})()
|
|
|
|
def grab_and_process(self, color=False):
|
|
if state["i"] >= 3:
|
|
return False, None
|
|
frame = sensor.make_frame(state["i"])
|
|
state["i"] += 1
|
|
return True, frame
|
|
|
|
frames: list = []
|
|
pipe = StreamPipeline.for_finger_runtime(FakeRuntime(), on_frame=frames.append)
|
|
pipe.start()
|
|
deadline = time.perf_counter() + 5.0
|
|
while len(frames) < 3 and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
assert len(frames) == 3
|
|
assert pipe.stats.produced == 3
|
|
|
|
|
|
def test_reset_is_applied_on_the_flow_stage_not_the_caller():
|
|
"""``reset()`` must not run on the caller's thread.
|
|
|
|
``Sensor.reset`` rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is
|
|
reading that buffer concurrently, so a caller-thread reset would capture a half-written
|
|
reference frame. The pipeline queues it and applies it before the next frame instead.
|
|
"""
|
|
sensor = FakeSensor()
|
|
ready = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def gated_produce():
|
|
# Hold the producer until the caller has asked for a reset, so the reset is provably
|
|
# queued before any frame reaches the flow stage.
|
|
ready.set()
|
|
release.wait(timeout=5.0)
|
|
return sensor.make_frame(0)
|
|
|
|
frames: list = []
|
|
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
|
|
pipe.start()
|
|
assert ready.wait(timeout=5.0)
|
|
|
|
pipe.reset()
|
|
pipe.reset() # idempotent: a second request before the next frame is not doubled
|
|
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
|
|
|
|
release.set()
|
|
deadline = time.perf_counter() + 5.0
|
|
while not frames and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
|
|
assert sensor.reset_calls == 1, f"expected one queued reset, got {sensor.reset_calls}"
|
|
assert sensor.reset_at_frame == [0], (
|
|
"the reset must land before the first compute_flow, not after it"
|
|
)
|
|
|
|
|
|
def test_snapshot_carries_the_raw_capture_size():
|
|
"""``image_size_raw`` lets a caller compare the processed frame with the capture resolution."""
|
|
sensor = FakeSensor()
|
|
sensor.img_size_raw = (1280, 720)
|
|
frames: list = []
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 1), on_frame=frames.append)
|
|
pipe.start()
|
|
deadline = time.perf_counter() + 5.0
|
|
while not frames and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
assert frames and frames[0].image_size_raw == (1280, 720)
|
|
|
|
|
|
def test_snapshot_raw_capture_size_is_none_when_unreported():
|
|
"""A producer that never sets the size must not fabricate one."""
|
|
sensor = FakeSensor()
|
|
sensor.img_size_raw = None
|
|
frames: list = []
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 1), on_frame=frames.append)
|
|
pipe.start()
|
|
deadline = time.perf_counter() + 5.0
|
|
while not frames and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
pipe.stop()
|
|
assert frames and frames[0].image_size_raw is None
|
|
|
|
|
|
class SplitResetFakeSensor(FakeSensor):
|
|
"""A fake with the split reset seam and event-gated flow/HHD stages.
|
|
|
|
The two halves of a reset belong to different pipeline threads, and the
|
|
version a frame is stamped with depends on which half has already run. The
|
|
events pin both stages to a known frame so the regression orders the race
|
|
instead of hoping for it: the flow stage parks at the end of its second
|
|
frame and the HHD stage parks inside its first decomposition.
|
|
"""
|
|
|
|
FLOW_GATE_CALL = 2
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.flow_gate_reached = threading.Event()
|
|
self.release_flow_gate = threading.Event()
|
|
self.hhd_gate_reached = threading.Event()
|
|
self.release_hhd_gate = threading.Event()
|
|
self.hhd_calls = 0
|
|
self.image_reset_threads: list[str] = []
|
|
self.force_reset_threads: list[str] = []
|
|
self.force_reset_at_hhd_calls: list[int] = []
|
|
|
|
def compute_flow(self, img=None, **kwargs) -> None:
|
|
super().compute_flow(img, **kwargs)
|
|
# Park after frame 2's flow. The next frame that reaches the flow stage
|
|
# is then provably the first one that can observe a reset queued now.
|
|
if self._flow_calls == self.FLOW_GATE_CALL:
|
|
self.flow_gate_reached.set()
|
|
self.release_flow_gate.wait(timeout=5.0)
|
|
|
|
def compute_hhd(self, flow=None, **kwargs) -> None:
|
|
self.hhd_calls += 1
|
|
if self.hhd_calls == 1:
|
|
self.hhd_gate_reached.set()
|
|
self.release_hhd_gate.wait(timeout=5.0)
|
|
super().compute_hhd(flow, **kwargs)
|
|
|
|
def reset_image_state(self) -> None:
|
|
self.image_reset_threads.append(threading.current_thread().name)
|
|
|
|
def reset_force_state(self) -> None:
|
|
self.force_reset_threads.append(threading.current_thread().name)
|
|
self.force_reset_at_hhd_calls.append(self.hhd_calls)
|
|
|
|
def reset(self) -> None:
|
|
# Mirrors Sensor.reset(): both halves, immediately, on the caller's thread.
|
|
self.reset_calls += 1
|
|
self.reset_at_frame.append(self._flow_calls)
|
|
self.reset_image_state()
|
|
self.reset_force_state()
|
|
|
|
|
|
class LegacyResetFakeSensor(FakeSensor):
|
|
"""A stand-in with only the historical ``reset()``, like a third-party sensor."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.reset_threads: list[str] = []
|
|
|
|
def reset(self) -> None:
|
|
self.reset_calls += 1
|
|
self.reset_at_frame.append(self._flow_calls)
|
|
self.reset_threads.append(threading.current_thread().name)
|
|
|
|
|
|
def test_reset_request_target_is_shared_until_flow_claims_it():
|
|
sensor = FakeSensor()
|
|
frames = []
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
|
|
assert pipe.request_reset() == 1
|
|
assert pipe.reset() is None # The published entry keeps its return contract.
|
|
assert pipe.request_reset() == 1
|
|
assert pipe.tracker_reset_seq == 0
|
|
pipe.start()
|
|
try:
|
|
assert _wait_until(lambda: len(frames) == 2)
|
|
assert [snap.tracker_reset_seq for snap in frames] == [1, 1]
|
|
assert sensor.reset_calls == 1
|
|
assert pipe.request_reset() == 2
|
|
finally:
|
|
pipe.stop()
|
|
|
|
|
|
def test_reset_requested_while_previous_reset_is_running_gets_a_new_target():
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
class Sensor(LegacyResetFakeSensor):
|
|
def reset(self):
|
|
super().reset()
|
|
if self.reset_calls == 1:
|
|
entered.set()
|
|
assert release.wait(timeout=5.0)
|
|
|
|
sensor = Sensor()
|
|
frames = []
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
|
|
assert pipe.request_reset() == 1
|
|
pipe.start()
|
|
try:
|
|
assert entered.wait(timeout=5.0)
|
|
# Flow has claimed request 1, but has not finished applying it. The live
|
|
# counter is still 0; request 2 must not be merged into request 1.
|
|
assert pipe.tracker_reset_seq == 0
|
|
assert pipe.request_reset() == 2
|
|
assert pipe.request_reset() == 2
|
|
release.set()
|
|
assert _wait_until(lambda: len(frames) == 2)
|
|
assert [snap.tracker_reset_seq for snap in frames] == [1, 2]
|
|
assert sensor.reset_calls == 2
|
|
finally:
|
|
release.set()
|
|
pipe.stop()
|
|
|
|
|
|
def _wait_until(predicate, timeout: float = 5.0) -> bool:
|
|
"""Poll a predicate; the events, not this poll, order the race."""
|
|
deadline = time.perf_counter() + timeout
|
|
while time.perf_counter() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.001)
|
|
return predicate()
|
|
|
|
|
|
def _run_gated_reset(sensor: SplitResetFakeSensor, frames: list, count: int = 3) -> None:
|
|
"""Drive two pre-reset frames and one post-reset frame through the real loops."""
|
|
pipe = StreamPipeline(sensor, make_producer(sensor, count),
|
|
on_frame=frames.append, depth=2)
|
|
pipe.start()
|
|
try:
|
|
assert sensor.flow_gate_reached.wait(timeout=5.0), "flow stage never reached its gate"
|
|
assert sensor.hhd_gate_reached.wait(timeout=5.0), "HHD stage never reached its gate"
|
|
# Both stages are provably parked: no frame has been published, and the
|
|
# frame whose flow would observe this reset has not been computed yet.
|
|
pipe.reset()
|
|
sensor.release_flow_gate.set()
|
|
assert _wait_until(lambda: sensor._flow_calls >= 3), (
|
|
"flow stage did not compute the first post-reset frame"
|
|
)
|
|
sensor.release_hhd_gate.set()
|
|
deadline = time.perf_counter() + 5.0
|
|
while len(frames) < count and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
finally:
|
|
sensor.release_flow_gate.set()
|
|
sensor.release_hhd_gate.set()
|
|
pipe.stop()
|
|
assert len(frames) == count, f"expected {count} published frames, got {len(frames)}"
|
|
|
|
|
|
def test_frames_keep_the_reference_version_they_were_measured_with():
|
|
"""A reset queued after a frame's flow must not relabel that frame.
|
|
|
|
Frames 0 and 1 are measured while the HHD stage is held on the first
|
|
snapshot. The caller then queues a reset, and frame 2 is measured with the
|
|
new version. Each snapshot must carry the version its own flow was computed
|
|
with; reading the live counter at publish time would stamp the two older
|
|
frames with the newer version.
|
|
"""
|
|
sensor = SplitResetFakeSensor()
|
|
frames: list[FrameSnapshot] = []
|
|
_run_gated_reset(sensor, frames)
|
|
versions = [snap.tracker_reset_seq for snap in frames]
|
|
assert versions == [0, 0, 1], f"expected per-frame versions [0, 0, 1], got {versions}"
|
|
|
|
|
|
def test_reset_halves_run_on_the_threads_that_own_their_state():
|
|
"""Image state resets on the flow stage, force state on the HHD stage.
|
|
|
|
The force half must land on the HHD stage before it decomposes the first
|
|
frame of the new version. Resetting it from the flow thread - the old
|
|
behaviour - mutates model/zero state that the HHD stage is using.
|
|
"""
|
|
sensor = SplitResetFakeSensor()
|
|
frames: list[FrameSnapshot] = []
|
|
_run_gated_reset(sensor, frames)
|
|
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
|
|
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
|
|
# Two decompositions (frames 0 and 1) had completed when the force reset ran,
|
|
# so it preceded the third one - the first frame of the new version.
|
|
assert sensor.force_reset_at_hhd_calls == [2]
|
|
assert sensor.hhd_calls == 3
|
|
assert [snap.tracker_reset_seq for snap in frames] == [0, 0, 1]
|
|
|
|
|
|
def test_legacy_sensor_with_only_reset_is_reset_on_the_flow_stage():
|
|
"""A sensor without the split methods keeps the historical fallback."""
|
|
sensor = LegacyResetFakeSensor()
|
|
ready = threading.Event()
|
|
release = threading.Event()
|
|
state = {"produced": 0}
|
|
|
|
def gated_produce():
|
|
ready.set()
|
|
release.wait(timeout=5.0)
|
|
if state["produced"]:
|
|
return None
|
|
state["produced"] = 1
|
|
return sensor.make_frame(0)
|
|
|
|
frames: list = []
|
|
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
|
|
pipe.start()
|
|
try:
|
|
assert ready.wait(timeout=5.0)
|
|
pipe.reset()
|
|
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
|
|
release.set()
|
|
deadline = time.perf_counter() + 5.0
|
|
while not frames and time.perf_counter() < deadline:
|
|
time.sleep(0.001)
|
|
finally:
|
|
release.set()
|
|
pipe.stop()
|
|
assert len(frames) == 1
|
|
assert sensor.reset_calls == 1
|
|
assert sensor.reset_threads == ["orisys-pipeline-flow"]
|
|
assert sensor.reset_at_frame == [0], "the fallback reset must precede compute_flow"
|
|
|
|
|
|
class PauseResetFakeSensor(FakeSensor):
|
|
"""A split-reset fake whose flow stage parks at chosen frame numbers.
|
|
|
|
Parking inside ``compute_flow`` is what makes a pause deterministic: the
|
|
flow stage has already decided the current frame's version and cannot claim
|
|
a new request until the gate is released.
|
|
"""
|
|
|
|
def __init__(self, *, gates=(1,)) -> None:
|
|
super().__init__()
|
|
self._gate_calls = {int(gate) for gate in gates}
|
|
self.gate_reached = {call: threading.Event() for call in self._gate_calls}
|
|
self.release_gate = {call: threading.Event() for call in self._gate_calls}
|
|
self.hhd_calls = 0
|
|
self.image_reset_threads: list[str] = []
|
|
self.force_reset_threads: list[str] = []
|
|
self.force_reset_at_hhd_calls: list[int] = []
|
|
|
|
def compute_flow(self, img=None, **kwargs) -> None:
|
|
FakeSensor.compute_flow(self, img, **kwargs)
|
|
call = self._flow_calls
|
|
if call in self._gate_calls:
|
|
self.gate_reached[call].set()
|
|
self.release_gate[call].wait(timeout=5.0)
|
|
|
|
def compute_hhd(self, flow=None, **kwargs) -> None:
|
|
self.hhd_calls += 1
|
|
FakeSensor.compute_hhd(self, flow, **kwargs)
|
|
|
|
def reset_image_state(self) -> None:
|
|
self.image_reset_threads.append(threading.current_thread().name)
|
|
|
|
def reset_force_state(self) -> None:
|
|
self.force_reset_threads.append(threading.current_thread().name)
|
|
self.force_reset_at_hhd_calls.append(self.hhd_calls)
|
|
|
|
def reset(self) -> None:
|
|
# Mirrors Sensor.reset(): both halves immediately, on the caller's thread.
|
|
self.reset_calls += 1
|
|
self.reset_at_frame.append(self._flow_calls)
|
|
self.reset_image_state()
|
|
self.reset_force_state()
|
|
|
|
|
|
def test_resume_coalesces_with_a_reset_request_queued_while_paused():
|
|
"""A request queued while flow cannot claim it shares the resume's target."""
|
|
sensor = PauseResetFakeSensor(gates=(1,))
|
|
frames: list[FrameSnapshot] = []
|
|
pipe = StreamPipeline(
|
|
sensor, make_producer(sensor, 8), on_frame=frames.append, depth=2
|
|
)
|
|
pipe.start()
|
|
try:
|
|
assert sensor.gate_reached[1].wait(timeout=5.0), "flow never reached its gate"
|
|
pipe.pause(PauseLevel.CAPTURE_ONLY)
|
|
assert pipe.tracker_reset_seq == 0
|
|
request = pipe.request_reset()
|
|
assert request == 1
|
|
pipe.resume(reset=True)
|
|
# The flow stage is still parked, so resume's own request coalesces.
|
|
assert pipe.request_reset() == request, (
|
|
"resume allocated a new target although the flow stage had not claimed "
|
|
"the queued request"
|
|
)
|
|
sensor.release_gate[1].set()
|
|
assert _wait_until(
|
|
lambda: any(snap.tracker_reset_seq >= request for snap in frames)
|
|
), "the pipeline never published the resumed reference version"
|
|
versions = [snap.tracker_reset_seq for snap in frames]
|
|
assert 0 in versions, versions
|
|
assert max(versions) == request, versions
|
|
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
|
|
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
|
|
finally:
|
|
sensor.release_gate[1].set()
|
|
pipe.stop()
|
|
|
|
|
|
def test_resume_after_a_claimed_reset_gets_a_new_target():
|
|
"""Once flow has claimed a request, resume's reset must not merge into it."""
|
|
sensor = PauseResetFakeSensor(gates=(1, 3))
|
|
frames: list[FrameSnapshot] = []
|
|
pipe = StreamPipeline(
|
|
sensor, make_producer(sensor, 10), on_frame=frames.append, depth=2
|
|
)
|
|
pipe.start()
|
|
try:
|
|
assert sensor.gate_reached[1].wait(timeout=5.0), "flow never reached gate 1"
|
|
first = pipe.request_reset()
|
|
assert first == 1
|
|
sensor.release_gate[1].set()
|
|
assert _wait_until(
|
|
lambda: any(snap.tracker_reset_seq >= first for snap in frames)
|
|
), "the first requested version was never published"
|
|
assert sensor.gate_reached[3].wait(timeout=5.0), "flow never reached gate 3"
|
|
assert pipe.tracker_reset_seq == first
|
|
|
|
pipe.pause(PauseLevel.CAPTURE_ONLY)
|
|
second = pipe.request_reset()
|
|
assert second == first + 1
|
|
pipe.resume(reset=True)
|
|
assert pipe.request_reset() == second, (
|
|
"resume merged into a target the flow stage had already claimed"
|
|
)
|
|
sensor.release_gate[3].set()
|
|
assert _wait_until(
|
|
lambda: any(snap.tracker_reset_seq >= second for snap in frames)
|
|
), "the resumed version was never published"
|
|
versions = [snap.tracker_reset_seq for snap in frames]
|
|
assert {first, second}.issubset(set(versions)), versions
|
|
assert max(versions) == second, versions
|
|
# Each new reference version resets the image and the force state once.
|
|
assert len(sensor.image_reset_threads) == 2, sensor.image_reset_threads
|
|
assert len(sensor.force_reset_threads) == 2, sensor.force_reset_threads
|
|
assert sensor.force_reset_at_hhd_calls == sorted(
|
|
sensor.force_reset_at_hhd_calls
|
|
), sensor.force_reset_at_hhd_calls
|
|
finally:
|
|
sensor.release_gate[1].set()
|
|
sensor.release_gate[3].set()
|
|
pipe.stop()
|