io+platform: phase 2 — sink trait split + fs_type detection
Introduces the SequentialSink / RandomAccessSink trait pair under io::sink and an open_for_mkv dispatch helper that picks WritebackFile on Linux+NFS and LocalFileSink everywhere else. LocalFileSink wraps BufWriter<File> with a 4 MiB buffer and exposes a per-OS preallocate path (fallocate on Linux, F_PREALLOCATE on macOS, no-op fallback). Adds platform::fs_type::detect with a per-OS split (statfs on Linux / macOS, UNC heuristic on Windows, Unknown elsewhere) so construction- site dispatch has a single primitive to call. Blanket impls cover the common shapes: any Write+Send is a SequentialSink, and any SequentialSink+Seek is a RandomAccessSink. WritebackFile satisfies the random-access trait via the blanket impl without needing an explicit per-type impl. No callers wired yet — the mux::resolve construction sites stay on WritebackFile pending Phase 3. Tests: 5 new sink/preallocate tests + 3 fs_type tests (1 ignored, needs a real NFS mount). cargo +1.86 fmt + clippy + tests all green.
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
pub(crate) mod bounded;
|
pub(crate) mod bounded;
|
||||||
pub mod byte_channel;
|
pub mod byte_channel;
|
||||||
pub mod file_sector_source;
|
pub mod file_sector_source;
|
||||||
|
pub mod sink;
|
||||||
mod writeback;
|
mod writeback;
|
||||||
mod writeback_file;
|
mod writeback_file;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! `LocalFileSink` — `BufWriter<File>` for the common local-disk case.
|
||||||
|
//!
|
||||||
|
//! Buffering: 4 MiB internal `BufWriter`. Sized to coalesce the small
|
||||||
|
//! per-PES writes that come out of the muxer into kernel-page-aligned
|
||||||
|
//! flushes without making the buffer big enough to matter for memory
|
||||||
|
//! pressure on a single concurrent rip.
|
||||||
|
//!
|
||||||
|
//! `Seek` flushes the underlying `BufWriter` first; otherwise a seek
|
||||||
|
//! could leapfrog buffered data and silently corrupt the file. This is
|
||||||
|
//! the same shape `BufWriter` itself uses when it impls `Seek` in
|
||||||
|
//! stdlib, and is necessary for MKV's seek-back operations (cluster
|
||||||
|
//! size patch, Cues index, segment header backpatch) to land on the
|
||||||
|
//! right offset.
|
||||||
|
//!
|
||||||
|
//! `RandomAccessSink` is satisfied via the blanket impl in
|
||||||
|
//! [`super::mod`]; no explicit impl needed here.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::preallocate;
|
||||||
|
|
||||||
|
const BUFFER_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Random-access write sink for local disks.
|
||||||
|
///
|
||||||
|
/// Wraps a `BufWriter<File>` with a 4 MiB internal buffer and forwards
|
||||||
|
/// `Write`/`Seek` so any call site that previously held a `File` or
|
||||||
|
/// `WritebackFile` can drop this in. `finish()` flushes the buffer and
|
||||||
|
/// runs `sync_all` on the underlying file so the caller can drop it
|
||||||
|
/// without losing data.
|
||||||
|
///
|
||||||
|
/// Construction always opens the file `create + truncate + read +
|
||||||
|
/// write`. `read` is enabled so the same handle can be reused for a
|
||||||
|
/// verification re-read after the mux (the existing
|
||||||
|
/// `FileSectorSink::create` pattern). On Linux, [`with_size_hint`]
|
||||||
|
/// additionally calls `fallocate(FALLOC_FL_KEEP_SIZE)` to pre-reserve
|
||||||
|
/// extents.
|
||||||
|
///
|
||||||
|
/// [`with_size_hint`]: Self::with_size_hint
|
||||||
|
pub struct LocalFileSink {
|
||||||
|
inner: BufWriter<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFileSink {
|
||||||
|
/// Open `path` for writing, truncating any existing contents.
|
||||||
|
pub fn create(path: &Path) -> io::Result<Self> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
Ok(Self {
|
||||||
|
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::create`] but additionally calls the per-OS
|
||||||
|
/// preallocate path with `size_bytes`. On Linux this is
|
||||||
|
/// `fallocate(FALLOC_FL_KEEP_SIZE)` so the on-disk extents are
|
||||||
|
/// reserved up front (reducing fragmentation for big sequential
|
||||||
|
/// muxer output); on other OSes it is a no-op today. Failures
|
||||||
|
/// from the preallocate call are non-fatal — the file is still
|
||||||
|
/// returned, just without the size reservation.
|
||||||
|
pub fn with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
preallocate::preallocate(&file, size_bytes);
|
||||||
|
Ok(Self {
|
||||||
|
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the internal buffer and `fsync` the underlying file.
|
||||||
|
/// Idempotent with `Drop` (the `BufWriter` also flushes on drop;
|
||||||
|
/// this call additionally surfaces fsync errors to the caller).
|
||||||
|
#[allow(dead_code)] // exposed for parity with WritebackFile::sync_all
|
||||||
|
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||||
|
self.inner.flush()?;
|
||||||
|
self.inner.get_ref().sync_all()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for LocalFileSink {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.inner.write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||||
|
self.inner.write_all(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.inner.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for LocalFileSink {
|
||||||
|
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||||
|
// Flush before seeking so buffered bytes land at the offset
|
||||||
|
// they were written for, not the new one.
|
||||||
|
self.inner.flush()?;
|
||||||
|
self.inner.get_mut().seek(from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_seek_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("rt.bin");
|
||||||
|
let mut s = LocalFileSink::create(&p).unwrap();
|
||||||
|
s.write_all(b"AAAA").unwrap();
|
||||||
|
s.write_all(b"BBBB").unwrap();
|
||||||
|
// Seek back over the second word and overwrite.
|
||||||
|
s.seek(SeekFrom::Start(4)).unwrap();
|
||||||
|
s.write_all(b"CCCC").unwrap();
|
||||||
|
s.sync_all().unwrap();
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
let mut f = File::open(&p).unwrap();
|
||||||
|
let mut got = Vec::new();
|
||||||
|
f.read_to_end(&mut got).unwrap();
|
||||||
|
assert_eq!(&got[..], b"AAAACCCC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_flushes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("drop.bin");
|
||||||
|
{
|
||||||
|
let mut s = LocalFileSink::create(&p).unwrap();
|
||||||
|
s.write_all(b"buffered").unwrap();
|
||||||
|
// No explicit flush / sync_all — BufWriter drop runs the
|
||||||
|
// flush and the file should land on disk.
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&p).unwrap();
|
||||||
|
assert_eq!(&bytes[..], b"buffered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_size_hint_creates_writable_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("sz.bin");
|
||||||
|
let mut s = LocalFileSink::with_size_hint(&p, 64 * 1024).unwrap();
|
||||||
|
s.write_all(b"hint-ok").unwrap();
|
||||||
|
s.sync_all().unwrap();
|
||||||
|
drop(s);
|
||||||
|
let bytes = std::fs::read(&p).unwrap();
|
||||||
|
assert_eq!(&bytes[..], b"hint-ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Output-sink trait split for the buffering architecture.
|
||||||
|
//!
|
||||||
|
//! Two traits, one for each capability axis of an output destination:
|
||||||
|
//!
|
||||||
|
//! - [`SequentialSink`] — anything you can `Write` to in order. Sockets,
|
||||||
|
//! pipes, append-only stores, plain files. Containers that don't need
|
||||||
|
//! seek (M2TS, fMP4, HEVC elementary) target this.
|
||||||
|
//! - [`RandomAccessSink`] — everything `SequentialSink` plus a working
|
||||||
|
//! `Seek`. Local files, NFS files, anything with random-write
|
||||||
|
//! semantics. Containers that need backpatch (MKV cluster sizes, Cues
|
||||||
|
//! index, MP4 moov-at-end) target this.
|
||||||
|
//!
|
||||||
|
//! `RandomAccessSink: SequentialSink` — every random-access sink is
|
||||||
|
//! also a valid sequential sink. The muxer is generic over which it
|
||||||
|
//! requires (`MkvMux<S: RandomAccessSink>`, `M2tsMux<S: SequentialSink>`)
|
||||||
|
//! so an attempt to mux MKV to a network socket is a compile error.
|
||||||
|
//!
|
||||||
|
//! Buffering policy belongs to the concrete sink, not to a wrapper at
|
||||||
|
//! the call site. `LocalFileSink` wraps a `BufWriter<File>` with a
|
||||||
|
//! 4 MiB buffer for the common local-disk case; `WritebackFile`
|
||||||
|
//! (separate module) wraps a `File` with the adaptive-chunk
|
||||||
|
//! `sync_file_range` machinery for the Linux+NFS case.
|
||||||
|
//!
|
||||||
|
//! See `freemkv-private/memory/project_buffering_architecture.md` for
|
||||||
|
//! the full design and the source/sink matrix.
|
||||||
|
|
||||||
|
use std::io::{Seek, Write};
|
||||||
|
|
||||||
|
mod local_file;
|
||||||
|
mod preallocate;
|
||||||
|
|
||||||
|
pub use local_file::LocalFileSink;
|
||||||
|
|
||||||
|
/// Sequential-only write destination. Sockets, pipes, append-only
|
||||||
|
/// stores. No seek. Implementations own their write buffering — the
|
||||||
|
/// trait does not impose or hide any buffering of its own.
|
||||||
|
///
|
||||||
|
/// `finish` drains any internal buffering and signals end-of-stream to
|
||||||
|
/// the underlying transport (close-write on a socket, flush on a
|
||||||
|
/// buffered writer, etc.). The default impl is a no-op; concrete
|
||||||
|
/// implementations that need explicit shutdown can override it but the
|
||||||
|
/// blanket impl below keeps it optional for adapter types like
|
||||||
|
/// `&mut File`.
|
||||||
|
pub trait SequentialSink: Write + Send {
|
||||||
|
fn finish(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random-access write destination. Local files, NFS files, anything
|
||||||
|
/// with a working `Seek`. Inherits the `SequentialSink` contract — a
|
||||||
|
/// random-access sink is always usable as a sequential sink.
|
||||||
|
pub trait RandomAccessSink: SequentialSink + Seek {}
|
||||||
|
|
||||||
|
// Blanket impls so any `Write + Send` type acts as a `SequentialSink`
|
||||||
|
// (with default `finish`), and any sink that also impls `Seek` is
|
||||||
|
// automatically a `RandomAccessSink`. Keeps call-site ergonomics simple
|
||||||
|
// — `&mut File`, `LocalFileSink`, `WritebackFile`, `BufWriter<File>`,
|
||||||
|
// and `Cursor<Vec<u8>>` all satisfy the right trait without per-type
|
||||||
|
// boilerplate.
|
||||||
|
impl<T: Write + Send + ?Sized> SequentialSink for T {}
|
||||||
|
impl<T: SequentialSink + Seek + ?Sized> RandomAccessSink for T {}
|
||||||
|
|
||||||
|
/// Pick the right `RandomAccessSink` impl for `dest` based on its
|
||||||
|
/// filesystem type.
|
||||||
|
///
|
||||||
|
/// - Linux + NFS path → `WritebackFile` with its adaptive-chunk
|
||||||
|
/// sync_file_range machinery and (when supported) `fallocate` size
|
||||||
|
/// hint.
|
||||||
|
/// - everything else → [`LocalFileSink`] over `BufWriter<File>`. On
|
||||||
|
/// non-Linux there is no `WritebackFile` machinery to opt into, and
|
||||||
|
/// on local Linux the kernel's default writeback policy is already
|
||||||
|
/// fine.
|
||||||
|
///
|
||||||
|
/// `size_hint`, when present, is forwarded to the per-OS preallocate
|
||||||
|
/// path (`fallocate(KEEP_SIZE)` on Linux, `F_PREALLOCATE` on macOS when
|
||||||
|
/// implemented, no-op elsewhere).
|
||||||
|
///
|
||||||
|
/// Returns a boxed trait object so the call site (mux construction)
|
||||||
|
/// stays agnostic of which concrete sink got picked.
|
||||||
|
#[allow(dead_code)] // wiring to mux::resolve is a follow-up commit
|
||||||
|
pub fn open_for_mkv(
|
||||||
|
dest: &std::path::Path,
|
||||||
|
size_hint: Option<u64>,
|
||||||
|
) -> std::io::Result<Box<dyn RandomAccessSink>> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use crate::platform::fs_type::{FsType, detect};
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
use crate::platform::fs_type::detect;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if detect(dest) == FsType::Nfs {
|
||||||
|
let wf = match size_hint {
|
||||||
|
Some(n) => crate::io::WritebackFile::create_with_size_hint(dest, n)?,
|
||||||
|
None => crate::io::WritebackFile::create(dest)?,
|
||||||
|
};
|
||||||
|
return Ok(Box::new(wf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Silence the unused-binding warning on non-Linux where the only
|
||||||
|
// branch above is cfg-gated out.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = detect(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sink = match size_hint {
|
||||||
|
Some(n) => LocalFileSink::with_size_hint(dest, n)?,
|
||||||
|
None => LocalFileSink::create(dest)?,
|
||||||
|
};
|
||||||
|
Ok(Box::new(sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
// Type-level assertion: the blanket impls cover the shapes we care
|
||||||
|
// about. These functions never run; they just have to type-check.
|
||||||
|
fn _assert_file_is_sequential(_: &mut dyn SequentialSink) {}
|
||||||
|
fn _assert_file_is_random_access(_: &mut dyn RandomAccessSink) {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blanket_impls_cover_file_and_localfilesink() {
|
||||||
|
// `File` directly via blanket impls.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut f = File::create(dir.path().join("a.bin")).unwrap();
|
||||||
|
_assert_file_is_sequential(&mut f);
|
||||||
|
_assert_file_is_random_access(&mut f);
|
||||||
|
|
||||||
|
// `LocalFileSink` ditto.
|
||||||
|
let mut s = LocalFileSink::create(&dir.path().join("b.bin")).unwrap();
|
||||||
|
_assert_file_is_sequential(&mut s);
|
||||||
|
_assert_file_is_random_access(&mut s);
|
||||||
|
|
||||||
|
// `WritebackFile` — confirms the Phase 1 type still satisfies
|
||||||
|
// the trait via the blanket impl without needing an explicit
|
||||||
|
// `impl RandomAccessSink for WritebackFile {}`.
|
||||||
|
let mut wf = crate::io::WritebackFile::create(&dir.path().join("c.bin")).unwrap();
|
||||||
|
_assert_file_is_sequential(&mut wf);
|
||||||
|
_assert_file_is_random_access(&mut wf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_for_mkv_returns_a_random_access_sink() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("c.bin");
|
||||||
|
let mut sink = open_for_mkv(&p, Some(64 * 1024)).unwrap();
|
||||||
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
sink.write_all(b"hello").unwrap();
|
||||||
|
sink.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
drop(sink);
|
||||||
|
let bytes = std::fs::read(&p).unwrap();
|
||||||
|
assert_eq!(&bytes[..5], b"hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Linux `fallocate(FALLOC_FL_KEEP_SIZE)` preallocation.
|
||||||
|
//!
|
||||||
|
//! `KEEP_SIZE` reserves extents without changing the apparent file
|
||||||
|
//! length, which matches the muxer's expectation that writes still grow
|
||||||
|
//! the file naturally.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||||
|
let fd = file.as_raw_fd();
|
||||||
|
// FALLOC_FL_KEEP_SIZE = 0x01.
|
||||||
|
let rc = unsafe { libc::fallocate(fd, libc::FALLOC_FL_KEEP_SIZE, 0, size_bytes as i64) };
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink fallocate size_hint={size_bytes} rc={rc} ok={}",
|
||||||
|
rc == 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! macOS `F_PREALLOCATE` extent reservation.
|
||||||
|
//!
|
||||||
|
//! `fcntl(F_PREALLOCATE)` with `F_ALLOCATECONTIG` first (try for a
|
||||||
|
//! contiguous run) and fall back to `F_ALLOCATEALL` (non-contig OK).
|
||||||
|
//! Reported file size is unchanged — the muxer's writes still grow it.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
// Mirror the Darwin `fstore_t` struct from `<sys/fcntl.h>`. libc on
|
||||||
|
// some Rust toolchains/versions doesn't ship this binding, so define
|
||||||
|
// it locally with the layout the kernel ABI requires.
|
||||||
|
#[repr(C)]
|
||||||
|
struct Fstore {
|
||||||
|
fst_flags: libc::c_uint,
|
||||||
|
fst_posmode: libc::c_int,
|
||||||
|
fst_offset: libc::off_t,
|
||||||
|
fst_length: libc::off_t,
|
||||||
|
fst_bytesalloc: libc::off_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants from <sys/fcntl.h>.
|
||||||
|
const F_PREALLOCATE: libc::c_int = 42;
|
||||||
|
const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002;
|
||||||
|
const F_ALLOCATEALL: libc::c_uint = 0x0000_0004;
|
||||||
|
const F_PEOFPOSMODE: libc::c_int = 3;
|
||||||
|
|
||||||
|
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||||
|
let fd = file.as_raw_fd();
|
||||||
|
let mut store = Fstore {
|
||||||
|
fst_flags: F_ALLOCATECONTIG,
|
||||||
|
fst_posmode: F_PEOFPOSMODE,
|
||||||
|
fst_offset: 0,
|
||||||
|
fst_length: size_bytes as libc::off_t,
|
||||||
|
fst_bytesalloc: 0,
|
||||||
|
};
|
||||||
|
let mut rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
|
||||||
|
if rc == -1 {
|
||||||
|
// Fall back to non-contiguous.
|
||||||
|
store.fst_flags = F_ALLOCATEALL;
|
||||||
|
rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink F_PREALLOCATE size_hint={size_bytes} rc={rc} bytesalloc={}",
|
||||||
|
store.fst_bytesalloc
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Per-OS extent preallocation. Best-effort; failures are logged at
|
||||||
|
//! debug and otherwise swallowed because the file is still usable
|
||||||
|
//! without the size reservation — only large-file fragmentation gets
|
||||||
|
//! marginally worse.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
mod other;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use linux::preallocate_impl;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos::preallocate_impl;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
use other::preallocate_impl;
|
||||||
|
|
||||||
|
/// Reserve `size_bytes` of disk space for `file`'s on-disk extents.
|
||||||
|
/// Reported file size is unchanged — writes still grow the file
|
||||||
|
/// naturally; only the allocator's extent map is primed.
|
||||||
|
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||||
|
preallocate_impl(file, size_bytes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Fallback preallocate impl. No-op.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
pub(super) fn preallocate_impl(_file: &File, size_bytes: u64) {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink preallocate size_hint={size_bytes} skipped (no platform impl)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Linux `statfs64`-based filesystem type detection.
|
||||||
|
//!
|
||||||
|
//! Recognised local-FS magics: ext2/3/4, xfs, btrfs, tmpfs. NFS is the
|
||||||
|
//! one network FS this layer cares about (the buffering decision keys
|
||||||
|
//! off it). Anything else maps to [`FsType::Unknown`].
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::FsType;
|
||||||
|
|
||||||
|
// Magic numbers from `<linux/magic.h>`. Kept literal here so we don't
|
||||||
|
// depend on libc exposing each one — only `NFS_SUPER_MAGIC` is
|
||||||
|
// guaranteed to be present across libc / musl revisions.
|
||||||
|
const EXT2_SUPER_MAGIC: i64 = 0xEF53;
|
||||||
|
const XFS_SUPER_MAGIC: i64 = 0x5846_5342;
|
||||||
|
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;
|
||||||
|
const TMPFS_MAGIC: i64 = 0x0102_1994;
|
||||||
|
|
||||||
|
pub(super) fn detect_impl(path: &Path) -> FsType {
|
||||||
|
let cpath = match CString::new(path.as_os_str().as_bytes()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return FsType::Unknown,
|
||||||
|
};
|
||||||
|
// `statfs64` is repr(C); zeroing is the documented init pattern for
|
||||||
|
// the kernel uapi struct.
|
||||||
|
let mut buf: libc::statfs64 = unsafe { std::mem::zeroed() };
|
||||||
|
let rc = unsafe { libc::statfs64(cpath.as_ptr(), &mut buf) };
|
||||||
|
if rc != 0 {
|
||||||
|
return FsType::Unknown;
|
||||||
|
}
|
||||||
|
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
||||||
|
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
||||||
|
// comparison.
|
||||||
|
let f_type = buf.f_type as i64;
|
||||||
|
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||||
|
if f_type == nfs_magic {
|
||||||
|
return FsType::Nfs;
|
||||||
|
}
|
||||||
|
match f_type {
|
||||||
|
EXT2_SUPER_MAGIC | XFS_SUPER_MAGIC | BTRFS_SUPER_MAGIC | TMPFS_MAGIC => FsType::Local,
|
||||||
|
_ => FsType::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! macOS `statfs`-based filesystem type detection.
|
||||||
|
//!
|
||||||
|
//! macOS exposes a textual `f_fstypename` field (e.g. `"apfs"`, `"hfs"`,
|
||||||
|
//! `"nfs"`, `"smbfs"`) on its `statfs` struct, which is far more
|
||||||
|
//! reliable than chasing magic numbers. Anything starting with `"nfs"`
|
||||||
|
//! counts as NFS; anything else recognised maps to `Local`; otherwise
|
||||||
|
//! `Unknown`.
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::FsType;
|
||||||
|
|
||||||
|
pub(super) fn detect_impl(path: &Path) -> FsType {
|
||||||
|
let cpath = match CString::new(path.as_os_str().as_bytes()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return FsType::Unknown,
|
||||||
|
};
|
||||||
|
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
||||||
|
let rc = unsafe { libc::statfs(cpath.as_ptr(), &mut buf) };
|
||||||
|
if rc != 0 {
|
||||||
|
return FsType::Unknown;
|
||||||
|
}
|
||||||
|
// `f_fstypename` is a NUL-terminated C string of length MFSTYPENAMELEN.
|
||||||
|
// SAFETY: libc guarantees the field is initialised to a NUL-terminated
|
||||||
|
// string by a successful statfs.
|
||||||
|
let name_ptr = buf.f_fstypename.as_ptr();
|
||||||
|
let cstr = unsafe { std::ffi::CStr::from_ptr(name_ptr) };
|
||||||
|
let name = cstr.to_bytes();
|
||||||
|
if name.starts_with(b"nfs") {
|
||||||
|
return FsType::Nfs;
|
||||||
|
}
|
||||||
|
// Recognised local types. SMB is not NFS but the buffering-policy
|
||||||
|
// outcome on macOS is the same as for any other local FS — there's
|
||||||
|
// no `WritebackFile` machinery to opt out of on this OS.
|
||||||
|
match name {
|
||||||
|
b"apfs" | b"hfs" | b"exfat" | b"msdos" | b"tmpfs" | b"smbfs" | b"webdav" => FsType::Local,
|
||||||
|
_ => FsType::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Filesystem-type detection.
|
||||||
|
//!
|
||||||
|
//! The buffering architecture (Phase 2) selects different output sinks
|
||||||
|
//! for local vs network filesystems: NFS gets the adaptive
|
||||||
|
//! `WritebackFile` machinery on Linux; local disks get `LocalFileSink`
|
||||||
|
//! and rely on the kernel's default writeback policy. This module
|
||||||
|
//! provides the construction-site primitive that picks which one.
|
||||||
|
//!
|
||||||
|
//! Per the per-OS file-split convention, the actual `statfs` call lives
|
||||||
|
//! in the matching platform file (`linux.rs`, `macos.rs`, `windows.rs`,
|
||||||
|
//! `other.rs`); this `mod.rs` exposes only the cross-platform enum and
|
||||||
|
//! the `detect` entry point.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// What kind of filesystem a path lives on, to the extent we can tell
|
||||||
|
/// cheaply at construction time.
|
||||||
|
///
|
||||||
|
/// `Unknown` is the fail-open default: a misdetection here should not
|
||||||
|
/// be load-bearing for correctness, only for the choice of sink (and
|
||||||
|
/// hence buffering policy). Callers that need a binary local/non-local
|
||||||
|
/// answer should treat `Unknown` as "probably local".
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FsType {
|
||||||
|
/// A local on-disk filesystem (ext4, xfs, btrfs, apfs, ntfs, …).
|
||||||
|
Local,
|
||||||
|
/// A network filesystem with NFS-like semantics. The current
|
||||||
|
/// detector lumps SMB / UNC into this on Windows because the
|
||||||
|
/// buffering policy outcome is the same.
|
||||||
|
Nfs,
|
||||||
|
/// `statfs` failed, the filesystem type is not on our recognised
|
||||||
|
/// list, or we are on an OS without a real implementation.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
mod other;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use linux::detect_impl;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos::detect_impl;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use windows::detect_impl;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
use other::detect_impl;
|
||||||
|
|
||||||
|
/// Best-effort classification of the filesystem under `path`.
|
||||||
|
///
|
||||||
|
/// Falls back to [`FsType::Unknown`] on any syscall error or unrecognised
|
||||||
|
/// filesystem signature. Never panics. Never blocks beyond the cost of
|
||||||
|
/// a single `statfs(2)` (Unix) or a string check (Windows).
|
||||||
|
pub fn detect(path: &Path) -> FsType {
|
||||||
|
detect_impl(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_does_not_panic_on_missing_path() {
|
||||||
|
// Non-existent path should fall through to Unknown, not panic.
|
||||||
|
let p = std::path::Path::new("/this/path/should/not/exist/freemkv-test");
|
||||||
|
let r = detect(p);
|
||||||
|
// We don't assert == Unknown because Windows' heuristic looks at
|
||||||
|
// the leading bytes and might still classify; just confirm the
|
||||||
|
// call returns rather than panicking.
|
||||||
|
let _ = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn macos_tmp_is_local() {
|
||||||
|
// `/tmp` on macOS dev rigs is APFS via the symlink to
|
||||||
|
// `/private/tmp`. Either way, never NFS.
|
||||||
|
let r = detect(std::path::Path::new("/tmp"));
|
||||||
|
assert!(
|
||||||
|
matches!(r, FsType::Local | FsType::Unknown),
|
||||||
|
"expected Local or Unknown for /tmp on macOS, got {r:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn linux_tmp_is_local_or_unknown() {
|
||||||
|
// `/tmp` is tmpfs on most distros (which we recognise) but
|
||||||
|
// could be ext4 on others. NFS would be unusual.
|
||||||
|
let r = detect(std::path::Path::new("/tmp"));
|
||||||
|
assert!(
|
||||||
|
matches!(r, FsType::Local | FsType::Unknown),
|
||||||
|
"expected Local or Unknown for /tmp on Linux, got {r:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real NFS exercise needs an actual NFS mount and isn't available
|
||||||
|
/// in CI. Kept here as a manual probe.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs an NFS mount path (e.g. /mnt/nfs/...) to validate live"]
|
||||||
|
fn nfs_path_classified_as_nfs() {
|
||||||
|
// Operator passes the mount as FREEMKV_NFS_PROBE; gated behind
|
||||||
|
// `--ignored` because there's no portable NFS path.
|
||||||
|
let p = std::env::var("FREEMKV_NFS_PROBE").expect("set FREEMKV_NFS_PROBE");
|
||||||
|
assert_eq!(detect(std::path::Path::new(&p)), FsType::Nfs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! Fallback fs-type detection for platforms without a specific impl.
|
||||||
|
//!
|
||||||
|
//! Always returns [`FsType::Unknown`]. Callers treat that as
|
||||||
|
//! "probably local" and pick `LocalFileSink`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::FsType;
|
||||||
|
|
||||||
|
pub(super) fn detect_impl(_path: &Path) -> FsType {
|
||||||
|
FsType::Unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! Windows filesystem-type detection.
|
||||||
|
//!
|
||||||
|
//! Heuristic-only: any UNC path (`\\server\share\...`) is treated as a
|
||||||
|
//! network mount and bucketed into `Nfs`. Strictly, SMB is not NFS, but
|
||||||
|
//! the buffering-policy outcome for our purposes is the same — there is
|
||||||
|
//! no platform `WritebackFile` machinery on Windows yet, so the worst
|
||||||
|
//! case of a false positive is using `LocalFileSink` regardless. A
|
||||||
|
//! proper `GetVolumeInformation` query is a Phase 4 concern.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::FsType;
|
||||||
|
|
||||||
|
pub(super) fn detect_impl(path: &Path) -> FsType {
|
||||||
|
// `Path::starts_with("\\\\")` won't match because component-wise
|
||||||
|
// matching strips the prefix. Look at the raw OsStr instead.
|
||||||
|
let s = path.as_os_str();
|
||||||
|
// OsStr -> [u16] is the platform-correct way on Windows, but
|
||||||
|
// checking the leading bytes via `to_string_lossy` is good enough
|
||||||
|
// for a UNC prefix probe and works on any encoding.
|
||||||
|
let lossy = s.to_string_lossy();
|
||||||
|
if lossy.starts_with("\\\\") || lossy.starts_with("//") {
|
||||||
|
return FsType::Nfs;
|
||||||
|
}
|
||||||
|
FsType::Local
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Platform-specific drive initialization and disc probing.
|
//! Platform-specific drive initialization and disc probing.
|
||||||
|
|
||||||
|
pub mod fs_type;
|
||||||
pub mod mt1959;
|
pub mod mt1959;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|||||||
Reference in New Issue
Block a user