Skip to main content

koan_core/audio/
streaming.rs

1//! `PartialFileSource` — a Read+Seek adapter over a file that is still downloading.
2//!
3//! The download thread writes the track to a `.part` file and publishes how far
4//! it has got in an `AtomicU64`; the Symphonia decoder reads the same file
5//! through a `PartialFileSource`, which blocks when the read position catches
6//! up to the write head. Playback starts long before the transfer finishes and
7//! seeking anywhere below the write head costs a `lseek`.
8//!
9//! Nothing is copied. An earlier design pumped the file into a shared `Vec<u8>`
10//! so the decoder could read from memory, which cost as much RAM as the track
11//! was long — half a gigabyte for a nine-hour recording, held for as long as it
12//! played. The bytes are already on disk; the page cache is better at this.
13//!
14//! The open descriptor survives the download's final rename from `.part` to its
15//! cache path, so a transfer landing mid-playback changes nothing for a reader.
16
17use std::fs::File;
18use std::io::{self, Read, Seek, SeekFrom};
19use std::path::Path;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24/// Longest a read may block waiting for bytes before the transfer counts as
25/// dead. A download that stops advancing must surface as an error, not park the
26/// decode thread forever holding the ring buffer producer.
27const STALL_LIMIT: Duration = Duration::from_secs(30);
28
29/// How long to wait between checks for bytes that have not landed yet.
30const POLL_INTERVAL: Duration = Duration::from_millis(10);
31
32/// Where a download has got to, as the source needs to know it.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum StreamStatus {
35    /// Bytes are still arriving.
36    Downloading,
37    /// Every byte landed. Reads past the end are a clean EOF.
38    Complete,
39    /// The transfer died before delivering everything. Reads past the written
40    /// bytes fail rather than reporting EOF, which would silently truncate the
41    /// track and look like a short file.
42    Failed,
43}
44
45/// A `Read + Seek` view of a file that is still being written.
46pub struct PartialFileSource {
47    file: File,
48    pos: u64,
49    /// How many bytes the download has committed to disk so far.
50    bytes_written: Arc<AtomicU64>,
51    /// Total expected length, or 0 when the server sent no Content-Length.
52    total: u64,
53    status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
54    stall_limit: Duration,
55    /// Whether a read may wait at the write head for more of the download.
56    ///
57    /// Playback waits; probing does not. A container asked to describe itself
58    /// reads whatever it needs to, and Ogg needs its last page — so a probe
59    /// that waits waits for the whole transfer. Refusing instead turns that
60    /// into an immediate answer of "not from what has arrived", which is
61    /// something the caller can act on.
62    wait_for_bytes: bool,
63    /// Whether to state the advertised length.
64    ///
65    /// Saying nothing is what stops a container going looking for its tail:
66    /// Ogg reads its final page only when the source claims both a length and
67    /// seekability. The cost is that it then cannot seek at all, so this is for
68    /// the second attempt at opening a partial file, once the first has shown
69    /// the container will not describe itself from the front.
70    advertise_len: bool,
71}
72
73/// How much a probe may claim, and how far it may read.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ProbeMode {
76    /// State the length. A container that can describe itself from the bytes
77    /// already downloaded does, and the result is fully seekable.
78    Full,
79    /// State no length, so a container that would go looking for its tail
80    /// settles for what it can read from the front. Opens immediately at the
81    /// cost of seeking, and of any duration only the tail could give.
82    Lengthless,
83}
84
85impl PartialFileSource {
86    /// Open `path` for playback: reads wait at the write head for the download
87    /// to catch up. `bytes_written` is the download's own counter and `total`
88    /// its advertised length, 0 when it sent none.
89    ///
90    /// `mode` must be whatever the probe settled on. A container opened without
91    /// a length to describe itself has to be decoded without one too — given a
92    /// length it goes looking for its tail all over again, and this time on the
93    /// decode thread, where the cost is silence rather than a busy player.
94    pub fn open(
95        path: &Path,
96        bytes_written: Arc<AtomicU64>,
97        total: u64,
98        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
99        mode: ProbeMode,
100    ) -> io::Result<Self> {
101        let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
102        source.advertise_len = mode == ProbeMode::Full;
103        Ok(source)
104    }
105
106    /// Open for a probe: never waits at the write head, so a container that
107    /// cannot describe itself from what has arrived says so at once.
108    pub fn open_for_probe(
109        path: &Path,
110        bytes_written: Arc<AtomicU64>,
111        total: u64,
112        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
113        mode: ProbeMode,
114    ) -> io::Result<Self> {
115        let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
116        source.wait_for_bytes = false;
117        source.advertise_len = mode == ProbeMode::Full;
118        Ok(source)
119    }
120
121    fn with_stall_limit(
122        path: &Path,
123        bytes_written: Arc<AtomicU64>,
124        total: u64,
125        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
126        stall_limit: Duration,
127    ) -> io::Result<Self> {
128        Ok(Self {
129            file: File::open(path)?,
130            pos: 0,
131            bytes_written,
132            total,
133            status,
134            stall_limit,
135            wait_for_bytes: true,
136            advertise_len: true,
137        })
138    }
139
140    /// Bytes known to be readable — what the download has written, or the whole
141    /// file once it has landed.
142    fn available(&self) -> u64 {
143        let written = self.bytes_written.load(Ordering::Acquire);
144        match (self.status)() {
145            StreamStatus::Complete => self.file.metadata().map(|m| m.len()).unwrap_or(written),
146            _ => written,
147        }
148    }
149
150    /// Read straight from the file, tolerating a short read at the write head:
151    /// `bytes_written` is published by the downloader as it goes and the data
152    /// behind it can lag by a moment.
153    fn read_available(&mut self, buf: &mut [u8], limit: u64) -> io::Result<usize> {
154        let to_read = (limit as usize).min(buf.len());
155        self.file.read(&mut buf[..to_read]).inspect(|n| {
156            self.pos += *n as u64;
157        })
158    }
159}
160
161impl Read for PartialFileSource {
162    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
163        if buf.is_empty() {
164            return Ok(0);
165        }
166
167        let deadline = Instant::now() + self.stall_limit;
168        loop {
169            let available = self.available();
170            if available > self.pos {
171                let n = self.read_available(buf, available - self.pos)?;
172                if n > 0 {
173                    return Ok(n);
174                }
175                // The counter ran ahead of what is visible on disk. Fall
176                // through and wait rather than reporting a false EOF.
177            }
178
179            match (self.status)() {
180                StreamStatus::Failed => {
181                    return Err(io::Error::new(
182                        io::ErrorKind::BrokenPipe,
183                        "stream download failed before delivering the whole track",
184                    ));
185                }
186                // Everything landed and there is nothing past `pos`: real EOF.
187                StreamStatus::Complete if available <= self.pos => return Ok(0),
188                StreamStatus::Complete => {}
189                StreamStatus::Downloading => {
190                    // A server that sent a Content-Length has delivered it all.
191                    if self.total > 0 && available >= self.total && self.pos >= self.total {
192                        return Ok(0);
193                    }
194                }
195            }
196
197            if !self.wait_for_bytes {
198                return Err(io::Error::new(
199                    io::ErrorKind::UnexpectedEof,
200                    "past what the download has delivered",
201                ));
202            }
203            if Instant::now() >= deadline {
204                return Err(io::Error::new(
205                    io::ErrorKind::TimedOut,
206                    "stream download stalled",
207                ));
208            }
209            std::thread::sleep(POLL_INTERVAL);
210        }
211    }
212}
213
214impl Seek for PartialFileSource {
215    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
216        let target: i64 = match pos {
217            SeekFrom::Start(n) => n as i64,
218            SeekFrom::Current(n) => self.pos as i64 + n,
219            // Seeking relative to an end the download has not reached yet is
220            // guesswork; the advertised length is the best answer there is.
221            SeekFrom::End(n) => {
222                let len = if self.total > 0 {
223                    self.total
224                } else {
225                    self.available()
226                };
227                len as i64 + n
228            }
229        };
230
231        if target < 0 {
232            return Err(io::Error::new(
233                io::ErrorKind::InvalidInput,
234                "seek before beginning of stream",
235            ));
236        }
237
238        self.pos = self.file.seek(SeekFrom::Start(target as u64))?;
239        Ok(self.pos)
240    }
241}
242
243// Symphonia requires MediaSource: Read + Seek + Send + Any
244impl symphonia::core::io::MediaSource for PartialFileSource {
245    fn is_seekable(&self) -> bool {
246        // Backward seeks and forward seeks below the write head are a `lseek`
247        // on a file that is already there. A forward seek past it lands on a
248        // read that blocks until the bytes arrive, which is the honest
249        // behaviour — callers clamp to `seekable_ms` to avoid asking.
250        true
251    }
252
253    fn byte_len(&self) -> Option<u64> {
254        (self.advertise_len && self.total > 0).then_some(self.total)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use std::io::Write;
261    use std::sync::atomic::AtomicU8;
262
263    use symphonia::core::io::MediaSource;
264
265    use super::*;
266
267    /// A file plus the counter and status a download would publish, so a test
268    /// can advance either independently.
269    struct Fixture {
270        _dir: tempfile::TempDir,
271        path: std::path::PathBuf,
272        written: Arc<AtomicU64>,
273        status: Arc<AtomicU8>,
274    }
275
276    const DOWNLOADING: u8 = 0;
277    const COMPLETE: u8 = 1;
278    const FAILED: u8 = 2;
279
280    impl Fixture {
281        fn new() -> Self {
282            let dir = tempfile::tempdir().unwrap();
283            let path = dir.path().join("track.opus.part");
284            File::create(&path).unwrap();
285            Self {
286                _dir: dir,
287                path,
288                written: Arc::new(AtomicU64::new(0)),
289                status: Arc::new(AtomicU8::new(DOWNLOADING)),
290            }
291        }
292
293        /// Append bytes and publish them, as the downloader does per chunk.
294        fn push(&self, chunk: &[u8]) {
295            let mut f = std::fs::OpenOptions::new()
296                .append(true)
297                .open(&self.path)
298                .unwrap();
299            f.write_all(chunk).unwrap();
300            f.flush().unwrap();
301            self.written
302                .fetch_add(chunk.len() as u64, Ordering::Release);
303        }
304
305        fn set(&self, status: u8) {
306            self.status.store(status, Ordering::Release);
307        }
308
309        fn status_fn(&self) -> Arc<dyn Fn() -> StreamStatus + Send + Sync> {
310            let status = self.status.clone();
311            Arc::new(move || match status.load(Ordering::Acquire) {
312                COMPLETE => StreamStatus::Complete,
313                FAILED => StreamStatus::Failed,
314                _ => StreamStatus::Downloading,
315            })
316        }
317
318        fn source(&self, total: u64) -> PartialFileSource {
319            self.source_with_stall(total, STALL_LIMIT)
320        }
321
322        fn source_with_stall(&self, total: u64, stall: Duration) -> PartialFileSource {
323            let status = self.status.clone();
324            PartialFileSource::with_stall_limit(
325                &self.path,
326                self.written.clone(),
327                total,
328                Arc::new(move || match status.load(Ordering::Acquire) {
329                    COMPLETE => StreamStatus::Complete,
330                    FAILED => StreamStatus::Failed,
331                    _ => StreamStatus::Downloading,
332                }),
333                stall,
334            )
335            .unwrap()
336        }
337    }
338
339    #[test]
340    fn reads_what_has_landed() {
341        let fx = Fixture::new();
342        fx.push(b"hello streaming world");
343        fx.set(COMPLETE);
344
345        let mut out = Vec::new();
346        fx.source(21).read_to_end(&mut out).unwrap();
347        assert_eq!(out, b"hello streaming world");
348    }
349
350    #[test]
351    fn read_stops_at_the_write_head_then_resumes() {
352        let fx = Fixture::new();
353        fx.push(b"abcd");
354        let mut src = fx.source(10);
355
356        let mut first = [0u8; 8];
357        assert_eq!(src.read(&mut first).unwrap(), 4);
358        assert_eq!(&first[..4], b"abcd");
359
360        // The rest arrives while the reader is blocked on it.
361        std::thread::spawn({
362            let path = fx.path.clone();
363            let written = fx.written.clone();
364            move || {
365                std::thread::sleep(Duration::from_millis(20));
366                let mut f = std::fs::OpenOptions::new()
367                    .append(true)
368                    .open(&path)
369                    .unwrap();
370                f.write_all(b"efghij").unwrap();
371                f.flush().unwrap();
372                written.fetch_add(6, Ordering::Release);
373            }
374        });
375
376        let mut rest = [0u8; 8];
377        let n = src.read(&mut rest).unwrap();
378        assert_eq!(&rest[..n], b"efghij");
379    }
380
381    #[test]
382    fn seeks_freely_below_the_write_head() {
383        let fx = Fixture::new();
384        fx.push(b"0123456789");
385        let mut src = fx.source(1_000_000);
386
387        assert_eq!(src.seek(SeekFrom::Start(5)).unwrap(), 5);
388        let mut out = [0u8; 3];
389        src.read_exact(&mut out).unwrap();
390        assert_eq!(&out, b"567");
391
392        // Backwards, into bytes already read — no re-download, no buffer.
393        assert_eq!(src.seek(SeekFrom::Start(1)).unwrap(), 1);
394        src.read_exact(&mut out).unwrap();
395        assert_eq!(&out, b"123");
396
397        assert_eq!(src.seek(SeekFrom::Current(-2)).unwrap(), 2);
398    }
399
400    #[test]
401    fn seek_from_end_uses_the_advertised_length() {
402        let fx = Fixture::new();
403        fx.push(b"0123456789");
404        let mut src = fx.source(10);
405
406        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
407        assert_eq!(src.seek(SeekFrom::End(-3)).unwrap(), 7);
408
409        let mut out = [0u8; 3];
410        src.read_exact(&mut out).unwrap();
411        assert_eq!(&out, b"789");
412    }
413
414    #[test]
415    fn seek_before_start_errors() {
416        let fx = Fixture::new();
417        fx.push(b"hello");
418        assert!(fx.source(5).seek(SeekFrom::Current(-1)).is_err());
419    }
420
421    #[test]
422    fn failed_download_errors_instead_of_reporting_eof() {
423        let fx = Fixture::new();
424        fx.push(b"partial");
425        fx.set(FAILED);
426        let mut src = fx.source(1000);
427
428        let mut out = [0u8; 7];
429        src.read_exact(&mut out).unwrap();
430        assert_eq!(&out, b"partial");
431
432        // Past the written bytes: an error, never a clean EOF — Ok(0) here
433        // would end the track early and look like a short file.
434        assert_eq!(
435            src.read(&mut out).unwrap_err().kind(),
436            io::ErrorKind::BrokenPipe
437        );
438    }
439
440    #[test]
441    fn failure_wakes_a_blocked_reader() {
442        let fx = Fixture::new();
443        let mut src = fx.source(1000);
444
445        let status = fx.status.clone();
446        std::thread::spawn(move || {
447            std::thread::sleep(Duration::from_millis(20));
448            status.store(FAILED, Ordering::Release);
449        });
450
451        let mut out = [0u8; 8];
452        assert_eq!(
453            src.read(&mut out).unwrap_err().kind(),
454            io::ErrorKind::BrokenPipe
455        );
456    }
457
458    #[test]
459    fn a_probe_reads_only_what_has_arrived() {
460        // The first attempt must fail at the write head rather than wait: a
461        // container that would go looking for its tail has to be detected, not
462        // waited for.
463        let fx = Fixture::new();
464        fx.push(b"0123456789");
465        let mut src = PartialFileSource::open_for_probe(
466            &fx.path,
467            fx.written.clone(),
468            1_000,
469            fx.status_fn(),
470            ProbeMode::Full,
471        )
472        .unwrap();
473
474        let mut out = [0u8; 10];
475        src.read_exact(&mut out).unwrap();
476        assert_eq!(
477            src.read(&mut out).unwrap_err().kind(),
478            io::ErrorKind::UnexpectedEof,
479            "past the write head is an answer, not a wait"
480        );
481    }
482
483    #[test]
484    fn playback_reads_wait_for_what_has_not_arrived() {
485        // And the second attempt does wait, which is what lets a container
486        // whose audio starts further in than the streaming threshold — a FLAC
487        // with a large padding block, most of them — be opened at all.
488        let fx = Fixture::new();
489        fx.push(b"0123456789");
490        let mut src = PartialFileSource::open(
491            &fx.path,
492            fx.written.clone(),
493            1_000,
494            fx.status_fn(),
495            ProbeMode::Lengthless,
496        )
497        .unwrap();
498
499        let mut out = [0u8; 10];
500        src.read_exact(&mut out).unwrap();
501
502        std::thread::spawn({
503            let path = fx.path.clone();
504            let written = fx.written.clone();
505            move || {
506                std::thread::sleep(Duration::from_millis(20));
507                let mut f = std::fs::OpenOptions::new()
508                    .append(true)
509                    .open(&path)
510                    .unwrap();
511                f.write_all(b"abcde").unwrap();
512                f.flush().unwrap();
513                written.fetch_add(5, Ordering::Release);
514            }
515        });
516
517        let n = src.read(&mut out).unwrap();
518        assert_eq!(&out[..n], b"abcde", "it waited rather than giving up");
519    }
520
521    #[test]
522    fn stalled_download_times_out() {
523        // A download with a Content-Length that never arrives: the read must
524        // give up rather than park the decode thread forever.
525        let fx = Fixture::new();
526        let mut src = fx.source_with_stall(1000, Duration::from_millis(20));
527        let mut out = [0u8; 8];
528        assert_eq!(
529            src.read(&mut out).unwrap_err().kind(),
530            io::ErrorKind::TimedOut
531        );
532    }
533
534    #[test]
535    fn completion_ends_the_read_at_the_true_length() {
536        // A chunked transfer reports no total; completion is what says the file
537        // is whole, and its length on disk is what is readable.
538        let fx = Fixture::new();
539        fx.push(b"chunked");
540        fx.set(COMPLETE);
541
542        let mut out = Vec::new();
543        fx.source(0).read_to_end(&mut out).unwrap();
544        assert_eq!(out, b"chunked");
545    }
546
547    #[test]
548    fn survives_the_part_file_being_renamed() {
549        // The download's final act is a rename. A reader that already has the
550        // file open must not notice.
551        let fx = Fixture::new();
552        fx.push(b"0123456789");
553        let mut src = fx.source(10);
554
555        let mut out = [0u8; 4];
556        src.read_exact(&mut out).unwrap();
557        assert_eq!(&out, b"0123");
558
559        std::fs::rename(&fx.path, fx.path.with_extension("")).unwrap();
560        fx.set(COMPLETE);
561
562        let mut rest = Vec::new();
563        src.read_to_end(&mut rest).unwrap();
564        assert_eq!(rest, b"456789");
565    }
566
567    #[test]
568    fn byte_len_is_the_advertised_length_only() {
569        let fx = Fixture::new();
570        assert_eq!(fx.source(42).byte_len(), Some(42));
571        // No Content-Length: the length is genuinely unknown, and claiming one
572        // would have Symphonia compute a duration from it.
573        assert_eq!(fx.source(0).byte_len(), None);
574    }
575
576    #[test]
577    fn is_seekable_true() {
578        let fx = Fixture::new();
579        assert!(fx.source(0).is_seekable());
580    }
581}