Skip to main content

runsync_transfer/io/
writer.rs

1//! Destination-side writes.
2//!
3//! Files are written to a `.part` sidecar and renamed into place only after the
4//! last chunk lands and the hash verifies. A crash therefore leaves an obvious
5//! partial file that resume can pick up, never a truncated file that looks
6//! complete.
7
8use crate::error::Result;
9use parking_lot::Mutex;
10use std::collections::HashMap;
11use std::fs::{File, OpenOptions};
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// Suffix for in-progress files.
17pub const PART_SUFFIX: &str = ".rst-part";
18
19/// One destination file, open for concurrent positional writes.
20pub struct WriteHandle {
21    file: File,
22    part_path: PathBuf,
23    final_path: PathBuf,
24    size: u64,
25    /// True when this session created the partial file. Only then is the file
26    /// known to be all zeros where nothing has been written, which is what
27    /// makes it safe to skip writing an all-zero chunk.
28    created_new: bool,
29}
30
31impl WriteHandle {
32    /// Open the partial file, seeding it from an existing copy where the
33    /// filesystem can do that for free.
34    ///
35    /// Delta sync reuses most blocks of an unchanged file, and the receiver
36    /// would otherwise copy every one of them out of the old file and into the
37    /// new one — gigabytes of local I/O to reproduce bytes that are already on
38    /// the disk. A copy-on-write clone shares the extents instead, so the
39    /// seeding is O(1) and only the chunks that genuinely differ are written.
40    /// Where the filesystem cannot clone, this falls back to the ordinary path
41    /// and the per-chunk copies happen as before.
42    pub fn open_cloned(final_path: &Path, size: u64, preallocate: bool) -> Result<Self> {
43        let part_path = part_path_for(final_path);
44        if !part_path.exists() && final_path.exists() && clone_file(final_path, &part_path) {
45            if let Ok(file) = OpenOptions::new().read(true).write(true).open(&part_path) {
46                if file.metadata()?.len() != size {
47                    file.set_len(size)?;
48                }
49                return Ok(Self {
50                    file,
51                    part_path,
52                    final_path: final_path.to_path_buf(),
53                    size,
54                    // A clone is not a file we created empty: it starts full of
55                    // the old contents, so nothing may assume unwritten regions
56                    // read as zeros.
57                    created_new: false,
58                });
59            }
60        }
61        Self::open(final_path, size, preallocate)
62    }
63
64    /// Open (or reopen) the partial file for `final_path`, sized to `size`.
65    pub fn open(final_path: &Path, size: u64, preallocate: bool) -> Result<Self> {
66        if let Some(parent) = final_path.parent() {
67            std::fs::create_dir_all(parent)
68                .map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", parent.display())))?;
69        }
70        let part_path = part_path_for(final_path);
71        // Try to create exclusively first: succeeding tells us the file is
72        // brand new, and therefore reads as zeros everywhere we have not
73        // written. That fact is what the sparse path depends on.
74        let (file, created_new) = match OpenOptions::new()
75            .create_new(true)
76            .read(true)
77            .write(true)
78            .open(&part_path)
79        {
80            Ok(f) => (f, true),
81            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
82                let f = OpenOptions::new()
83                    .read(true)
84                    .write(true)
85                    .truncate(false)
86                    .open(&part_path)
87                    .map_err(|e| {
88                        io::Error::new(e.kind(), format!("{}: {e}", part_path.display()))
89                    })?;
90                (f, false)
91            }
92            Err(e) => {
93                return Err(
94                    io::Error::new(e.kind(), format!("{}: {e}", part_path.display())).into(),
95                )
96            }
97        };
98
99        // Reserve before sizing, and only on a file we just created.
100        //
101        // macOS `F_PREALLOCATE` appends to the end of the file rather than
102        // filling up to a target, so running it after `set_len` reserves a
103        // second copy of the whole file. Doing it while the file is still empty
104        // is the documented idiom and reserves exactly `size`. A resumed
105        // transfer skips it: those blocks are already backed, and re-reserving
106        // is precisely what over-allocated.
107        if preallocate && size > 0 && created_new {
108            reserve(&file, size)?;
109        }
110        // Then size it: positional writes into a region past EOF would
111        // otherwise each extend the file, serialising on the inode.
112        if file.metadata()?.len() != size {
113            file.set_len(size)?;
114        }
115
116        Ok(Self {
117            file,
118            part_path,
119            final_path: final_path.to_path_buf(),
120            size,
121            created_new,
122        })
123    }
124
125    pub fn size(&self) -> u64 {
126        self.size
127    }
128
129    pub fn part_path(&self) -> &Path {
130        &self.part_path
131    }
132
133    /// Whether this session created the partial file, and can therefore assume
134    /// unwritten regions read as zeros.
135    pub fn created_new(&self) -> bool {
136        self.created_new
137    }
138
139    /// Write `len` zero bytes at `offset` without allocating a buffer that big.
140    pub fn write_zeros_at(&self, offset: u64, len: usize) -> io::Result<()> {
141        const BLOCK: usize = 256 * 1024;
142        let zeros = [0u8; BLOCK];
143        let mut done = 0usize;
144        while done < len {
145            let n = BLOCK.min(len - done);
146            self.write_at(offset + done as u64, &zeros[..n])?;
147            done += n;
148        }
149        Ok(())
150    }
151
152    /// Write `buf` at `offset`. Safe to call concurrently from many threads.
153    pub fn write_at(&self, offset: u64, buf: &[u8]) -> io::Result<()> {
154        // A write landing past the declared size means the sender's manifest and
155        // its frames disagree. Refuse rather than grow the file.
156        let end = offset
157            .checked_add(buf.len() as u64)
158            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "write offset overflow"))?;
159        if end > self.size {
160            return Err(io::Error::new(
161                io::ErrorKind::InvalidInput,
162                format!(
163                    "write of {} bytes at {offset} exceeds declared size {}",
164                    buf.len(),
165                    self.size
166                ),
167            ));
168        }
169        let mut written = 0usize;
170        while written < buf.len() {
171            let n = pwrite(&self.file, &buf[written..], offset + written as u64)?;
172            if n == 0 {
173                return Err(io::Error::new(
174                    io::ErrorKind::WriteZero,
175                    "pwrite made no progress",
176                ));
177            }
178            written += n;
179        }
180        Ok(())
181    }
182
183    pub fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
184        pread(&self.file, buf, offset)
185    }
186
187    /// Is `want` already what the file holds at `offset`?
188    ///
189    /// Used to avoid rewriting bytes a copy-on-write clone already shares:
190    /// writing them would break the sharing and cost the very I/O the clone
191    /// was there to avoid.
192    pub fn matches_at(&self, offset: u64, want: &[u8]) -> io::Result<bool> {
193        let mut scratch = vec![0u8; want.len()];
194        let n = pread(&self.file, &mut scratch, offset)?;
195        Ok(n == want.len() && scratch == want)
196    }
197
198    /// Flush to stable storage. Called once per file, not per chunk — an fsync
199    /// per chunk would dominate the cost of the transfer.
200    pub fn sync(&self) -> io::Result<()> {
201        self.file.sync_all()
202    }
203
204    /// Rename the partial file into place and apply metadata.
205    ///
206    /// Takes `&self` rather than `self` because several workers may still hold
207    /// a clone of the `Arc` around this handle when the last chunk lands.
208    /// Renaming a file that is still open is safe on unix, and on Windows too:
209    /// Rust's `OpenOptions` opens with `FILE_SHARE_DELETE`. Callers guarantee
210    /// this runs once per file.
211    pub fn commit(&self, mode: u32, mtime: i64, preserve_metadata: bool) -> Result<()> {
212        self.file.sync_all()?;
213        if preserve_metadata {
214            #[cfg(unix)]
215            if mode != 0 {
216                use std::os::unix::fs::PermissionsExt;
217                let _ = self
218                    .file
219                    .set_permissions(std::fs::Permissions::from_mode(mode & 0o7777));
220            }
221        }
222
223        std::fs::rename(&self.part_path, &self.final_path).map_err(|e| {
224            io::Error::new(
225                e.kind(),
226                format!(
227                    "rename {} -> {}: {e}",
228                    self.part_path.display(),
229                    self.final_path.display()
230                ),
231            )
232        })?;
233
234        if preserve_metadata && mtime > 0 {
235            set_mtime(&self.final_path, mtime);
236        }
237        Ok(())
238    }
239
240    /// Delete the partial file. Used when a transfer aborts unrecoverably.
241    pub fn discard(&self) {
242        let _ = std::fs::remove_file(&self.part_path);
243    }
244}
245
246/// Copy-on-write clone, where the platform offers one. Returns whether it
247/// worked; a failure is never fatal, only slower.
248// The `return`s below are load-bearing: each arm is behind a `cfg`, so a tail
249// expression would be parsed as the result on every platform at once.
250#[allow(clippy::needless_return)]
251fn clone_file(src: &Path, dst: &Path) -> bool {
252    #[cfg(target_os = "macos")]
253    {
254        use std::ffi::CString;
255        let (Ok(s), Ok(d)) = (
256            CString::new(src.as_os_str().as_encoded_bytes()),
257            CString::new(dst.as_os_str().as_encoded_bytes()),
258        ) else {
259            return false;
260        };
261        // APFS shares the extents; the copy costs metadata only.
262        return unsafe { libc::clonefile(s.as_ptr(), d.as_ptr(), 0) } == 0;
263    }
264    #[cfg(target_os = "linux")]
265    {
266        use std::os::unix::io::AsRawFd;
267        // FICLONE: btrfs, XFS with reflinks, bcachefs.
268        const FICLONE: libc::c_ulong = 0x4004_9409;
269        let (Ok(from), Ok(to)) = (
270            std::fs::File::open(src),
271            OpenOptions::new().create_new(true).write(true).open(dst),
272        ) else {
273            return false;
274        };
275        let ok = unsafe { libc::ioctl(to.as_raw_fd(), FICLONE, from.as_raw_fd()) } == 0;
276        if !ok {
277            drop(to);
278            let _ = std::fs::remove_file(dst);
279        }
280        ok
281    }
282    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
283    {
284        let _ = (src, dst);
285        false
286    }
287}
288
289pub fn part_path_for(final_path: &Path) -> PathBuf {
290    let mut s = final_path.as_os_str().to_os_string();
291    s.push(PART_SUFFIX);
292    PathBuf::from(s)
293}
294
295/// Ask the filesystem to actually reserve blocks.
296///
297/// `set_len` only records a size; on a sparse-capable filesystem the blocks are
298/// not committed until written. For a 100 GB transfer that is the difference
299/// between failing on the first byte and failing eight hours in, so this asks
300/// for real reservation where the platform supports it.
301// The explicit `return`s below are load-bearing: each arm is behind a `cfg`,
302// so a tail expression would be parsed as the function's result on every
303// platform at once.
304#[allow(clippy::needless_return)]
305fn reserve(file: &File, size: u64) -> Result<()> {
306    #[cfg(target_os = "linux")]
307    {
308        use std::os::unix::io::AsRawFd;
309        // Keep the size set by set_len; only allocate the blocks.
310        let rc = unsafe {
311            libc::fallocate(
312                file.as_raw_fd(),
313                libc::FALLOC_FL_KEEP_SIZE,
314                0,
315                size as libc::off_t,
316            )
317        };
318        if rc != 0 {
319            let e = io::Error::last_os_error();
320            match e.raw_os_error() {
321                // Filesystem does not implement it (tmpfs, some network mounts).
322                // set_len already gave us the size we need; carry on.
323                Some(libc::EOPNOTSUPP) | Some(libc::ENOSYS) | Some(libc::EINVAL) => {}
324                // Out of space is exactly what this call exists to surface early.
325                _ => return Err(crate::error::Error::Io(e)),
326            }
327        }
328        return Ok(());
329    }
330    #[cfg(target_os = "macos")]
331    {
332        use std::os::unix::fs::MetadataExt;
333        use std::os::unix::io::AsRawFd;
334
335        // `F_PEOFPOSMODE` allocates `fst_length` bytes *beyond what the file
336        // already has*, not up to a target size. Asking for `size` on a file
337        // that `set_len` already sized therefore reserves a second copy — and
338        // repeating it reserves another each time. Ask only for the shortfall,
339        // which also makes this a no-op on a file that is already backed.
340        let allocated = file.metadata()?.blocks().saturating_mul(512);
341        if allocated >= size {
342            return Ok(());
343        }
344        let want = (size - allocated) as libc::off_t;
345
346        let mut store = libc::fstore_t {
347            fst_flags: libc::F_ALLOCATECONTIG,
348            fst_posmode: libc::F_PEOFPOSMODE,
349            fst_offset: 0,
350            fst_length: want,
351            fst_bytesalloc: 0,
352        };
353        unsafe {
354            // Contiguous first; fall back to any available extents.
355            if libc::fcntl(file.as_raw_fd(), libc::F_PREALLOCATE, &mut store) == -1 {
356                store.fst_flags = libc::F_ALLOCATEALL;
357                let _ = libc::fcntl(file.as_raw_fd(), libc::F_PREALLOCATE, &mut store);
358            }
359        }
360        return Ok(());
361    }
362    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
363    {
364        let _ = (file, size);
365        Ok(())
366    }
367}
368
369fn set_mtime(path: &Path, mtime: i64) {
370    #[cfg(unix)]
371    {
372        use std::ffi::CString;
373        if let Ok(c) = CString::new(path.as_os_str().as_encoded_bytes()) {
374            let times = [
375                libc::timeval {
376                    tv_sec: mtime as libc::time_t,
377                    tv_usec: 0,
378                },
379                libc::timeval {
380                    tv_sec: mtime as libc::time_t,
381                    tv_usec: 0,
382                },
383            ];
384            unsafe {
385                libc::utimes(c.as_ptr(), times.as_ptr());
386            }
387        }
388    }
389    #[cfg(not(unix))]
390    let _ = (path, mtime);
391}
392
393#[cfg(unix)]
394fn pwrite(file: &File, buf: &[u8], offset: u64) -> io::Result<usize> {
395    use std::os::unix::fs::FileExt;
396    file.write_at(buf, offset)
397}
398
399#[cfg(windows)]
400fn pwrite(file: &File, buf: &[u8], offset: u64) -> io::Result<usize> {
401    use std::os::windows::fs::FileExt;
402    file.seek_write(buf, offset)
403}
404
405#[cfg(unix)]
406fn pread(file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
407    use std::os::unix::fs::FileExt;
408    file.read_at(buf, offset)
409}
410
411#[cfg(windows)]
412fn pread(file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
413    use std::os::windows::fs::FileExt;
414    file.seek_read(buf, offset)
415}
416
417// ---------------------------------------------------------------------------
418// Registry
419// ---------------------------------------------------------------------------
420
421/// One file's slot. The inner lock makes opening happen exactly once.
422#[derive(Default)]
423struct Slot(Mutex<Option<Arc<WriteHandle>>>);
424
425/// Open write handles keyed by file id.
426///
427/// Handles are opened on the first chunk that arrives for a file rather than
428/// up front: a manifest may name a million files, and a million open
429/// descriptors is not a thing we can ask the OS for.
430#[derive(Default)]
431pub struct FileWriters {
432    map: Mutex<HashMap<u32, Arc<Slot>>>,
433}
434
435impl FileWriters {
436    pub fn new() -> Self {
437        Self::default()
438    }
439
440    /// Fetch the handle for `file_id`, opening it with `make` if absent.
441    ///
442    /// `make` runs **at most once per file**, even when every worker hits the
443    /// same file's first chunk simultaneously. That is not just an efficiency
444    /// point: opening a destination preallocates it, so letting several racing
445    /// callers each open the same file reserved the file's full size several
446    /// times over and could exhaust the disk on a large transfer.
447    ///
448    /// The lock taken during `make` is per file, so opening one file never
449    /// blocks writers on another.
450    pub fn get_or_open<F>(&self, file_id: u32, make: F) -> Result<Arc<WriteHandle>>
451    where
452        F: FnOnce() -> Result<WriteHandle>,
453    {
454        let slot = {
455            let mut map = self.map.lock();
456            map.entry(file_id).or_default().clone()
457        };
458        let mut guard = slot.0.lock();
459        if let Some(h) = guard.as_ref() {
460            return Ok(h.clone());
461        }
462        let handle = Arc::new(make()?);
463        *guard = Some(handle.clone());
464        Ok(handle)
465    }
466
467    pub fn take(&self, file_id: u32) -> Option<Arc<WriteHandle>> {
468        let slot = self.map.lock().remove(&file_id)?;
469        let mut guard = slot.0.lock();
470        guard.take()
471    }
472
473    pub fn len(&self) -> usize {
474        self.map
475            .lock()
476            .values()
477            .filter(|s| s.0.lock().is_some())
478            .count()
479    }
480
481    pub fn is_empty(&self) -> bool {
482        self.len() == 0
483    }
484
485    /// Drop every handle, deleting partial files. Used on unrecoverable abort.
486    pub fn discard_all(&self) {
487        let slots: Vec<_> = self.map.lock().drain().map(|(_, s)| s).collect();
488        for s in slots {
489            if let Some(h) = s.0.lock().take() {
490                h.discard();
491            }
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn writes_out_of_order_then_commits() {
502        let tmp = tempfile::tempdir().unwrap();
503        let dest = tmp.path().join("nested/dir/out.bin");
504        let h = WriteHandle::open(&dest, 3000, true).unwrap();
505
506        // Deliberately out of order, as chunks arrive across parallel streams.
507        h.write_at(2000, &[3u8; 1000]).unwrap();
508        h.write_at(0, &[1u8; 1000]).unwrap();
509        h.write_at(1000, &[2u8; 1000]).unwrap();
510
511        assert!(h.part_path().exists());
512        assert!(!dest.exists(), "must not appear until committed");
513
514        h.commit(0o640, 1_600_000_000, true).unwrap();
515        assert!(dest.exists());
516        assert!(!part_path_for(&dest).exists());
517
518        let got = std::fs::read(&dest).unwrap();
519        assert_eq!(got.len(), 3000);
520        assert_eq!(&got[0..1000], &[1u8; 1000]);
521        assert_eq!(&got[1000..2000], &[2u8; 1000]);
522        assert_eq!(&got[2000..3000], &[3u8; 1000]);
523
524        #[cfg(unix)]
525        {
526            use std::os::unix::fs::PermissionsExt;
527            let m = std::fs::metadata(&dest).unwrap();
528            assert_eq!(m.permissions().mode() & 0o777, 0o640);
529        }
530    }
531
532    #[test]
533    fn refuses_writes_past_declared_size() {
534        let tmp = tempfile::tempdir().unwrap();
535        let h = WriteHandle::open(&tmp.path().join("a.bin"), 100, false).unwrap();
536        assert!(h.write_at(90, &[0u8; 20]).is_err());
537        assert!(h.write_at(u64::MAX, &[0u8; 1]).is_err());
538        h.write_at(90, &[0u8; 10]).unwrap();
539    }
540
541    #[test]
542    fn concurrent_writers_share_one_handle() {
543        let tmp = tempfile::tempdir().unwrap();
544        let dest = tmp.path().join("big.bin");
545        let h = Arc::new(WriteHandle::open(&dest, 64 * 4096, true).unwrap());
546
547        let threads: Vec<_> = (0..8u64)
548            .map(|t| {
549                let h = h.clone();
550                std::thread::spawn(move || {
551                    for i in 0..8u64 {
552                        let idx = t * 8 + i;
553                        h.write_at(idx * 4096, &vec![idx as u8; 4096]).unwrap();
554                    }
555                })
556            })
557            .collect();
558        for t in threads {
559            t.join().unwrap();
560        }
561
562        h.commit(0o644, 0, true).unwrap();
563        let got = std::fs::read(&dest).unwrap();
564        for idx in 0..64u64 {
565            let s = (idx * 4096) as usize;
566            assert!(
567                got[s..s + 4096].iter().all(|&b| b == idx as u8),
568                "chunk {idx}"
569            );
570        }
571    }
572
573    #[test]
574    fn reopening_a_partial_preserves_bytes() {
575        let tmp = tempfile::tempdir().unwrap();
576        let dest = tmp.path().join("resume.bin");
577        {
578            let h = WriteHandle::open(&dest, 8192, true).unwrap();
579            h.write_at(0, &[9u8; 4096]).unwrap();
580            h.sync().unwrap();
581        }
582        // A second open must not truncate what the first one wrote.
583        let h = WriteHandle::open(&dest, 8192, true).unwrap();
584        let mut buf = [0u8; 4096];
585        h.read_at(0, &mut buf).unwrap();
586        assert!(buf.iter().all(|&b| b == 9));
587    }
588
589    #[test]
590    fn registry_opens_once_under_contention() {
591        let tmp = tempfile::tempdir().unwrap();
592        let reg = Arc::new(FileWriters::new());
593        let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0));
594
595        let threads: Vec<_> = (0..16)
596            .map(|_| {
597                let reg = reg.clone();
598                let opens = opens.clone();
599                let path = tmp.path().join("shared.bin");
600                std::thread::spawn(move || {
601                    reg.get_or_open(1, || {
602                        opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
603                        WriteHandle::open(&path, 1024, false)
604                    })
605                    .unwrap()
606                })
607            })
608            .collect();
609        let handles: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect();
610
611        // Every caller must observe the same handle, whatever the race did.
612        for h in &handles {
613            assert!(Arc::ptr_eq(h, &handles[0]));
614        }
615        assert_eq!(reg.len(), 1);
616        // The part this originally missed: opening is side-effecting — it
617        // preallocates — so it must happen exactly once, not merely produce one
618        // surviving handle.
619        assert_eq!(
620            opens.load(std::sync::atomic::Ordering::SeqCst),
621            1,
622            "the destination was opened more than once"
623        );
624    }
625
626    #[cfg(unix)]
627    fn allocated_bytes(p: &Path) -> u64 {
628        use std::os::unix::fs::MetadataExt;
629        std::fs::metadata(p)
630            .map(|m| m.blocks().saturating_mul(512))
631            .unwrap_or(0)
632    }
633
634    /// A preallocated file must reserve its own size and no more.
635    ///
636    /// This is the check that was missing: the reservation used to be issued
637    /// after the file was sized, and macOS `F_PREALLOCATE` appends rather than
638    /// fills, so an N-byte destination consumed 2N — or N per racing worker.
639    #[cfg(unix)]
640    #[test]
641    fn preallocation_reserves_exactly_the_file_size() {
642        let tmp = tempfile::tempdir().unwrap();
643        let dest = tmp.path().join("prealloc.bin");
644        let size = 64 * 1024 * 1024;
645
646        {
647            let h = WriteHandle::open(&dest, size, true).unwrap();
648            h.write_at(0, &[1u8; 4096]).unwrap();
649            h.sync().unwrap();
650        }
651        let after_first = allocated_bytes(&part_path_for(&dest));
652        assert!(
653            after_first <= size + size / 8,
654            "a {size} byte file reserved {after_first} bytes on first open"
655        );
656
657        // Reopening, as a resumed transfer does, must not reserve again.
658        for _ in 0..3 {
659            let h = WriteHandle::open(&dest, size, true).unwrap();
660            h.write_at(0, &[1u8; 4096]).unwrap();
661            h.sync().unwrap();
662        }
663        let after_reopens = allocated_bytes(&part_path_for(&dest));
664        assert!(
665            after_reopens <= size + size / 8,
666            "reopening grew the reservation to {after_reopens} for a {size} byte file"
667        );
668    }
669
670    #[test]
671    fn zero_length_file_commits() {
672        let tmp = tempfile::tempdir().unwrap();
673        let dest = tmp.path().join("empty.bin");
674        let h = WriteHandle::open(&dest, 0, true).unwrap();
675        h.commit(0o644, 0, true).unwrap();
676        assert!(dest.exists());
677        assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0);
678    }
679}