use std::time::Duration;
use crate::backend::{BackendKind, WriteOutcome};
use crate::device::DacInfo;
use crate::error::Error;
use super::super::content_source::ContentSourceKind;
use super::{
blank_and_close_shutter, close_if_disarmed, estimator_fullness, process_control_messages,
ControlAction, LoopCtx, OutputModelAdapter, StepOutcome,
};
const MIN_WRITE_QUANTUM_SECS: f64 = 0.005;
const MIN_WRITE_QUANTUM_FLOOR: usize = 16;
const WOULDBLOCK_STALL_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) struct NetworkFifoAdapter {
max_points: usize,
written_pps: Option<u32>,
}
impl NetworkFifoAdapter {
pub fn new(backend: &BackendKind) -> Self {
Self {
max_points: backend.caps().max_points_per_chunk,
written_pps: None,
}
}
}
impl OutputModelAdapter for NetworkFifoAdapter {
fn step(&mut self, ctx: &mut LoopCtx<'_>) -> StepOutcome {
let pps = ctx.pps;
let pps_f64 = pps as f64;
let requested_points = ctx.target_buffer.as_secs_f64() * pps_f64;
let target_buffer_f64 = match ctx.backend.target_buffer_ceiling() {
Some(ceiling) => requested_points.min(ceiling.max(1) as f64),
None => requested_points,
};
let target_buffer_points = target_buffer_f64 as u64;
let quantum = ((MIN_WRITE_QUANTUM_SECS * pps_f64).ceil() as usize)
.max(MIN_WRITE_QUANTUM_FLOOR)
.min(self.max_points)
.min(target_buffer_points as usize);
let drain_pps = self.written_pps.unwrap_or(pps);
let rate_pending = drain_pps != pps;
let buffered = estimator_fullness(ctx.backend.estimator(), drain_pps);
if buffered > target_buffer_points && !rate_pending {
let excess = buffered - target_buffer_points;
let sleep_time = Duration::from_secs_f64(excess as f64 / pps_f64.max(1.0));
if let Err(stopped) = ctx.sleep_with_control_check(sleep_time) {
return stopped;
}
return StepOutcome::Continue;
}
let deficit = (target_buffer_f64 - buffered as f64).max(0.0);
let mut target_points = (deficit.ceil() as usize).min(self.max_points);
if rate_pending {
target_points = target_points.max(quantum).max(1);
}
if target_points == 0 {
ctx.sleep_and_mark_activity(Duration::from_millis(1));
return StepOutcome::Continue;
}
if target_points < quantum {
let shortfall = (quantum - target_points) as f64;
let wait = Duration::from_secs_f64(shortfall / pps_f64.max(1.0));
if let Err(stopped) = ctx.sleep_with_control_check(wait) {
return stopped;
}
return StepOutcome::Continue;
}
let source = match &mut ctx.source {
ContentSourceKind::Fifo(s) => s,
ContentSourceKind::Frame(_) => {
return super::source_mismatch(ctx, "NetworkFifoAdapter");
}
};
let n = source.produce_chunk(target_points, pps, ctx.is_armed).len();
if n == 0 {
ctx.sleep_and_mark_activity(Duration::from_millis(1));
return StepOutcome::Continue;
}
let spin_start = ctx.clock.now();
let desired_at_start = ctx.control.desired_state();
loop {
let outcome = match source.cached_slice() {
Some(slice) => ctx.backend.try_write(pps, slice),
None => return StepOutcome::Continue,
};
match outcome {
Ok(WriteOutcome::Written) => {
ctx.metrics.mark_write_success();
self.written_pps = Some(pps);
source.commit_written(n, ctx.is_armed);
break;
}
Ok(WriteOutcome::WouldBlock) => {
ctx.metrics.mark_loop_activity();
std::thread::yield_now();
if ctx.control.is_stop_requested() {
return StepOutcome::Stopped;
}
let action = process_control_messages(ctx.control_rx);
close_if_disarmed(ctx.control, ctx.shutter_open, ctx.backend);
match action {
ControlAction::Stop => return StepOutcome::Stopped,
ControlAction::StateChanged => return StepOutcome::StateChanged,
ControlAction::None
if ctx.control.is_armed() != ctx.is_armed
|| ctx.control.desired_state() != desired_at_start =>
{
return StepOutcome::StateChanged;
}
ControlAction::None => {}
}
if ctx.clock.now().saturating_duration_since(spin_start)
>= WOULDBLOCK_STALL_TIMEOUT
{
log::warn!("write stalled (WouldBlock > {WOULDBLOCK_STALL_TIMEOUT:?}), treating as disconnect");
let _ = ctx.backend.disconnect();
(ctx.error_sink)(Error::disconnected(
"write stalled: continuous WouldBlock",
));
return StepOutcome::Disconnected;
}
ctx.clock.sleep(Duration::from_micros(100));
ctx.metrics.mark_loop_activity();
}
Err(e) if e.is_stopped() => return StepOutcome::Stopped,
Err(e) if e.is_disconnected() => {
(ctx.error_sink)(e);
return StepOutcome::Disconnected;
}
Err(e) => {
log::warn!("write error, disconnecting backend: {e}");
let _ = ctx.backend.disconnect();
(ctx.error_sink)(e);
return StepOutcome::Disconnected;
}
}
}
StepOutcome::Continue
}
fn on_reconnect(&mut self, info: &DacInfo, _backend: &mut BackendKind) {
self.max_points = info.caps.max_points_per_chunk;
self.written_pps = None;
}
fn drain_and_blank(&mut self, ctx: &mut LoopCtx<'_>, timeout: Duration) {
super::drain_via_estimator(ctx, timeout);
blank_and_close_shutter(ctx);
}
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::backend::{BackendKind, DacBackend, FifoBackend, WriteOutcome};
use crate::buffer_estimate::{BufferEstimator, SoftwareDecayEstimator};
use crate::config::IdlePolicy;
use crate::device::{DacCapabilities, DacType, OutputModel};
use crate::error::Result as DacResult;
use crate::point::LaserPoint;
use crate::presentation::content_source::{ContentSourceKind, FifoContentSource};
use crate::presentation::engine::PresentationEngine;
use crate::presentation::output_model::{
FakeClock, LoopCtx, OutputModelAdapter, StepOutcome, SystemClock,
};
use crate::presentation::session::FrameSessionMetrics;
use crate::presentation::slice_pipeline::SlicePipeline;
use crate::presentation::{Frame, TransitionPlan};
use crate::session::{ControlMsg, SessionControl};
use super::NetworkFifoAdapter;
struct FakeFifo {
caps: DacCapabilities,
always_block: bool,
writes: Arc<Mutex<Vec<usize>>>,
shutter_calls: Arc<Mutex<Vec<bool>>>,
estimator: SoftwareDecayEstimator,
}
impl DacBackend for FakeFifo {
fn dac_type(&self) -> DacType {
DacType::Custom("FakeFifo".into())
}
fn caps(&self) -> &DacCapabilities {
&self.caps
}
fn connect(&mut self) -> DacResult<()> {
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
Ok(())
}
fn is_connected(&self) -> bool {
true
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, open: bool) -> DacResult<()> {
self.shutter_calls.lock().unwrap().push(open);
Ok(())
}
}
impl FifoBackend for FakeFifo {
fn try_write_points(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<WriteOutcome> {
if self.always_block {
return Ok(WriteOutcome::WouldBlock);
}
self.writes.lock().unwrap().push(points.len());
Ok(WriteOutcome::Written)
}
fn estimator(&self) -> &dyn BufferEstimator {
&self.estimator
}
}
fn frame_with_points(n: usize) -> Frame {
let pts: Vec<LaserPoint> = (0..n)
.map(|i| LaserPoint::new(i as f32 * 0.0001, 0.0, 1000, 1000, 1000, 1000))
.collect();
Frame::new(pts)
}
fn caps(max_points: usize) -> DacCapabilities {
DacCapabilities {
pps_min: 1_000,
pps_max: 100_000,
max_points_per_chunk: max_points,
output_model: OutputModel::NetworkFifo,
}
}
struct HoldingEstimator(Arc<Mutex<Vec<usize>>>);
impl BufferEstimator for HoldingEstimator {
fn estimated_fullness(&self, _now: Instant, _pps: u32) -> u64 {
self.0.lock().unwrap().iter().sum::<usize>() as u64
}
}
struct CeilingFifo {
caps: DacCapabilities,
ceiling: Option<usize>,
writes: Arc<Mutex<Vec<usize>>>,
estimator: HoldingEstimator,
}
impl DacBackend for CeilingFifo {
fn dac_type(&self) -> DacType {
DacType::Custom("CeilingFifo".into())
}
fn caps(&self) -> &DacCapabilities {
&self.caps
}
fn connect(&mut self) -> DacResult<()> {
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
Ok(())
}
fn is_connected(&self) -> bool {
true
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, _open: bool) -> DacResult<()> {
Ok(())
}
}
impl FifoBackend for CeilingFifo {
fn try_write_points(
&mut self,
_pps: u32,
points: &[LaserPoint],
) -> DacResult<WriteOutcome> {
self.writes.lock().unwrap().push(points.len());
Ok(WriteOutcome::Written)
}
fn estimator(&self) -> &dyn BufferEstimator {
&self.estimator
}
fn target_buffer_ceiling(&self) -> Option<usize> {
self.ceiling
}
}
fn writes_with_ceiling(ceiling: Option<usize>, steps: usize) -> Vec<usize> {
const PPS: u32 = 100_000;
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let backend = CeilingFifo {
caps: caps(3_899),
ceiling,
writes: Arc::clone(&writes),
estimator: HoldingEstimator(Arc::clone(&writes)),
};
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
for _ in 0..steps {
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(50),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
let written = writes.lock().unwrap().clone();
written
}
#[test]
fn target_is_clamped_to_the_backend_ceiling() {
let w = writes_with_ceiling(Some(3_119), 4);
assert_eq!(w, vec![3_119], "one write up to the ceiling, then none");
}
#[test]
fn no_ceiling_keeps_the_requested_target() {
let w = writes_with_ceiling(None, 4);
assert_eq!(w, vec![3_899, 1_101], "chunk-capped, then up to 5000");
}
#[test]
fn deficit_below_quantum_is_not_written() {
const PPS: u32 = 30_000;
let mut estimator = SoftwareDecayEstimator::new();
estimator.record_send(Instant::now() + Duration::from_secs(1), 595, PPS);
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator,
};
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
assert!(
writes.lock().unwrap().is_empty(),
"sub-quantum deficit must not be written"
);
}
#[test]
fn deficit_above_quantum_is_written() {
const PPS: u32 = 30_000;
let mut estimator = SoftwareDecayEstimator::new();
estimator.record_send(Instant::now(), 100, PPS);
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator,
};
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
let w = writes.lock().unwrap();
assert_eq!(w.len(), 1, "above-quantum deficit should be written");
assert!(w[0] >= 150, "written chunk should be at least the quantum");
}
#[test]
fn disarm_during_wouldblock_spin_closes_shutter() {
const PPS: u32 = 30_000;
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let shutter_calls = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: true, writes: Arc::new(Mutex::new(Vec::new())),
shutter_calls: Arc::clone(&shutter_calls),
estimator: SoftwareDecayEstimator::new(), };
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
tx.send(ControlMsg::Disarm).unwrap();
tx.send(ControlMsg::Stop).unwrap();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open; {
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Stopped));
}
assert_eq!(
shutter,
crate::presentation::output_model::ShutterState::Closed,
"shutter must be closed by the in-spin Disarm"
);
assert_eq!(
shutter_calls.lock().unwrap().as_slice(),
&[false],
"exactly one set_shutter(false) from the Disarm"
);
}
#[test]
fn on_reconnect_updates_max_points() {
use crate::device::{DacInfo, DacType};
const PPS: u32 = 30_000;
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator: SoftwareDecayEstimator::new(), };
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let info = DacInfo {
id: "fakefifo:reconnect".to_string(),
name: "FakeFifo".to_string(),
kind: DacType::Custom("FakeFifo".to_string()),
caps: caps(3),
};
adapter.on_reconnect(&info, &mut backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
let w = writes.lock().unwrap();
assert_eq!(w.len(), 1, "one write after reconnect");
assert_eq!(
w[0], 3,
"write must be clamped to the reconnected device's max_points_per_chunk"
);
}
#[test]
fn buffered_above_target_sleeps_without_writing() {
const PPS: u32 = 30_000;
let mut estimator = SoftwareDecayEstimator::new();
estimator.record_send(Instant::now() + Duration::from_secs(1), 800, PPS);
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator,
};
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
assert!(
writes.lock().unwrap().is_empty(),
"a buffer above target must not be topped up with a write"
);
}
#[test]
fn drain_and_blank_closes_shutter_and_writes_blank() {
const PPS: u32 = 30_000;
let mut engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
engine.set_pending(frame_with_points(2_000));
let mut pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let writes = Arc::new(Mutex::new(Vec::new()));
let shutter_calls = Arc::new(Mutex::new(Vec::new()));
let backend = FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::clone(&shutter_calls),
estimator: SoftwareDecayEstimator::new(),
};
let mut backend = BackendKind::Fifo(Box::new(backend));
let mut adapter = NetworkFifoAdapter::new(&backend);
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: PPS,
is_armed: true,
clock: &SystemClock,
};
adapter.drain_and_blank(&mut ctx, Duration::ZERO);
}
assert_eq!(
shutter,
crate::presentation::output_model::ShutterState::Closed,
"drain_and_blank must close the shutter"
);
assert_eq!(
shutter_calls.lock().unwrap().as_slice(),
&[false],
"exactly one set_shutter(false) from drain_and_blank"
);
let w = writes.lock().unwrap();
assert_eq!(w.len(), 1, "one trailing blank write");
assert_eq!(w[0], 16, "the trailing blank chunk is 16 points");
}
fn sleep_scaffold() -> (
BackendKind,
SlicePipeline,
SessionControl,
mpsc::Receiver<ControlMsg>,
FrameSessionMetrics,
) {
const PPS: u32 = 30_000;
let engine =
PresentationEngine::new(Box::new(|_, _, _| TransitionPlan::Transition(Vec::new())));
let pipeline = SlicePipeline::new(
engine,
std::time::Duration::ZERO,
None,
IdlePolicy::Blank,
0,
);
let backend = BackendKind::Fifo(Box::new(FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::new(Mutex::new(Vec::new())),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator: SoftwareDecayEstimator::new(),
}));
let (tx, rx) = mpsc::channel::<ControlMsg>();
let control = SessionControl::new(tx, Duration::ZERO, PPS);
let metrics = FrameSessionMetrics::new(true);
(backend, pipeline, control, rx, metrics)
}
#[test]
fn pacing_sleep_uses_injected_clock_not_wall_clock() {
let (mut backend, mut pipeline, control, rx, metrics) = sleep_scaffold();
let clock = FakeClock::new();
let mut shutter = crate::presentation::output_model::ShutterState::Open;
let wall_start = Instant::now();
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: 30_000,
is_armed: true,
clock: &clock,
};
assert!(ctx
.sleep_with_control_check(Duration::from_secs(30))
.is_ok());
}
assert!(
wall_start.elapsed() < Duration::from_secs(1),
"sleep must not block on the wall clock"
);
assert_eq!(clock.total_slept(), Duration::from_secs(30));
}
#[test]
fn pacing_sleep_returns_stopped_on_stop_request() {
let (mut backend, mut pipeline, control, rx, metrics) = sleep_scaffold();
let clock = FakeClock::new();
let mut shutter = crate::presentation::output_model::ShutterState::Open;
control.stop().unwrap();
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: 30_000,
is_armed: true,
clock: &clock,
};
assert!(matches!(
ctx.sleep_with_control_check(Duration::from_secs(30)),
Err(StepOutcome::Stopped)
));
assert!(clock.total_slept() < Duration::from_secs(1));
}
struct CountingBlocker {
caps: DacCapabilities,
calls: Arc<Mutex<usize>>,
arm_after: Option<(usize, SessionControl)>,
estimator: SoftwareDecayEstimator,
}
impl DacBackend for CountingBlocker {
fn dac_type(&self) -> DacType {
DacType::Custom("CountingBlocker".into())
}
fn caps(&self) -> &DacCapabilities {
&self.caps
}
fn connect(&mut self) -> DacResult<()> {
Ok(())
}
fn disconnect(&mut self) -> DacResult<()> {
Ok(())
}
fn is_connected(&self) -> bool {
true
}
fn stop(&mut self) -> DacResult<()> {
Ok(())
}
fn set_shutter(&mut self, _open: bool) -> DacResult<()> {
Ok(())
}
}
impl FifoBackend for CountingBlocker {
fn try_write_points(
&mut self,
_pps: u32,
_points: &[LaserPoint],
) -> DacResult<WriteOutcome> {
let mut calls = self.calls.lock().unwrap();
*calls += 1;
if let Some((n, control)) = &self.arm_after {
if *calls == *n {
control.arm().unwrap();
}
}
Ok(WriteOutcome::WouldBlock)
}
fn estimator(&self) -> &dyn BufferEstimator {
&self.estimator
}
}
fn step_disarmed_against_blocker(arm_after: Option<usize>) -> (StepOutcome, usize, Duration) {
let (_, mut pipeline, control, rx, metrics) = sleep_scaffold();
assert!(!control.is_armed());
let calls = Arc::new(Mutex::new(0));
let mut backend = BackendKind::Fifo(Box::new(CountingBlocker {
caps: caps(4_096),
calls: Arc::clone(&calls),
arm_after: arm_after.map(|n| (n, control.clone())),
estimator: SoftwareDecayEstimator::new(),
}));
let mut adapter = NetworkFifoAdapter::new(&backend);
let clock = FakeClock::new();
let mut shutter = crate::presentation::output_model::ShutterState::Closed;
let outcome = {
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(20),
pps: 30_000,
is_armed: false,
clock: &clock,
};
adapter.step(&mut ctx)
};
let calls = *calls.lock().unwrap();
(outcome, calls, clock.total_slept())
}
#[test]
fn disarmed_wouldblock_backs_off_and_hits_the_stall_timeout() {
let (outcome, calls, slept) = step_disarmed_against_blocker(None);
assert!(matches!(outcome, StepOutcome::Disconnected));
assert!(slept >= super::WOULDBLOCK_STALL_TIMEOUT, "slept {slept:?}");
let bound = (super::WOULDBLOCK_STALL_TIMEOUT.as_micros() / 100) as usize + 2;
assert!(calls <= bound, "{calls} writes in {slept:?}");
}
#[test]
fn arming_during_disarmed_wouldblock_returns_state_changed() {
let (outcome, calls, _) = step_disarmed_against_blocker(Some(5));
assert!(matches!(outcome, StepOutcome::StateChanged));
assert_eq!(calls, 5);
}
#[test]
fn lowered_rate_is_written_without_waiting() {
let (_, mut pipeline, control, rx, metrics) = sleep_scaffold();
let mut estimator = SoftwareDecayEstimator::new();
estimator.record_send(Instant::now() + Duration::from_secs(5), 1_000, 20_000);
let writes = Arc::new(Mutex::new(Vec::new()));
let mut backend = BackendKind::Fifo(Box::new(FakeFifo {
caps: caps(4_096),
always_block: false,
writes: Arc::clone(&writes),
shutter_calls: Arc::new(Mutex::new(Vec::new())),
estimator,
}));
let mut adapter = NetworkFifoAdapter::new(&backend);
adapter.written_pps = Some(20_000);
let clock = FakeClock::new();
let mut shutter = crate::presentation::output_model::ShutterState::Open;
{
let source = ContentSourceKind::Fifo(&mut pipeline as &mut dyn FifoContentSource);
let mut ctx = LoopCtx {
backend: &mut backend,
source,
control: &control,
control_rx: &rx,
metrics: &metrics,
shutter_open: &mut shutter,
error_sink: &mut |_| {},
target_buffer: Duration::from_millis(50),
pps: 1_000,
is_armed: true,
clock: &clock,
};
assert!(matches!(adapter.step(&mut ctx), StepOutcome::Continue));
}
assert_eq!(clock.total_slept(), Duration::ZERO);
assert_eq!(writes.lock().unwrap().as_slice(), &[16]);
assert_eq!(adapter.written_pps, Some(1_000));
}
}