Files
proton-stream/crates/pstr-player/src/cursor.rs
T
2026-08-02 19:37:03 +02:00

284 lines
9.6 KiB
Rust

//! The read/seek state behind one open `pstr://` URL.
//!
//! Deliberately a plain safe type with no mpv in it. Everything mpv's stream
//! callbacks actually *do* — advance a position, clamp a seek, turn a short read
//! into an EOF, honour a cancellation — lives here and is unit-tested against an
//! in-memory block source. [`crate::protocol`] is then only pointer plumbing,
//! which is the part that has to be read rather than tested.
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pstr_stream::VideoStream;
use tokio::runtime::Runtime;
use tokio::sync::Notify;
/// What mpv's callbacks return on failure.
///
/// `read_fn` documents -1; `seek_fn` and `size_fn` want an `MPV_ERROR_*`.
pub(crate) const READ_ERROR: i64 = -1;
pub(crate) const SEEK_ERROR: i64 = libmpv2::mpv_error::Generic as i64;
pub(crate) const SIZE_UNKNOWN: i64 = libmpv2::mpv_error::Unsupported as i64;
/// One-way "this stream is going away".
///
/// mpv calls `cancel_fn` from a thread other than the demuxer thread, precisely
/// so it can interrupt a read that is already blocked. Without it, tearing down
/// a player — closing the window, playing something else — waits out whatever
/// block fetch was in flight, which is seconds over a cold network.
///
/// It latches, and nothing un-latches it. That matches mpv's own model: the
/// cancellation is tied to the stream instance's lifetime, not to an individual
/// operation, and `close_fn` follows it. A cursor that tried to re-arm itself
/// would be guessing at a contract mpv does not offer.
#[derive(Default)]
pub(crate) struct CancelLatch {
flagged: AtomicBool,
notify: Notify,
}
impl CancelLatch {
pub(crate) fn cancel(&self) {
self.flagged.store(true, Ordering::Release);
// `notify_one`, not `notify_waiters`: it leaves a permit behind when no
// one is waiting yet, which closes the window between a reader checking
// the flag and actually parking on the notify.
self.notify.notify_one();
}
pub(crate) fn is_cancelled(&self) -> bool {
self.flagged.load(Ordering::Acquire)
}
async fn cancelled(&self) {
self.notify.notified().await;
}
}
/// A position inside one [`VideoStream`], plus the runtime its reads run on.
pub(crate) struct StreamCursor {
stream: VideoStream,
/// mpv's demuxer thread is not a tokio thread, so it can block on this. The
/// whole runtime is held rather than a `Handle` because a read arriving
/// after the runtime shut down would panic, and mpv may well outlive the
/// code that built it.
runtime: Arc<Runtime>,
size: u64,
pos: u64,
}
impl StreamCursor {
pub(crate) fn new(stream: VideoStream, runtime: Arc<Runtime>) -> Self {
let size = stream.size();
Self {
stream,
runtime,
size,
pos: 0,
}
}
/// Total size, or [`SIZE_UNKNOWN`] if it does not fit mpv's `i64`.
pub(crate) fn size(&self) -> i64 {
i64::try_from(self.size).unwrap_or(SIZE_UNKNOWN)
}
/// Where the next read starts. mpv tracks this itself, so nothing outside
/// the tests asks.
#[cfg(test)]
pub(crate) fn position(&self) -> u64 {
self.pos
}
/// Move to an absolute offset, returning the new one.
///
/// Seeking exactly to EOF is allowed — that is how a demuxer asks for the
/// end — and reads there return 0. Past it is an error rather than a clamp,
/// so a mistake upstream shows up as a failure instead of as bytes from the
/// wrong place.
pub(crate) fn seek(&mut self, offset: i64) -> i64 {
let Ok(target) = u64::try_from(offset) else {
return SEEK_ERROR;
};
if target > self.size {
return SEEK_ERROR;
}
self.pos = target;
offset
}
/// Fill `buf` from the current position, advancing it.
///
/// Returns the byte count, 0 at EOF, or [`READ_ERROR`]. A read is allowed to
/// come up short — mpv treats that as "call again", which is exactly right
/// when the range spanned a block boundary.
pub(crate) fn read(&mut self, buf: &mut [u8], cancel: &CancelLatch) -> i64 {
if cancel.is_cancelled() {
return READ_ERROR;
}
if buf.is_empty() || self.pos >= self.size {
return 0;
}
// Named up front so the future below borrows these and not `self`,
// which `buf` is already borrowed out of.
let stream = &self.stream;
let pos = self.pos;
let outcome = self.runtime.block_on(async {
tokio::select! {
// Biased so an already-cancelled stream loses no time to a
// fetch nobody will use.
biased;
() = cancel.cancelled() => None,
read = stream.read_at(pos, buf) => Some(read),
}
});
match outcome {
None => READ_ERROR,
Some(Err(error)) => {
tracing::warn!(%error, offset = pos, "stream read failed");
READ_ERROR
}
Some(Ok(read)) => {
self.pos += read as u64;
read as i64
}
}
}
/// [`Self::read`] with a panic in the layers below turned into a read
/// error.
///
/// `extern "C"` aborts the process on unwind, so a bug anywhere under
/// `read_at` would take the whole app down mid-episode. Failing the stream
/// instead lets the app say so and keep its window.
pub(crate) fn read_caught(&mut self, buf: &mut [u8], cancel: &CancelLatch) -> i64 {
std::panic::catch_unwind(AssertUnwindSafe(|| self.read(buf, cancel))).unwrap_or_else(|_| {
tracing::error!("panic while reading a stream; failing it");
READ_ERROR
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{CONTENT_BLOCKS, test_runtime, test_stream};
fn cursor() -> (StreamCursor, CancelLatch) {
let runtime = test_runtime();
let stream = test_stream(&runtime);
(StreamCursor::new(stream, runtime), CancelLatch::default())
}
fn total() -> u64 {
CONTENT_BLOCKS.iter().sum::<usize>() as u64
}
#[test]
fn the_cursor_reports_the_whole_revision_size() {
let (cursor, _) = cursor();
assert_eq!(cursor.size(), total() as i64);
}
#[test]
fn sequential_reads_walk_the_file_and_stop_at_eof() {
let (mut cursor, cancel) = cursor();
let mut got = Vec::new();
let mut buf = [0_u8; 700];
loop {
let read = cursor.read(&mut buf, &cancel);
assert!(read >= 0, "read failed at {}", cursor.position());
if read == 0 {
break;
}
got.extend_from_slice(&buf[..read as usize]);
}
assert_eq!(got.len() as u64, total());
assert!(got.iter().enumerate().all(|(i, b)| *b == (i % 251) as u8));
assert_eq!(cursor.position(), total());
}
#[test]
fn a_seek_moves_where_the_next_read_starts() {
let (mut cursor, cancel) = cursor();
let offset = 5_000_i64;
assert_eq!(cursor.seek(offset), offset);
let mut buf = [0_u8; 16];
let read = cursor.read(&mut buf, &cancel);
assert_eq!(read, 16);
assert_eq!(buf[0], (offset as usize % 251) as u8);
assert_eq!(cursor.position(), offset as u64 + 16);
}
#[test]
fn a_read_crossing_a_block_boundary_is_allowed_to_come_up_short() {
// mpv reads again rather than treating a short read as EOF, so this is
// not a bug to paper over — but the bytes it does return must be right.
let (mut cursor, cancel) = cursor();
let boundary = CONTENT_BLOCKS[0] as i64;
cursor.seek(boundary - 8);
let mut buf = [0_u8; 64];
let read = cursor.read(&mut buf, &cancel);
assert!(read > 0 && read <= 64);
let start = boundary as usize - 8;
assert!(
buf[..read as usize]
.iter()
.enumerate()
.all(|(i, b)| *b == ((start + i) % 251) as u8)
);
}
#[test]
fn seeking_exactly_to_eof_is_allowed_and_reads_zero() {
let (mut cursor, cancel) = cursor();
let end = total() as i64;
assert_eq!(cursor.seek(end), end);
let mut buf = [0_u8; 16];
assert_eq!(cursor.read(&mut buf, &cancel), 0);
}
#[test]
fn seeking_past_the_end_or_before_the_start_is_an_error() {
let (mut cursor, _) = cursor();
assert_eq!(cursor.seek(total() as i64 + 1), SEEK_ERROR);
assert_eq!(cursor.seek(-1), SEEK_ERROR);
// And the position is untouched by a refused seek.
assert_eq!(cursor.position(), 0);
}
#[test]
fn mpvs_seekability_probe_to_zero_succeeds() {
// mpv issues a seek to 0 immediately after open purely to find out
// whether the stream is seekable. Failing it would cost every seek bar.
let (mut cursor, _) = cursor();
assert_eq!(cursor.seek(0), 0);
}
#[test]
fn a_cancelled_stream_fails_its_reads_instead_of_serving_them() {
let (mut cursor, cancel) = cursor();
cancel.cancel();
let mut buf = [0_u8; 16];
assert_eq!(cursor.read(&mut buf, &cancel), READ_ERROR);
// And stays cancelled — the latch is the stream's lifetime, not one op.
assert_eq!(cursor.read(&mut buf, &cancel), READ_ERROR);
}
#[test]
fn an_empty_buffer_reads_nothing_rather_than_failing() {
let (mut cursor, cancel) = cursor();
assert_eq!(cursor.read(&mut [], &cancel), 0);
}
}