merge: qbzd successor warm-up, streaming gapless hand-off, inhibit backoff
cc
This commit is contained in:
2 files changed
+218
-8
No files matched your search
@@ -59,6 +59,13 @@ pub enum DriverAction {
|
||||
/// Pre-queue this upcoming track's bytes for a gapless transition
|
||||
/// (`playback.rs:4387`).
|
||||
ArmGapless(u64),
|
||||
/// A track started (by ANY entry: API, QConnect, CLI, natural advance or
|
||||
/// a seamless hand-off) — warm its successor into the player cache now,
|
||||
/// while the whole track duration is still ahead. Fired once per current
|
||||
/// track id. Without it the daemon only warmed after a gapped advance, so
|
||||
/// every edge after a seamless one was cold and had to fit a whole-file
|
||||
/// download inside the fixed 10 s gapless window (#699 follow-up, 1 GB Pi).
|
||||
WarmSuccessor,
|
||||
/// The current track ended and there is a next playable track — run the
|
||||
/// full advance ritual (`playback.rs:4743`).
|
||||
AdvanceAndPlay,
|
||||
@@ -115,6 +122,8 @@ pub struct DriverState {
|
||||
/// Track id an `ArmGapless` already fired for, so the ticker does not
|
||||
/// re-request it every tick (`gapless_requested_for`).
|
||||
pub gapless_requested_for: u64,
|
||||
/// Track id a `WarmSuccessor` already fired for (once per current track).
|
||||
pub warmed_for: u64,
|
||||
/// ~4-tick throttle counter for the periodic QConnect report.
|
||||
pub report_tick: u64,
|
||||
/// Last track id we emitted a `ReportEdge` for (`last_reported_track_id`).
|
||||
@@ -132,6 +141,7 @@ impl DriverState {
|
||||
last: LastTick::from_event(ev),
|
||||
save_pos_tick: 0,
|
||||
gapless_requested_for: 0,
|
||||
warmed_for: 0,
|
||||
report_tick: 0,
|
||||
last_reported_track_id: ev.track_id,
|
||||
last_reported_playing: ev.is_playing,
|
||||
@@ -195,6 +205,13 @@ pub fn plan_tick(
|
||||
actions.push(DriverAction::SavePosition(ev.position));
|
||||
}
|
||||
|
||||
// 2b. Successor warm-up: a new current track is audible — warm the next
|
||||
// one once, regardless of which entry started it. Placed BEFORE the
|
||||
// seamless early-return so a hand-off warms its own successor too.
|
||||
if ev.is_playing && ev.track_id != 0 && state.warmed_for != ev.track_id {
|
||||
actions.push(DriverAction::WarmSuccessor);
|
||||
}
|
||||
|
||||
// 3. Seamless gapless transition (playback.rs:4324-4371): the engine advanced
|
||||
// to a new track WITHOUT a stop (track-id change while still playing).
|
||||
// Sync the cursor and STOP — the desktop `continue`s before every block
|
||||
@@ -301,6 +318,10 @@ pub fn advance_state(
|
||||
)
|
||||
});
|
||||
let reported = actions.iter().any(|a| matches!(a, DriverAction::ReportEdge));
|
||||
let warmed = actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, DriverAction::WarmSuccessor));
|
||||
let warmed_for = if warmed { ev.track_id } else { prev.warmed_for };
|
||||
|
||||
// save_pos_tick advances every tick — playback.rs:4305 runs before the
|
||||
// seamless `continue`.
|
||||
@@ -314,6 +335,7 @@ pub fn advance_state(
|
||||
last: LastTick::from_event(ev),
|
||||
save_pos_tick,
|
||||
gapless_requested_for: 0,
|
||||
warmed_for,
|
||||
report_tick: prev.report_tick,
|
||||
last_reported_track_id: prev.last_reported_track_id,
|
||||
last_reported_playing: prev.last_reported_playing,
|
||||
@@ -368,6 +390,7 @@ pub fn advance_state(
|
||||
last,
|
||||
save_pos_tick,
|
||||
gapless_requested_for,
|
||||
warmed_for,
|
||||
report_tick,
|
||||
last_reported_track_id,
|
||||
last_reported_playing,
|
||||
@@ -413,6 +436,9 @@ struct GaplessFetchResult {
|
||||
predecessor_id: u64,
|
||||
successor_id: u64,
|
||||
bytes: Option<Vec<u8>>,
|
||||
/// The successor was appended as an incremental CMAF stream by the
|
||||
/// player itself (`queue_gapless_streaming`); nothing left to hand over.
|
||||
streamed: bool,
|
||||
}
|
||||
|
||||
/// A completed gapless fetch is usable only while both playback and the queue
|
||||
@@ -435,6 +461,14 @@ async fn finish_gapless_fetch<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
result: GaplessFetchResult,
|
||||
) {
|
||||
if result.streamed {
|
||||
log::info!(
|
||||
"[qbzd] driver: successor {} queued as a gapless stream after {}",
|
||||
result.successor_id,
|
||||
result.predecessor_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
if result.bytes.is_none() {
|
||||
log::warn!(
|
||||
"[qbzd] driver: gapless fetch for track {} failed",
|
||||
@@ -487,6 +521,9 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
// queue. JoinSet keeps those tasks owned by the driver so shutdown can
|
||||
// abort and drain them before AppRuntime is dropped (#521 ordering).
|
||||
let mut gapless_fetches = tokio::task::JoinSet::new();
|
||||
// Successor warm-ups (cache prefetch) are best-effort downloads owned by
|
||||
// the driver for the same shutdown reason; they never block a tick.
|
||||
let mut warm_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -510,6 +547,14 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reaped = warm_tasks.join_next(), if !warm_tasks.is_empty() => {
|
||||
if let Some(Err(error)) = reaped {
|
||||
if !error.is_cancelled() {
|
||||
log::warn!("[qbzd] driver: successor warm-up task failed: {error}");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ = ticker.tick() => {}
|
||||
}
|
||||
|
||||
@@ -550,18 +595,49 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
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 bytes = task_runtime
|
||||
.core()
|
||||
let core = task_runtime.core();
|
||||
// COLD successor: append its initial CMAF buffer as an
|
||||
// incremental source (the desktop's streaming
|
||||
// hand-off) instead of materializing the whole file —
|
||||
// a Hi-Res track cannot reliably download inside the
|
||||
// 10 s window on a Pi, and the whole-file `Vec` is
|
||||
// exactly what a 1 GB box cannot afford. Cached
|
||||
// successors keep the byte path (no network at all).
|
||||
if !cached {
|
||||
match core.queue_gapless_streaming(successor_id, quality).await {
|
||||
Ok(()) => {
|
||||
return 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
DriverAction::WarmSuccessor => {
|
||||
let quality = (deps.quality)();
|
||||
let task_runtime = Arc::clone(&runtime);
|
||||
warm_tasks.spawn(async move {
|
||||
prefetch_successors(task_runtime.as_ref(), quality).await;
|
||||
});
|
||||
}
|
||||
DriverAction::PauseStopAfter => {
|
||||
// The ended track is the queue's current track (playback.rs:4708).
|
||||
let finished = queue.current;
|
||||
@@ -610,6 +686,8 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
state = advance_state(&state, &ev, &actions);
|
||||
(deps.on_tick)();
|
||||
}
|
||||
warm_tasks.abort_all();
|
||||
while warm_tasks.join_next().await.is_some() {}
|
||||
gapless_fetches.abort_all();
|
||||
while let Some(result) = gapless_fetches.join_next().await {
|
||||
if let Err(error) = result {
|
||||
@@ -686,8 +764,9 @@ pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>(
|
||||
let track_id = track.id;
|
||||
core.play_track_resolved(track_id, quality, None, None, 0)
|
||||
.await?;
|
||||
// Warm the successors so the next transition can be gapless (best-effort).
|
||||
prefetch_successors(runtime, quality).await;
|
||||
// The successor warm-up is the driver's `WarmSuccessor` action (fired on
|
||||
// the first tick that sees the new track, for EVERY entry point) — never
|
||||
// awaited here, where a full download used to stall the control loop.
|
||||
// Persist the session (queue + current + position) so a restart resumes.
|
||||
save_session_now(runtime).await;
|
||||
Ok(Some(track))
|
||||
@@ -1076,6 +1155,42 @@ mod tests {
|
||||
assert!(!a.contains(&DriverAction::AdvanceAndPlay));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_successor_fires_once_per_track_for_any_entry() {
|
||||
// A track appears playing (started by API/QConnect/CLI — the driver
|
||||
// does not care which): warm once, then stay quiet for that id.
|
||||
let e = ev(1, true, 3, 581);
|
||||
let s = DriverState::after(&ev(1, true, 2, 581));
|
||||
let queue = q(1, &[(2, true)], "off", None);
|
||||
let a1 = plan_tick(&s, &e, &queue, None);
|
||||
assert!(a1.contains(&DriverAction::WarmSuccessor));
|
||||
let s2 = advance_state(&s, &e, &a1);
|
||||
assert_eq!(s2.warmed_for, 1);
|
||||
let a2 = plan_tick(&s2, &e, &queue, None);
|
||||
assert!(!a2.contains(&DriverAction::WarmSuccessor));
|
||||
// Paused: no warm for a track that is not audible yet.
|
||||
let paused = ev(5, false, 0, 581);
|
||||
let a3 = plan_tick(&s2, &paused, &q(5, &[(6, true)], "off", None), None);
|
||||
assert!(!a3.contains(&DriverAction::WarmSuccessor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seamless_edge_warms_the_new_successor() {
|
||||
// 1 -> 2 hand-off without a stop: the cursor sync AND a warm for 2's
|
||||
// successor fire on the same tick (the #699 follow-up: every edge
|
||||
// after a seamless one used to be cold).
|
||||
let prev = ev(1, true, 580, 581);
|
||||
let mut s = DriverState::after(&prev);
|
||||
s.warmed_for = 1;
|
||||
let e = ev(2, true, 0, 300);
|
||||
let a = plan_tick(&s, &e, &q(1, &[(2, true), (3, true)], "off", None), None);
|
||||
assert!(a.contains(&DriverAction::SyncCursorTo(2)));
|
||||
assert!(a.contains(&DriverAction::WarmSuccessor));
|
||||
let s2 = advance_state(&s, &e, &a);
|
||||
assert_eq!(s2.warmed_for, 2);
|
||||
assert_eq!(s2.gapless_requested_for, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gapless_arms_exactly_once() {
|
||||
let mut e = ev(1, true, 300, 581);
|
||||
@@ -1113,6 +1228,7 @@ mod tests {
|
||||
predecessor_id: 1,
|
||||
successor_id: 2,
|
||||
bytes: None,
|
||||
streamed: false,
|
||||
};
|
||||
let mut current = ev(1, true, 575, 581);
|
||||
current.gapless_ready = true;
|
||||
@@ -1145,6 +1261,7 @@ mod tests {
|
||||
predecessor_id: 1,
|
||||
successor_id: 2,
|
||||
bytes: None,
|
||||
streamed: false,
|
||||
};
|
||||
let mut current = ev(1, true, 575, 581);
|
||||
current.gapless_ready = true;
|
||||
|
||||
@@ -13,11 +13,48 @@
|
||||
//! unlike MPRIS (session bus), so this keeps its own lazy connection.
|
||||
|
||||
use mpris_server::zbus::{self, zvariant::OwnedFd};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const LOGIN1_DEST: &str = "org.freedesktop.login1";
|
||||
const LOGIN1_PATH: &str = "/org/freedesktop/login1";
|
||||
const LOGIN1_IFACE: &str = "org.freedesktop.login1.Manager";
|
||||
|
||||
/// After a transient `Inhibit` failure, wait this long before the next try.
|
||||
/// `set_playing(true)` arrives with every playback-state update (~2 s), so
|
||||
/// without a backoff a headless box logged the same warning every 2 s (369
|
||||
/// lines in 11 min on a Pi daemon, 2026-08-29).
|
||||
const RETRY_BACKOFF: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Why the last `Inhibit` attempt did not produce a lock.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Denial {
|
||||
/// A permanent policy answer for this process (polkit wants an
|
||||
/// interactive auth we can never give — a daemon with no logind
|
||||
/// session). Never retried.
|
||||
Policy,
|
||||
/// Anything else (bus hiccup, logind restarting). Retried after
|
||||
/// `RETRY_BACKOFF`.
|
||||
Transient,
|
||||
}
|
||||
|
||||
/// Pure retry decision, so the backoff can be unit-tested without a bus.
|
||||
fn should_attempt(last: Option<(Denial, Instant)>, now: Instant) -> bool {
|
||||
match last {
|
||||
None => true,
|
||||
Some((Denial::Policy, _)) => false,
|
||||
Some((Denial::Transient, at)) => now.duration_since(at) >= RETRY_BACKOFF,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(error: &zbus::Error) -> Denial {
|
||||
let text = error.to_string();
|
||||
if text.contains("InteractiveAuthorizationRequired") || text.contains("AccessDenied") {
|
||||
Denial::Policy
|
||||
} else {
|
||||
Denial::Transient
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the inhibitor fd while playing. Owned by the MPRIS update loop
|
||||
/// (single-threaded); acquire/release are driven by playback-state updates.
|
||||
pub struct SleepInhibitor {
|
||||
@@ -27,11 +64,18 @@ pub struct SleepInhibitor {
|
||||
/// The inhibitor lock. `Some` while Playing; dropping it closes the fd,
|
||||
/// which is how logind releases the lock.
|
||||
fd: Option<OwnedFd>,
|
||||
/// Last failed attempt and why — gates the next attempt (see
|
||||
/// `should_attempt`).
|
||||
last_failure: Option<(Denial, Instant)>,
|
||||
}
|
||||
|
||||
impl SleepInhibitor {
|
||||
pub fn new() -> Self {
|
||||
Self { conn: None, fd: None }
|
||||
Self {
|
||||
conn: None,
|
||||
fd: None,
|
||||
last_failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the inhibitor from a playback transition: acquire on Playing,
|
||||
@@ -50,6 +94,9 @@ impl SleepInhibitor {
|
||||
if self.fd.is_some() {
|
||||
return;
|
||||
}
|
||||
if !should_attempt(self.last_failure, Instant::now()) {
|
||||
return;
|
||||
}
|
||||
let conn = match &self.conn {
|
||||
Some(c) => c.clone(),
|
||||
None => match zbus::Connection::system().await {
|
||||
@@ -58,7 +105,11 @@ impl SleepInhibitor {
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[inhibit] system bus unavailable, cannot inhibit sleep: {e}");
|
||||
log::warn!(
|
||||
"[inhibit] system bus unavailable, cannot inhibit sleep (retry in {}s): {e}",
|
||||
RETRY_BACKOFF.as_secs()
|
||||
);
|
||||
self.last_failure = Some((Denial::Transient, Instant::now()));
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -77,10 +128,27 @@ impl SleepInhibitor {
|
||||
Ok(fd) => {
|
||||
log::info!("[inhibit] acquired login1 sleep:idle inhibitor");
|
||||
self.fd = Some(fd);
|
||||
self.last_failure = None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[inhibit] bad Inhibit reply (expected fd): {e}");
|
||||
self.last_failure = Some((Denial::Transient, Instant::now()));
|
||||
}
|
||||
Err(e) => log::warn!("[inhibit] bad Inhibit reply (expected fd): {e}"),
|
||||
},
|
||||
Err(e) => log::warn!("[inhibit] login1 Inhibit call failed: {e}"),
|
||||
Err(e) => {
|
||||
let denial = classify(&e);
|
||||
match denial {
|
||||
Denial::Policy => log::info!(
|
||||
"[inhibit] login1 refuses a sleep:idle inhibitor for this process ({e}); \
|
||||
giving up — playback is unaffected"
|
||||
),
|
||||
Denial::Transient => log::warn!(
|
||||
"[inhibit] login1 Inhibit call failed (retry in {}s): {e}",
|
||||
RETRY_BACKOFF.as_secs()
|
||||
),
|
||||
}
|
||||
self.last_failure = Some((denial, Instant::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +159,28 @@ impl SleepInhibitor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_attempt_is_always_made() {
|
||||
assert!(should_attempt(None, Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_denial_is_never_retried() {
|
||||
let at = Instant::now();
|
||||
assert!(!should_attempt(Some((Denial::Policy, at)), at));
|
||||
assert!(!should_attempt(Some((Denial::Policy, at)), at + RETRY_BACKOFF * 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_failure_waits_out_the_backoff() {
|
||||
let at = Instant::now();
|
||||
assert!(!should_attempt(Some((Denial::Transient, at)), at));
|
||||
assert!(!should_attempt(Some((Denial::Transient, at)), at + RETRY_BACKOFF / 2));
|
||||
assert!(should_attempt(Some((Denial::Transient, at)), at + RETRY_BACKOFF));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user