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, and Symphonia scans for trailing metadata on the same
68    /// terms — which no partial file can satisfy. So this is what every
69    /// container mid-download ends up opened with, not only Ogg.
70    ///
71    /// It governs the end of the stream as well as the length, because they
72    /// have to be the same end. See `Seek`.
73    advertise_len: bool,
74    /// Whether `SeekFrom::End` answers for the whole file or for what has
75    /// arrived. Separate from `advertise_len` because Ogg needs the first
76    /// without the second — see `ProbeMode`.
77    whole_file_end: bool,
78}
79
80/// How much a probe may claim, and how far it may read.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ProbeMode {
83    /// State the length. A container that can describe itself from the bytes
84    /// already downloaded does, and the result is fully seekable.
85    Full,
86    /// State no length, so a container that would go looking for its tail
87    /// settles for what it can read from the front. Opens immediately, and
88    /// stays seekable within what has arrived for anything that describes its
89    /// frames from the front. What it gives up is whatever only the tail could
90    /// give — for Ogg that is the duration, and with it seeking at all.
91    ///
92    /// The end is what has arrived. FLAC bisects between its first frame and
93    /// the end it is given, so an end it cannot reach sends every probe into
94    /// bytes that are not on disk.
95    Lengthless,
96    /// The same, except that the end stays the whole file's.
97    ///
98    /// Ogg takes the end it is handed as the end of the *stream*. Handed the
99    /// write head it reports a track that is already over — nought
100    /// milliseconds — and the decode thread reaches the end of it in a second
101    /// and moves on to the next, over and over, so a large Opus file
102    /// downloading never plays at all. It cannot seek mid-download under either
103    /// answer; this is the difference between playing and not.
104    LengthlessWholeEnd,
105}
106
107impl PartialFileSource {
108    /// Open `path` for playback: reads wait at the write head for the download
109    /// to catch up. `bytes_written` is the download's own counter and `total`
110    /// its advertised length, 0 when it sent none.
111    ///
112    /// `mode` must be whatever the probe settled on. A container opened without
113    /// a length to describe itself has to be decoded without one too — given a
114    /// length it goes looking for its tail all over again, and this time on the
115    /// decode thread, where the cost is silence rather than a busy player.
116    pub fn open(
117        path: &Path,
118        bytes_written: Arc<AtomicU64>,
119        total: u64,
120        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
121        mode: ProbeMode,
122    ) -> io::Result<Self> {
123        let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
124        source.advertise_len = mode == ProbeMode::Full;
125        source.whole_file_end = mode != ProbeMode::Lengthless;
126        Ok(source)
127    }
128
129    /// Open for a probe: never waits at the write head, so a container that
130    /// cannot describe itself from what has arrived says so at once.
131    pub fn open_for_probe(
132        path: &Path,
133        bytes_written: Arc<AtomicU64>,
134        total: u64,
135        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
136        mode: ProbeMode,
137    ) -> io::Result<Self> {
138        let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
139        source.wait_for_bytes = false;
140        source.advertise_len = mode == ProbeMode::Full;
141        source.whole_file_end = mode != ProbeMode::Lengthless;
142        Ok(source)
143    }
144
145    fn with_stall_limit(
146        path: &Path,
147        bytes_written: Arc<AtomicU64>,
148        total: u64,
149        status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
150        stall_limit: Duration,
151    ) -> io::Result<Self> {
152        Ok(Self {
153            file: File::open(path)?,
154            pos: 0,
155            bytes_written,
156            total,
157            status,
158            stall_limit,
159            wait_for_bytes: true,
160            advertise_len: true,
161            whole_file_end: true,
162        })
163    }
164
165    /// Bytes known to be readable — what the download has written, or the whole
166    /// file once it has landed.
167    fn available(&self) -> u64 {
168        let written = self.bytes_written.load(Ordering::Acquire);
169        match (self.status)() {
170            StreamStatus::Complete => self.file.metadata().map(|m| m.len()).unwrap_or(written),
171            _ => written,
172        }
173    }
174
175    /// Read straight from the file, tolerating a short read at the write head:
176    /// `bytes_written` is published by the downloader as it goes and the data
177    /// behind it can lag by a moment.
178    fn read_available(&mut self, buf: &mut [u8], limit: u64) -> io::Result<usize> {
179        let to_read = (limit as usize).min(buf.len());
180        self.file.read(&mut buf[..to_read]).inspect(|n| {
181            self.pos += *n as u64;
182        })
183    }
184}
185
186impl Read for PartialFileSource {
187    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
188        if buf.is_empty() {
189            return Ok(0);
190        }
191
192        let deadline = Instant::now() + self.stall_limit;
193        loop {
194            let available = self.available();
195            if available > self.pos {
196                let n = self.read_available(buf, available - self.pos)?;
197                if n > 0 {
198                    return Ok(n);
199                }
200                // The counter ran ahead of what is visible on disk. Fall
201                // through and wait rather than reporting a false EOF.
202            }
203
204            match (self.status)() {
205                StreamStatus::Failed => {
206                    return Err(io::Error::new(
207                        io::ErrorKind::BrokenPipe,
208                        "stream download failed before delivering the whole track",
209                    ));
210                }
211                // Everything landed and there is nothing past `pos`: real EOF.
212                StreamStatus::Complete if available <= self.pos => return Ok(0),
213                StreamStatus::Complete => {}
214                StreamStatus::Downloading => {
215                    // A server that sent a Content-Length has delivered it all.
216                    if self.total > 0 && available >= self.total && self.pos >= self.total {
217                        return Ok(0);
218                    }
219                }
220            }
221
222            if !self.wait_for_bytes {
223                return Err(io::Error::new(
224                    io::ErrorKind::UnexpectedEof,
225                    "past what the download has delivered",
226                ));
227            }
228            if Instant::now() >= deadline {
229                return Err(io::Error::new(
230                    io::ErrorKind::TimedOut,
231                    "stream download stalled",
232                ));
233            }
234            std::thread::sleep(POLL_INTERVAL);
235        }
236    }
237}
238
239impl Seek for PartialFileSource {
240    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
241        let target: i64 = match pos {
242            SeekFrom::Start(n) => n as i64,
243            SeekFrom::Current(n) => self.pos as i64 + n,
244            // The end has to be the same end `byte_len` describes. A source
245            // that states a length is asked about the whole file and answers
246            // for it; one that states none has only what has arrived, and
247            // answering with the advertised total there sends a reader
248            // bisecting into bytes that are not on disk yet — which is the
249            // whole file's worth of waiting for a FLAC seeked mid-download.
250            SeekFrom::End(n) => {
251                let len = if self.whole_file_end && self.total > 0 {
252                    self.total
253                } else {
254                    self.available()
255                };
256                len as i64 + n
257            }
258        };
259
260        if target < 0 {
261            return Err(io::Error::new(
262                io::ErrorKind::InvalidInput,
263                "seek before beginning of stream",
264            ));
265        }
266
267        self.pos = self.file.seek(SeekFrom::Start(target as u64))?;
268        Ok(self.pos)
269    }
270}
271
272// Symphonia requires MediaSource: Read + Seek + Send + Any
273impl symphonia::core::io::MediaSource for PartialFileSource {
274    fn is_seekable(&self) -> bool {
275        // Backward seeks and forward seeks below the write head are a `lseek`
276        // on a file that is already there. A forward seek past it lands on a
277        // read that blocks until the bytes arrive, which is the honest
278        // behaviour — callers clamp to `seekable_ms` to avoid asking.
279        //
280        // Saying no would cost more than it saved: a reader told a stream is
281        // unseekable does not stop seeking, it walks the whole file to the
282        // target instead, and cannot go backwards at all.
283        true
284    }
285
286    fn byte_len(&self) -> Option<u64> {
287        (self.advertise_len && self.total > 0).then_some(self.total)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use std::io::Write;
294    use std::sync::atomic::AtomicU8;
295
296    use symphonia::core::io::MediaSource;
297
298    use super::*;
299
300    /// A file plus the counter and status a download would publish, so a test
301    /// can advance either independently.
302    struct Fixture {
303        _dir: tempfile::TempDir,
304        path: std::path::PathBuf,
305        written: Arc<AtomicU64>,
306        status: Arc<AtomicU8>,
307    }
308
309    const DOWNLOADING: u8 = 0;
310    const COMPLETE: u8 = 1;
311    const FAILED: u8 = 2;
312
313    impl Fixture {
314        fn new() -> Self {
315            let dir = tempfile::tempdir().unwrap();
316            let path = dir.path().join("track.opus.part");
317            File::create(&path).unwrap();
318            Self {
319                _dir: dir,
320                path,
321                written: Arc::new(AtomicU64::new(0)),
322                status: Arc::new(AtomicU8::new(DOWNLOADING)),
323            }
324        }
325
326        /// Append bytes and publish them, as the downloader does per chunk.
327        fn push(&self, chunk: &[u8]) {
328            let mut f = std::fs::OpenOptions::new()
329                .append(true)
330                .open(&self.path)
331                .unwrap();
332            f.write_all(chunk).unwrap();
333            f.flush().unwrap();
334            self.written
335                .fetch_add(chunk.len() as u64, Ordering::Release);
336        }
337
338        fn set(&self, status: u8) {
339            self.status.store(status, Ordering::Release);
340        }
341
342        fn status_fn(&self) -> Arc<dyn Fn() -> StreamStatus + Send + Sync> {
343            let status = self.status.clone();
344            Arc::new(move || match status.load(Ordering::Acquire) {
345                COMPLETE => StreamStatus::Complete,
346                FAILED => StreamStatus::Failed,
347                _ => StreamStatus::Downloading,
348            })
349        }
350
351        fn source(&self, total: u64) -> PartialFileSource {
352            self.source_with_stall(total, STALL_LIMIT)
353        }
354
355        fn source_with_stall(&self, total: u64, stall: Duration) -> PartialFileSource {
356            let status = self.status.clone();
357            PartialFileSource::with_stall_limit(
358                &self.path,
359                self.written.clone(),
360                total,
361                Arc::new(move || match status.load(Ordering::Acquire) {
362                    COMPLETE => StreamStatus::Complete,
363                    FAILED => StreamStatus::Failed,
364                    _ => StreamStatus::Downloading,
365                }),
366                stall,
367            )
368            .unwrap()
369        }
370    }
371
372    #[test]
373    fn reads_what_has_landed() {
374        let fx = Fixture::new();
375        fx.push(b"hello streaming world");
376        fx.set(COMPLETE);
377
378        let mut out = Vec::new();
379        fx.source(21).read_to_end(&mut out).unwrap();
380        assert_eq!(out, b"hello streaming world");
381    }
382
383    #[test]
384    fn read_stops_at_the_write_head_then_resumes() {
385        let fx = Fixture::new();
386        fx.push(b"abcd");
387        let mut src = fx.source(10);
388
389        let mut first = [0u8; 8];
390        assert_eq!(src.read(&mut first).unwrap(), 4);
391        assert_eq!(&first[..4], b"abcd");
392
393        // The rest arrives while the reader is blocked on it.
394        std::thread::spawn({
395            let path = fx.path.clone();
396            let written = fx.written.clone();
397            move || {
398                std::thread::sleep(Duration::from_millis(20));
399                let mut f = std::fs::OpenOptions::new()
400                    .append(true)
401                    .open(&path)
402                    .unwrap();
403                f.write_all(b"efghij").unwrap();
404                f.flush().unwrap();
405                written.fetch_add(6, Ordering::Release);
406            }
407        });
408
409        let mut rest = [0u8; 8];
410        let n = src.read(&mut rest).unwrap();
411        assert_eq!(&rest[..n], b"efghij");
412    }
413
414    #[test]
415    fn seeks_freely_below_the_write_head() {
416        let fx = Fixture::new();
417        fx.push(b"0123456789");
418        let mut src = fx.source(1_000_000);
419
420        assert_eq!(src.seek(SeekFrom::Start(5)).unwrap(), 5);
421        let mut out = [0u8; 3];
422        src.read_exact(&mut out).unwrap();
423        assert_eq!(&out, b"567");
424
425        // Backwards, into bytes already read — no re-download, no buffer.
426        assert_eq!(src.seek(SeekFrom::Start(1)).unwrap(), 1);
427        src.read_exact(&mut out).unwrap();
428        assert_eq!(&out, b"123");
429
430        assert_eq!(src.seek(SeekFrom::Current(-2)).unwrap(), 2);
431    }
432
433    #[test]
434    fn seek_from_end_uses_the_advertised_length() {
435        let fx = Fixture::new();
436        fx.push(b"0123456789");
437        let mut src = fx.source(10);
438
439        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
440        assert_eq!(src.seek(SeekFrom::End(-3)).unwrap(), 7);
441
442        let mut out = [0u8; 3];
443        src.read_exact(&mut out).unwrap();
444        assert_eq!(&out, b"789");
445    }
446
447    #[test]
448    fn a_lengthless_source_ends_where_the_download_does() {
449        // The end has to agree with `byte_len`. FLAC seeks by bisecting
450        // between its first frame and `SeekFrom::End(0)`, so answering with the
451        // advertised total aims the search at bytes that are not on disk yet:
452        // every probe of the range waits at the write head, and a seek into a
453        // half-downloaded track spends the stall limit before failing.
454        let fx = Fixture::new();
455        fx.push(b"0123456789");
456
457        let mut src = PartialFileSource::open(
458            &fx.path,
459            fx.written.clone(),
460            1_000,
461            fx.status_fn(),
462            ProbeMode::Lengthless,
463        )
464        .unwrap();
465        assert_eq!(src.byte_len(), None);
466        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
467
468        // Stating a length means answering for the whole of it.
469        let mut src = PartialFileSource::open(
470            &fx.path,
471            fx.written.clone(),
472            1_000,
473            fx.status_fn(),
474            ProbeMode::Full,
475        )
476        .unwrap();
477        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 1_000);
478    }
479
480    /// Ogg's half of the same question, and the opposite answer.
481    ///
482    /// Handed the write head as the end, Ogg reports a stream that is already
483    /// over: a large Opus file downloading opened, said nought milliseconds,
484    /// and the decode thread finished it and moved on, over and over, so it
485    /// never played at all.
486    #[test]
487    fn an_ogg_source_still_ends_at_the_whole_file() {
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::LengthlessWholeEnd,
496        )
497        .unwrap();
498        // Still no length: the point of opening this way is that Ogg does not
499        // go looking for a tail that has not arrived.
500        assert_eq!(src.byte_len(), None);
501        // But the end it is told about is the file's, not the download's.
502        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 1_000);
503    }
504
505    #[test]
506    fn a_landed_download_ends_at_the_whole_file() {
507        // The bound moves with the transfer, so it stops bounding anything
508        // once every byte is there.
509        let fx = Fixture::new();
510        fx.push(b"0123456789");
511        fx.set(COMPLETE);
512
513        let mut src = PartialFileSource::open(
514            &fx.path,
515            fx.written.clone(),
516            10,
517            fx.status_fn(),
518            ProbeMode::Lengthless,
519        )
520        .unwrap();
521        assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
522    }
523
524    #[test]
525    fn seek_before_start_errors() {
526        let fx = Fixture::new();
527        fx.push(b"hello");
528        assert!(fx.source(5).seek(SeekFrom::Current(-1)).is_err());
529    }
530
531    #[test]
532    fn failed_download_errors_instead_of_reporting_eof() {
533        let fx = Fixture::new();
534        fx.push(b"partial");
535        fx.set(FAILED);
536        let mut src = fx.source(1000);
537
538        let mut out = [0u8; 7];
539        src.read_exact(&mut out).unwrap();
540        assert_eq!(&out, b"partial");
541
542        // Past the written bytes: an error, never a clean EOF — Ok(0) here
543        // would end the track early and look like a short file.
544        assert_eq!(
545            src.read(&mut out).unwrap_err().kind(),
546            io::ErrorKind::BrokenPipe
547        );
548    }
549
550    #[test]
551    fn failure_wakes_a_blocked_reader() {
552        let fx = Fixture::new();
553        let mut src = fx.source(1000);
554
555        let status = fx.status.clone();
556        std::thread::spawn(move || {
557            std::thread::sleep(Duration::from_millis(20));
558            status.store(FAILED, Ordering::Release);
559        });
560
561        let mut out = [0u8; 8];
562        assert_eq!(
563            src.read(&mut out).unwrap_err().kind(),
564            io::ErrorKind::BrokenPipe
565        );
566    }
567
568    #[test]
569    fn a_probe_reads_only_what_has_arrived() {
570        // The first attempt must fail at the write head rather than wait: a
571        // container that would go looking for its tail has to be detected, not
572        // waited for.
573        let fx = Fixture::new();
574        fx.push(b"0123456789");
575        let mut src = PartialFileSource::open_for_probe(
576            &fx.path,
577            fx.written.clone(),
578            1_000,
579            fx.status_fn(),
580            ProbeMode::Full,
581        )
582        .unwrap();
583
584        let mut out = [0u8; 10];
585        src.read_exact(&mut out).unwrap();
586        assert_eq!(
587            src.read(&mut out).unwrap_err().kind(),
588            io::ErrorKind::UnexpectedEof,
589            "past the write head is an answer, not a wait"
590        );
591    }
592
593    #[test]
594    fn playback_reads_wait_for_what_has_not_arrived() {
595        // And the second attempt does wait, which is what lets a container
596        // whose audio starts further in than the streaming threshold — a FLAC
597        // with a large padding block, most of them — be opened at all.
598        let fx = Fixture::new();
599        fx.push(b"0123456789");
600        let mut src = PartialFileSource::open(
601            &fx.path,
602            fx.written.clone(),
603            1_000,
604            fx.status_fn(),
605            ProbeMode::Lengthless,
606        )
607        .unwrap();
608
609        let mut out = [0u8; 10];
610        src.read_exact(&mut out).unwrap();
611
612        std::thread::spawn({
613            let path = fx.path.clone();
614            let written = fx.written.clone();
615            move || {
616                std::thread::sleep(Duration::from_millis(20));
617                let mut f = std::fs::OpenOptions::new()
618                    .append(true)
619                    .open(&path)
620                    .unwrap();
621                f.write_all(b"abcde").unwrap();
622                f.flush().unwrap();
623                written.fetch_add(5, Ordering::Release);
624            }
625        });
626
627        let n = src.read(&mut out).unwrap();
628        assert_eq!(&out[..n], b"abcde", "it waited rather than giving up");
629    }
630
631    #[test]
632    fn stalled_download_times_out() {
633        // A download with a Content-Length that never arrives: the read must
634        // give up rather than park the decode thread forever.
635        let fx = Fixture::new();
636        let mut src = fx.source_with_stall(1000, Duration::from_millis(20));
637        let mut out = [0u8; 8];
638        assert_eq!(
639            src.read(&mut out).unwrap_err().kind(),
640            io::ErrorKind::TimedOut
641        );
642    }
643
644    #[test]
645    fn completion_ends_the_read_at_the_true_length() {
646        // A chunked transfer reports no total; completion is what says the file
647        // is whole, and its length on disk is what is readable.
648        let fx = Fixture::new();
649        fx.push(b"chunked");
650        fx.set(COMPLETE);
651
652        let mut out = Vec::new();
653        fx.source(0).read_to_end(&mut out).unwrap();
654        assert_eq!(out, b"chunked");
655    }
656
657    #[test]
658    fn survives_the_part_file_being_renamed() {
659        // The download's final act is a rename. A reader that already has the
660        // file open must not notice.
661        let fx = Fixture::new();
662        fx.push(b"0123456789");
663        let mut src = fx.source(10);
664
665        let mut out = [0u8; 4];
666        src.read_exact(&mut out).unwrap();
667        assert_eq!(&out, b"0123");
668
669        std::fs::rename(&fx.path, fx.path.with_extension("")).unwrap();
670        fx.set(COMPLETE);
671
672        let mut rest = Vec::new();
673        src.read_to_end(&mut rest).unwrap();
674        assert_eq!(rest, b"456789");
675    }
676
677    #[test]
678    fn byte_len_is_the_advertised_length_only() {
679        let fx = Fixture::new();
680        assert_eq!(fx.source(42).byte_len(), Some(42));
681        // No Content-Length: the length is genuinely unknown, and claiming one
682        // would have Symphonia compute a duration from it.
683        assert_eq!(fx.source(0).byte_len(), None);
684    }
685
686    #[test]
687    fn is_seekable_true() {
688        let fx = Fixture::new();
689        assert!(fx.source(0).is_seekable());
690    }
691}