fix(qconnect): enforce shared lifecycle authority

cx
This commit is contained in:
vicrodh committed 2026-08-30 22:22:08 -06:00
1 parent 74a41150e9
commit b4404f45ba
22 files changed
+3233 -406

No files matched your search

+33 -4
View File
@@ -1,10 +1,11 @@
//! The async facade both hosts drive: a [`ListenTracker`] plus a
//! [`ListenStore`], every store call inside `spawn_blocking`.
//!
//! Hosts call the five verbs (`track_started`, `tick`, `ended_naturally`,
//! `stopped`, `shutdown`) and never touch SQLite or the state machine
//! themselves. "Listening history OFF" (`paused`) is honoured HERE, in one
//! place: a paused logger opens no row, so nothing downstream has to ask.
//! Hosts call explicit lifecycle verbs (`track_started`, `tick`,
//! `ended_naturally`, `stopped`, `handoff`, `shutdown`) and never touch SQLite
//! or the state machine themselves. "Listening history OFF" (`paused`) is
//! honoured HERE, in one place: a paused logger opens no row, so nothing
//! downstream has to ask.
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -170,6 +171,14 @@ impl ListenLogger {
self.close(closed).await;
}
/// Playback authority moved away from this process. Close the owner row
/// as `handoff`; delegated tracks are intentionally never opened here.
/// Idempotent so repeated lifecycle observations cannot rewrite the row.
pub async fn handoff(&self) {
let closed = self.tracker.lock().unwrap().handoff(now_unix());
self.close(closed).await;
}
/// Orderly exit: close the row in flight as `shutdown`. SYNCHRONOUS on
/// purpose — the hosts call it after their event loop is gone.
pub fn shutdown_blocking(&self) {
@@ -382,4 +391,24 @@ mod tests {
assert_eq!(rows[0].end_reason, Some(EndReason::Natural));
assert_eq!(rows[1].end_reason, Some(EndReason::Stop));
}
#[tokio::test]
async fn handoff_closes_the_owner_row_once() {
let dir = tempfile::tempdir().unwrap();
let l = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
.await
.unwrap();
l.track_started(meta("1", 100_000), false).await;
l.tick(0, true).await;
l.tick(1_000, true).await;
l.handoff().await;
l.handoff().await;
assert!(!l.has_open_row());
let rows = l.rows().await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].end_reason, Some(EndReason::Handoff));
assert_eq!(rows[0].played_ms, 1_000);
}
}
+12 -2
View File
@@ -167,6 +167,13 @@ impl ListenTracker {
self.close_with(EndReason::Shutdown, now)
}
/// Playback authority moved to another renderer. The owner row is closed
/// explicitly and no delegated playback is folded into it. Calling this
/// more than once is harmless: after the first close there is no open row.
pub fn handoff(&mut self, now: i64) -> Option<Closed> {
self.close_with(EndReason::Handoff, now)
}
/// For hosts that only see "a different track started": was the previous
/// one within 2 s of its end? Mirrors the desktop's
/// `seen_position + 2 >= duration` (playback_driver.rs:226-264).
@@ -353,7 +360,7 @@ mod tests {
}
#[test]
fn stop_shutdown_error_reasons() {
fn stop_shutdown_error_handoff_reasons() {
let mut t = ListenTracker::new();
started(&mut t, 1);
assert_eq!(t.stopped(2, false).unwrap().reason, EndReason::Stop);
@@ -361,7 +368,10 @@ mod tests {
assert_eq!(t.shutdown(3).unwrap().reason, EndReason::Shutdown);
started(&mut t, 3);
assert_eq!(t.errored(4).unwrap().reason, EndReason::Error);
assert_eq!(t.shutdown(5), None);
started(&mut t, 4);
assert_eq!(t.handoff(5).unwrap().reason, EndReason::Handoff);
assert_eq!(t.handoff(6), None, "handoff is idempotent");
assert_eq!(t.shutdown(7), None);
}
#[test]
+367 -46
View File
@@ -19,6 +19,8 @@
// * `advance_and_play` — the full advance ritual (skip-walk → play → prefetch
// → persist), reused verbatim by the CLI next/prev routes
use std::any::Any;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
@@ -85,6 +87,18 @@ pub enum DriverAction {
QueueFinished,
}
/// Whether an action may run under the current authority. Playback/queue
/// mutation, owner-side resolution and session persistence are fenced while a
/// delegated renderer owns playback; diagnostics and renderer reports remain
/// live so the guest session stays observable.
fn action_allowed(action: &DriverAction, owner_actions_allowed: bool) -> bool {
owner_actions_allowed
|| matches!(
action,
DriverAction::LatchError(_) | DriverAction::ReportEdge
)
}
/// The previous tick's snapshot: the desktop loop's `last_track_id` /
/// `seen_position` / `was_playing` (plus duration for [`DriverState::after`]).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -422,6 +436,26 @@ pub fn advance_state(
}
}
/// Keep delegated observations useful for reports without ever promoting them
/// into the owner's carried playback state. The delegated state is discarded
/// on the first owner-admitted tick; restore then plans only from the last
/// owner snapshot captured before handoff.
fn advance_authority_state(
owner_state: &mut DriverState,
delegated_state: &mut Option<DriverState>,
ev: &PlaybackEvent,
actions: &[DriverAction],
owner_actions_allowed: bool,
) {
if owner_actions_allowed {
*owner_state = advance_state(owner_state, ev, actions);
*delegated_state = None;
} else {
let previous = delegated_state.take().unwrap_or_default();
*delegated_state = Some(advance_state(&previous, ev, actions));
}
}
/// Map the desktop `ui_prefs.streaming_quality` key to a request-layer
/// [`Quality`]. Byte-identical contract to `crates/qbz/src/ui_prefs.rs:823`
/// (`streaming_quality_for_key`), replicated here because the desktop crate is
@@ -445,6 +479,19 @@ pub fn quality_from_key(key: &str) -> Quality {
pub struct DriverDeps {
/// Resolve the streaming quality at play time (qbzd passes the daemon prefs).
pub quality: Arc<dyn Fn() -> Quality + Send + Sync>,
/// True only while the daemon's owner authority may mutate/resolve/persist.
/// Delegated playback keeps the driver alive for diagnostics and reports,
/// but fences every owner-side action.
pub owner_actions_allowed: Arc<dyn Fn() -> bool + Send + Sync>,
/// Atomically classify authority before reading player/queue state. An
/// owner result carries the exact generation and a drain-visible permit;
/// delegated ticks remain report-only, while fenced ticks retry unread.
pub observe_authority: Arc<dyn Fn() -> DriverAuthorityObservation + Send + Sync>,
/// Re-admit a spawned continuation only under the exact owner generation
/// that produced it. A guest round-trip must never promote stale work into
/// a later owner merely because owner actions are allowed again.
pub readmit_owner_action:
Arc<dyn Fn(&DriverOwnerToken) -> Option<DriverActionPermit> + Send + Sync>,
/// Report-edge signal (T10 wires the QConnect renderer report).
pub on_edge: Arc<dyn Fn() + Send + Sync>,
/// Latch a drained error under a category ("stream" | "transport" | "auth").
@@ -453,6 +500,71 @@ pub struct DriverDeps {
pub on_tick: Arc<dyn Fn() + Send + Sync>,
}
/// Type-erased RAII permit supplied by the host's authority coordinator.
/// `qbz-app` needs only the drop boundary; it deliberately knows nothing about
/// the daemon's concrete authority stamps.
pub struct DriverActionPermit {
_inner: Box<dyn Send + Sync>,
}
/// Type-erased exact owner token supplied by the host authority coordinator.
/// The concrete token remains opaque to `qbz-app`; only the host callback can
/// downcast and validate it.
#[derive(Clone)]
pub struct DriverOwnerToken(Arc<dyn Any + Send + Sync>);
impl DriverOwnerToken {
pub fn new<T: Send + Sync + 'static>(token: T) -> Self {
Self(Arc::new(token))
}
pub fn downcast_ref<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.0.downcast_ref::<T>()
}
}
impl fmt::Debug for DriverOwnerToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("DriverOwnerToken(..)")
}
}
/// One exact owner observation plus the live permit covering the tick input.
pub struct DriverOwnerAdmission {
token: DriverOwnerToken,
_permit: DriverActionPermit,
}
impl DriverOwnerAdmission {
pub fn new(token: DriverOwnerToken, permit: DriverActionPermit) -> Self {
Self {
token,
_permit: permit,
}
}
fn token(&self) -> DriverOwnerToken {
self.token.clone()
}
}
/// Host classification captured before a driver tick reads player or queue.
/// A lifecycle fence is distinct from delegated playback: fenced ticks retry
/// without reading or altering either authority's carried state.
pub enum DriverAuthorityObservation {
Owner(DriverOwnerAdmission),
Delegated,
Fenced,
}
impl DriverActionPermit {
pub fn new<T: Send + Sync + 'static>(permit: T) -> Self {
Self {
_inner: Box::new(permit),
}
}
}
/// Result of a gapless byte fetch that ran outside the 450 ms control loop.
/// Keeping the predecessor alongside the successor lets the loop reject a
/// late download after a manual track/queue change instead of appending it to
@@ -539,6 +651,7 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let mut state = DriverState::default();
let mut delegated_state: Option<DriverState> = None;
let mut ticker = tokio::time::interval(Duration::from_millis(TICK_MS));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// Gapless downloads may outlive the fixed ten-second arm window, but they
@@ -561,7 +674,13 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
}
completed = gapless_fetches.join_next(), if !gapless_fetches.is_empty() => {
match completed {
Some(Ok(result)) => finish_gapless_fetch(&runtime, result).await,
Some(Ok((owner_token, result))) => {
if let Some(_permit) = (deps.readmit_owner_action)(&owner_token) {
finish_gapless_fetch(&runtime, result).await;
} else {
log::debug!("[qbzd] driver: discarded gapless result from a stale owner authority");
}
}
Some(Err(error)) if error.is_cancelled() => {
log::debug!("[qbzd] driver: stale gapless fetch cancelled");
}
@@ -583,6 +702,25 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
_ = ticker.tick() => {}
}
// Capture owner admission BEFORE the first player/queue read. If this
// tick begins under guest authority it remains guest-only even when an
// owner restore completes during `queue_snapshot().await`.
let owner_admission = match (deps.observe_authority)() {
DriverAuthorityObservation::Owner(admission) => Some(admission),
DriverAuthorityObservation::Delegated => None,
DriverAuthorityObservation::Fenced => {
// The transition fence is waiting for every exact permit held
// by these network jobs. Abort them on the first fenced tick,
// not only after delegated authority is already installed, or
// a slow prefetch could stretch handoff to the stream timeout.
gapless_fetches.abort_all();
warm_tasks.abort_all();
continue;
}
};
let owner_token = owner_admission
.as_ref()
.map(DriverOwnerAdmission::token);
let core = runtime.core();
let player = core.player();
let ev = player.get_playback_event();
@@ -590,7 +728,30 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
let stream_error = player.state.take_stream_error_message();
let queue = queue_snapshot(core).await;
let actions = plan_tick(&state, &ev, &queue, stream_error.as_deref());
let owner_actions_allowed = owner_admission.is_some();
if owner_actions_allowed && delegated_state.is_some() {
// Rebase on the first fully owner-admitted observation after a
// delegated span. Durable player generations may have advanced
// while the guest was active; comparing those guest edges with the
// pre-handoff owner snapshot could synthesize an owner auto-advance.
state = DriverState::after(&ev);
delegated_state = None;
}
let delegated_baseline = DriverState::default();
let planning_state = if owner_actions_allowed {
&state
} else {
delegated_state.as_ref().unwrap_or(&delegated_baseline)
};
let actions = plan_tick(planning_state, &ev, &queue, stream_error.as_deref());
// Authority can change while an owner prefetch is in flight. Cancel
// those best-effort jobs at the first observed delegated tick; their
// completion paths are fenced independently above.
if !owner_actions_allowed {
gapless_fetches.abort_all();
warm_tasks.abort_all();
}
// Once the engine/queue edge has moved, an outstanding fetch belongs
// to the old predecessor. Drop its network future immediately so it
@@ -607,7 +768,12 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
gapless_fetches.abort_all();
}
let mut executed_actions = Vec::with_capacity(actions.len());
for action in &actions {
if !action_allowed(action, owner_actions_allowed) {
continue;
}
executed_actions.push(action.clone());
match action {
DriverAction::SyncCursorTo(id) => {
core.sync_current_to_id(*id).await;
@@ -617,11 +783,28 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
let predecessor_id = ev.track_id;
let successor_id = *id;
let task_runtime = Arc::clone(&runtime);
let task_owner_actions_allowed = Arc::clone(&deps.owner_actions_allowed);
let task_readmit_owner_action = Arc::clone(&deps.readmit_owner_action);
let task_owner_token = owner_token
.as_ref()
.expect("owner action admitted without an owner token")
.clone();
log::info!(
"[qbzd] driver: fetching gapless track {successor_id} after {predecessor_id}"
);
let cached = player.is_track_cached(successor_id);
gapless_fetches.spawn(async move {
let Some(_permit) = task_readmit_owner_action(&task_owner_token) else {
return (
task_owner_token,
GaplessFetchResult {
predecessor_id,
successor_id,
bytes: None,
streamed: false,
},
);
};
let core = task_runtime.core();
// COLD successor: append its initial CMAF buffer as an
// incremental source (the desktop's streaming
@@ -633,34 +816,57 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
if !cached {
match core.queue_gapless_streaming(successor_id, quality).await {
Ok(()) => {
return GaplessFetchResult {
predecessor_id,
successor_id,
bytes: None,
streamed: true,
}
return (
task_owner_token,
GaplessFetchResult {
predecessor_id,
successor_id,
bytes: None,
streamed: true,
},
)
}
Err(error) => log::warn!(
"[qbzd] driver: gapless stream setup for {successor_id} failed: {error}; trying byte fallback"
),
}
}
let bytes = core
.fetch_for_gapless_resolved(successor_id, quality, None, None)
.await;
GaplessFetchResult {
predecessor_id,
successor_id,
bytes,
streamed: false,
}
let bytes = if task_owner_actions_allowed() {
core.fetch_for_gapless_resolved(successor_id, quality, None, None)
.await
} else {
None
};
(
task_owner_token,
GaplessFetchResult {
predecessor_id,
successor_id,
bytes,
streamed: false,
},
)
});
}
DriverAction::WarmSuccessor => {
let quality = (deps.quality)();
let task_runtime = Arc::clone(&runtime);
let task_owner_actions_allowed = Arc::clone(&deps.owner_actions_allowed);
let task_readmit_owner_action = Arc::clone(&deps.readmit_owner_action);
let task_owner_token = owner_token
.as_ref()
.expect("owner action admitted without an owner token")
.clone();
warm_tasks.spawn(async move {
prefetch_successors(task_runtime.as_ref(), quality).await;
let Some(_permit) = task_readmit_owner_action(&task_owner_token) else {
return;
};
prefetch_successors(
task_runtime.as_ref(),
quality,
task_owner_actions_allowed.as_ref(),
)
.await;
});
}
DriverAction::PauseStopAfter => {
@@ -708,7 +914,13 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
}
}
state = advance_state(&state, &ev, &actions);
advance_authority_state(
&mut state,
&mut delegated_state,
&ev,
&executed_actions,
owner_actions_allowed,
);
(deps.on_tick)();
}
warm_tasks.abort_all();
@@ -803,7 +1015,11 @@ pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>(
async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
runtime: &AppRuntime<A>,
quality: Quality,
owner_actions_allowed: &(dyn Fn() -> bool + Send + Sync),
) {
if !owner_actions_allowed() {
return;
}
// Memory-pressure watchdog: critical pressure latched a halt — do not
// start a new download (issue #660).
if crate::memory_watchdog::prefetch_halted() {
@@ -830,6 +1046,9 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
if player.is_track_cached(next.id) {
return;
}
if !owner_actions_allowed() {
return;
}
let client_lock = core.client();
let guard = client_lock.read().await;
let Some(client) = guard.as_ref() else {
@@ -842,6 +1061,9 @@ async fn prefetch_successors<A: FrontendAdapter + Send + Sync + 'static>(
let allow_hires =
profile.allow_hires_prefetch && !crate::memory_watchdog::hires_prefetch_paused();
let prefetch_quality = cap_prefetch_quality(quality, allow_hires);
if !owner_actions_allowed() {
return;
}
if let Err(e) = player
.prefetch_into_cache(client, next.id, prefetch_quality)
.await
@@ -891,9 +1113,7 @@ async fn queue_snapshot<A: FrontendAdapter + Send + Sync + 'static>(
/// `None`). Mirrors `crates/qbz/src/session_persist.rs::capture_and_save`, minus
/// the desktop-only `persist_session` gate (the daemon's store IS its queue
/// persistence, so it always saves).
pub async fn save_session_now<A: FrontendAdapter + Send + Sync + 'static>(
runtime: &AppRuntime<A>,
) {
pub async fn save_session_now<A: FrontendAdapter + Send + Sync + 'static>(runtime: &AppRuntime<A>) {
let core = runtime.core();
let (tracks, current_index) = core.get_all_queue_tracks().await;
let full = core.get_queue_state_full().await;
@@ -1073,10 +1293,103 @@ mod tests {
}
}
#[test]
fn delegated_authority_fences_every_owner_action() {
let owner_actions = [
DriverAction::SyncCursorTo(2),
DriverAction::ArmGapless(2),
DriverAction::WarmSuccessor,
DriverAction::AdvanceAndPlay,
DriverAction::PauseStopAfter,
DriverAction::SavePosition(17),
DriverAction::QueueFinished,
];
for action in &owner_actions {
assert!(action_allowed(action, true), "owner rejected {action:?}");
assert!(
!action_allowed(action, false),
"delegated authority accepted {action:?}"
);
}
for action in [
DriverAction::LatchError("stream failed".to_string()),
DriverAction::ReportEdge,
] {
assert!(action_allowed(&action, true));
assert!(action_allowed(&action, false));
}
}
#[test]
fn delegated_filter_keeps_diagnostics_in_planned_order() {
let state = DriverState::after(&ev(1, true, 580, 581));
let planned = plan_tick(
&state,
&ev(1, false, 581, 581),
&q(1, &[(2, true)], "off", None),
Some("stream failed"),
);
assert!(planned.contains(&DriverAction::AdvanceAndPlay));
let executable: Vec<_> = planned
.into_iter()
.filter(|action| action_allowed(action, false))
.collect();
assert_eq!(
executable,
vec![
DriverAction::LatchError("stream failed".to_string()),
DriverAction::ReportEdge,
]
);
}
#[test]
fn delegated_snapshots_never_replace_owner_driver_state() {
let owner_event = ev(11, true, 42, 300);
let mut owner_state = DriverState::after(&owner_event);
let original_owner_state = owner_state.clone();
let mut delegated_state = None;
let guest_event = ev(99, true, 120, 240);
advance_authority_state(
&mut owner_state,
&mut delegated_state,
&guest_event,
&[DriverAction::ReportEdge],
false,
);
assert_eq!(owner_state, original_owner_state);
assert_eq!(
delegated_state.as_ref().map(|state| state.last.track_id),
Some(99)
);
let restored_event = ev(11, true, 43, 300);
advance_authority_state(
&mut owner_state,
&mut delegated_state,
&restored_event,
&[],
true,
);
assert_eq!(owner_state.last.track_id, 11);
assert_eq!(owner_state.last.position, 43);
assert!(delegated_state.is_none());
}
#[test]
fn end_edge_advances() {
let s = DriverState::after(&ev(1, true, 580, 581));
let a = plan_tick(&s, &ev(1, false, 581, 581), &q(1, &[(2, true)], "off", None), None);
let a = plan_tick(
&s,
&ev(1, false, 581, 581),
&q(1, &[(2, true)], "off", None),
None,
);
assert!(a.contains(&DriverAction::AdvanceAndPlay));
assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge
}
@@ -1091,12 +1404,7 @@ mod tests {
current.engine_empty_generation = 1;
current.engine_empty_track_id = 1;
let actions = plan_tick(
&s,
&current,
&q(1, &[(2, true)], "off", None),
None,
);
let actions = plan_tick(&s, &current, &q(1, &[(2, true)], "off", None), None);
assert!(actions.contains(&DriverAction::AdvanceAndPlay));
}
@@ -1165,7 +1473,12 @@ mod tests {
#[test]
fn mid_track_pause_does_not_advance() {
let s = DriverState::after(&ev(1, true, 100, 581));
let a = plan_tick(&s, &ev(1, false, 100, 581), &q(1, &[(2, true)], "off", None), None);
let a = plan_tick(
&s,
&ev(1, false, 100, 581),
&q(1, &[(2, true)], "off", None),
None,
);
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
assert!(a.contains(&DriverAction::ReportEdge));
}
@@ -1179,12 +1492,7 @@ mod tests {
let mut underrun = ready.clone();
underrun.buffer_state = PlaybackBufferState::Underrun;
let actions = plan_tick(
&state,
&underrun,
&q(41, &[(42, true)], "off", None),
None,
);
let actions = plan_tick(&state, &underrun, &q(41, &[(42, true)], "off", None), None);
assert!(actions.contains(&DriverAction::ReportEdge));
}
@@ -1257,12 +1565,7 @@ mod tests {
let mut current = ev(1, false, 581, 581);
current.gapless_ready = true;
let previous = DriverState::after(&ev(1, true, 580, 581));
let actions = plan_tick(
&previous,
&current,
&q(1, &[(2, true)], "off", None),
None,
);
let actions = plan_tick(&previous, &current, &q(1, &[(2, true)], "off", None), None);
assert!(!actions
.iter()
.any(|action| matches!(action, DriverAction::ArmGapless(_))));
@@ -1342,13 +1645,21 @@ mod tests {
#[test]
fn queue_finished_when_nothing_playable() {
let s = DriverState::after(&ev(1, true, 580, 581));
let a = plan_tick(&s, &ev(1, false, 581, 581), &q(1, &[(2, false)], "off", None), None);
let a = plan_tick(
&s,
&ev(1, false, 581, 581),
&q(1, &[(2, false)], "off", None),
None,
);
assert!(a.contains(&DriverAction::QueueFinished));
}
#[test]
fn skip_walk_bounds() {
assert_eq!(next_playable(&[(2, false), (3, false), (4, true)], 50), Some((2, 4)));
assert_eq!(
next_playable(&[(2, false), (3, false), (4, true)], 50),
Some((2, 4))
);
let all_bad: Vec<(u64, bool)> = (0..60).map(|i| (i, false)).collect();
assert_eq!(next_playable(&all_bad, 50), None); // bounded — never walks forever
}
@@ -1371,7 +1682,12 @@ mod tests {
#[test]
fn seamless_gapless_transition_syncs_cursor() {
let s = DriverState::after(&ev(1, true, 580, 581));
let a = plan_tick(&s, &ev(2, true, 0, 547), &q(1, &[(2, true)], "off", None), None);
let a = plan_tick(
&s,
&ev(2, true, 0, 547),
&q(1, &[(2, true)], "off", None),
None,
);
assert!(a.contains(&DriverAction::SyncCursorTo(2)));
}
@@ -1390,7 +1706,12 @@ mod tests {
#[test]
fn duration_zero_never_advances() {
let s = DriverState::after(&ev(1, true, 580, 581));
let a = plan_tick(&s, &ev(1, false, 580, 0), &q(1, &[(2, true)], "off", None), None);
let a = plan_tick(
&s,
&ev(1, false, 580, 0),
&q(1, &[(2, true)], "off", None),
None,
);
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
assert!(a.contains(&DriverAction::ReportEdge)); // play-state edge
}