libfreemkv 1.1.0

Open source raw disc access library for optical drives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
//! `WritebackFile` — a `File` wrapper whose reason for existing is the
//! bounded-cache writeback pipeline.
//!
//! Why: large sequential writes (sweep, patch, mux on UHD-scale output)
//! left to the kernel's default writeback policy accumulate hundreds of
//! megabytes of dirty pages and then burst-flush, stalling subsequent
//! writes for seconds at a time. `WritebackFile` drives a continuous
//! [`super::writeback::WritebackPipeline`] that on Linux issues
//! incremental `sync_file_range` + `posix_fadvise(DONTNEED)` calls at
//! 32 MB granularity so dirty pages drain at the same rate they're
//! produced. macOS and Windows fall through to a no-op pipeline — their
//! default cache policies have not been shown to exhibit the same
//! pathology for this access pattern.
//!
//! It implements `Write` and `Seek` so any call site that wrote to a
//! plain `File` through those traits (sweep, patch, mux) can swap in
//! `WritebackFile` without touching the body of the loop. The wrapper
//! also tracks the current file position to feed the pipeline with
//! progress + seek boundaries.
//!
//! See `super::writeback::linux` for the underlying pathology and the
//! strategy.
//!
//! ## Platform split
//!
//! The platform-specific pieces of this wrapper — extent preallocation
//! (Linux `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows no-op
//! today) and the durable-flush primitive (Linux/macOS
//! `fsync`/`F_FULLFSYNC` wrapped in a bounded syscall; Windows plain
//! `FlushFileBuffers`, unbounded) — live in per-OS sibling modules. The
//! dispatch happens once at the bottom of this file via cfg-gated `mod`
//! decls. No inline `#[cfg(target_os = "...")]` in the business-logic
//! above.
//!
//! ## Write path
//!
//! Writes are direct passthrough to the underlying `File` (no writer
//! thread, no ring, no batching). Empirically a writer-thread
//! architecture introduced a ~60% mux throughput regression on NFS
//! bidirectional workloads; the direct-passthrough write path is faster.
//! The writeback pipeline still runs (it's called inline from `write` /
//! `write_all` / `seek`) so the bounded-cache invariant on Linux is
//! preserved.
//!
//! ## Halt-safety
//!
//! `sync_all` runs the per-OS durable-flush primitive. On Linux/macOS
//! it is wrapped in [`crate::io::bounded::bounded_syscall`] with a 60 s
//! deadline, so a wedged NFS server cannot trap the muxer indefinitely
//! on the final fsync. Windows is a known deviation: its `durable_sync`
//! calls `File::sync_all` (`FlushFileBuffers`) directly and is NOT
//! bounded — a wedged UNC/SMB share can block the final flush there.

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
mod other;
#[cfg(target_os = "windows")]
mod windows;

#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
use other as platform;
#[cfg(target_os = "windows")]
use windows as platform;

use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;

use super::writeback::WritebackPipeline;

/// Granularity at which the Linux writeback pipeline issues
/// `sync_file_range` pairs. 32 MiB is the empirically best value on a
/// 1 GbE NFS mount backed by a single spinning disk: 8 MiB / 64 MiB /
/// 128 MiB all measured worse. Override via `FREEMKV_WRITEBACK_CHUNK_MIB`
/// — faster backends (NVMe, RAID) may tolerate larger windows.
const WRITEBACK_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;

/// Upper bound (in MiB) accepted from `FREEMKV_WRITEBACK_CHUNK_MIB`.
/// 64 GiB — far above `CHUNK_BYTES_MAX` (256 MiB), generous for any
/// real backend, and small enough that `n * 1024 * 1024` cannot wrap
/// `u64`. Out-of-range values fall back to the default.
const WRITEBACK_CHUNK_MIB_MAX: u64 = 64 * 1024;

fn writeback_chunk_bytes() -> u64 {
    std::env::var("FREEMKV_WRITEBACK_CHUNK_MIB")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .filter(|&n| n > 0 && n <= WRITEBACK_CHUNK_MIB_MAX)
        .map(|n| n * 1024 * 1024)
        .unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
}

pub(crate) struct WritebackFile {
    file: File,
    pipeline: WritebackPipeline,
    pos: u64,
    /// Count of position-moving seeks (for the finalize summary). The MKV muxer
    /// seeks back occasionally (cluster size patching, Cues, Segment header
    /// backpatch); the per-seek DEBUG line is trace-level now, and this rolls
    /// the total into one finalize summary.
    seek_count: u64,
    /// Sum of |delta| over all position-moving seeks, in bytes.
    seek_bytes: u64,
}

impl WritebackFile {
    /// Wrap an open `File`. The current OS file position is queried
    /// once so the pipeline starts tracking from wherever the file
    /// already is (typically 0 for fresh files; non-zero for resumed
    /// or appended files).
    pub(crate) fn new(mut file: File) -> io::Result<Self> {
        let pos = file.stream_position()?;
        let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
        Ok(Self {
            file,
            pipeline,
            pos,
            seek_count: 0,
            seek_bytes: 0,
        })
    }

    /// Create a new file at `path` (truncating any existing contents)
    /// and wrap it. Convenience for the common
    /// `File::create(path)` + `WritebackFile::new(file)` pair so callers
    /// don't have to assemble a `File` first.
    ///
    /// Callers that know the target output size should prefer
    /// [`Self::create_with_size_hint`] so the kernel can pre-reserve
    /// extents.
    #[allow(dead_code)]
    pub(crate) fn create(path: &Path) -> io::Result<Self> {
        let file = File::create(path)?;
        Self::new(file)
    }

    /// Like [`Self::create`] but pre-reserves `size_bytes` of disk
    /// space via the platform's extent-preallocation primitive (Linux
    /// `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows
    /// `SetFileValidData` stub). The reported file size is unchanged
    /// (writes still grow the file naturally) — only the on-disk extent
    /// allocation is preallocated, which reduces extent fragmentation
    /// on large sequential writes (mux output, especially on slow
    /// storage / NFS).
    ///
    /// On platforms without an extent-preallocation primitive this is
    /// equivalent to `create` — the size hint is dropped after a debug
    /// log.
    pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
        let file = File::create(path)?;
        platform::preallocate(&file, size_bytes);
        Self::new(file)
    }

    /// Open an existing file at `path` for writing (no truncation) and
    /// wrap it. Mirrors `File::open` semantics for the writable case
    /// — used by patch / resume paths that mutate an existing ISO in
    /// place.
    pub(crate) fn open(path: &Path) -> io::Result<Self> {
        let file = OpenOptions::new().write(true).open(path)?;
        Self::new(file)
    }

    /// Drain in-flight writeback then issue a full fsync. Use this in
    /// place of `File::sync_all`.
    ///
    /// The final durable flush is wrapped in
    /// [`crate::io::bounded::bounded_syscall`] (per the per-OS module)
    /// with a 60 s deadline on Linux/macOS — a wedged NFS server cannot
    /// trap the calling thread indefinitely. On timeout the page cache
    /// is left to the kernel's normal flush-on-close path — best
    /// effort, but bounded.
    ///
    /// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
    /// guarantee the data is durable if the bounded fsync timed out or
    /// was halted — only the hang is bounded, the fsync may not have
    /// completed. Callers needing crash-consistency (e.g. mux-finish
    /// then external commit/DB update) must not treat `Ok(())` as a
    /// durability barrier.
    pub(crate) fn sync_all(&mut self) -> io::Result<()> {
        if self.seek_count > 0 {
            tracing::debug!(
                target: "mux",
                "WritebackFile finalize: {} seeks, {} bytes seeked total",
                self.seek_count,
                self.seek_bytes
            );
        }
        self.pipeline.finalize();
        platform::durable_sync(&self.file)
    }
}

impl Write for WritebackFile {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let n = self.file.write(buf)?;
        self.pos += n as u64;
        self.pipeline.note_progress(self.pos);
        Ok(n)
    }

    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
        self.file.write_all(buf)?;
        self.pos += buf.len() as u64;
        self.pipeline.note_progress(self.pos);
        Ok(())
    }

    fn flush(&mut self) -> io::Result<()> {
        self.file.flush()
    }
}

impl Seek for WritebackFile {
    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
        let p = self.file.seek(from)?;
        // Only treat seeks that actually move the position as
        // boundaries — sweep does a redundant `seek(Current(pos))`
        // before every write, and we don't want that to drain the
        // pipeline on every iteration.
        if p != self.pos {
            // Diagnostic for the NFS mux hang: the MKV format requires
            // the muxer to seek back occasionally (cluster size
            // patching, Cues index write, Segment header backpatch).
            // Each such seek invalidates the writeback chunk tracking
            // and forces a finalize → WAIT_AFTER on the in-flight
            // chunk. Logging the seek delta lets us correlate hang
            // offsets with specific muxer operations.
            let from_pos = self.pos;
            let to_pos = p;
            let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
            // Per-seek detail is trace-level (L4) — benign and high-frequency.
            // The aggregate (count + total bytes) is logged once at finalize.
            tracing::trace!(
                target: "mux",
                "WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
            );
            self.seek_count += 1;
            self.seek_bytes += delta.unsigned_abs();
            self.pipeline.handle_seek(p);
            self.pos = p;
        }
        Ok(p)
    }
}

impl super::sink::SequentialSink for WritebackFile {
    /// Drain the writeback pipeline and run the bounded durable flush —
    /// the same work [`Self::sync_all`] does. Implemented explicitly (no
    /// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
    /// `finish()` actually finalises + fsyncs instead of hitting a no-op
    /// default. Note the bounded-fsync caveat from [`Self::sync_all`]
    /// applies: `Ok(())` is not a durability barrier if the fsync timed
    /// out or was halted.
    fn finish(&mut self) -> io::Result<()> {
        self.sync_all()
    }
}

impl super::sink::RandomAccessSink for WritebackFile {}

impl Drop for WritebackFile {
    fn drop(&mut self) {
        // Run the pipeline's tail finalize so the last in-flight chunk
        // gets its `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. Without
        // this, callers that drop a `WritebackFile` without calling
        // `sync_all` (panic, early-return, idiomatic `let _ = w;`)
        // leave the trailing chunk in cache; the kernel still flushes
        // on close, but the bounded-cache invariant fails at the tail.
        // We deliberately do *not* call `self.file.sync_all()` here —
        // close already triggers a flush, and an `fsync` from `Drop`
        // would silently swallow its `io::Error` anyway. `finalize` is
        // idempotent so an explicit `sync_all` followed by drop is
        // still safe.
        self.pipeline.finalize();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Read;

    fn read_back(path: &Path) -> Vec<u8> {
        let mut f = File::open(path).unwrap();
        let mut v = Vec::new();
        f.read_to_end(&mut v).unwrap();
        v
    }

    #[test]
    fn write_then_drop_persists_bytes() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("a.bin");
        {
            let mut w = WritebackFile::create(&p).unwrap();
            w.write_all(b"hello world").unwrap();
            // Drop drains the pipeline tail.
        }
        assert_eq!(read_back(&p), b"hello world");
    }

    #[test]
    fn sync_all_drains_and_flushes() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("b.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        for _ in 0..32 {
            w.write_all(&[0x5au8; 1024]).unwrap();
        }
        // After sync_all, the bytes MUST be visible to a separate
        // reader. The pipeline has been finalised and durable-sync has
        // run.
        w.sync_all().unwrap();
        let bytes = read_back(&p);
        assert_eq!(bytes.len(), 32 * 1024);
        assert!(bytes.iter().all(|&b| b == 0x5a));
        drop(w);
    }

    #[test]
    fn seek_then_patch_roundtrip() {
        // Write A; seek back; patch with B; read back; the patch lands
        // at the right offset.
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("c.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        let big = vec![b'A'; 4096];
        w.write_all(&big).unwrap();
        // Seek back to offset 1000 and overwrite 8 bytes.
        w.seek(SeekFrom::Start(1000)).unwrap();
        w.write_all(b"PATCHED!").unwrap();
        w.sync_all().unwrap();
        drop(w);
        let bytes = read_back(&p);
        assert_eq!(bytes.len(), 4096);
        assert_eq!(&bytes[1000..1008], b"PATCHED!");
        // Bytes outside the patch are still 'A'.
        assert_eq!(bytes[999], b'A');
        assert_eq!(bytes[1008], b'A');
    }

    #[test]
    fn flush_is_observed_in_order() {
        // `Write::flush` should not panic or reorder; verify the bytes
        // land in order through interleaved flushes.
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("f.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        w.write_all(b"one").unwrap();
        w.flush().unwrap();
        w.write_all(b"two").unwrap();
        w.flush().unwrap();
        w.write_all(b"three").unwrap();
        w.sync_all().unwrap();
        drop(w);
        assert_eq!(read_back(&p), b"onetwothree");
    }

    /// finish() through a `dyn RandomAccessSink` trait object must
    /// dispatch to WritebackFile's override (finalize + durable_sync),
    /// not a no-op default. Bytes must be visible to a separate reader
    /// before drop.
    #[test]
    fn finish_through_trait_object_persists() {
        use crate::io::sink::RandomAccessSink;
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("finish-dyn.bin");
        let w = WritebackFile::create(&p).unwrap();
        let mut boxed: Box<dyn RandomAccessSink> = Box::new(w);
        boxed.write_all(b"durable-tail").unwrap();
        boxed.finish().unwrap();
        assert_eq!(read_back(&p), b"durable-tail");
    }

    // ── Added hardening tests ───────────────────────────────────────

    /// `write` (not write_all) must return the count the inner File
    /// reported and advance `pos` by exactly that count (lines
    /// 185-189). For a regular file a single `write` of a small buffer
    /// writes all of it. We verify the returned count equals the buffer
    /// length AND that a subsequent seek reports the right position.
    /// Mutation: changing `self.pos += n` to `self.pos += buf.len()`
    /// (lines 187 vs a hypothetical bug) would desync on a partial
    /// write; here they coincide, but `Seek(Current(0))` reflecting `n`
    /// still guards the count return value.
    #[test]
    fn write_returns_byte_count_and_advances_pos() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("wc.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        let n = w.write(b"twelve bytes").unwrap();
        assert_eq!(n, 12, "write must report bytes written");
        // pos is private; observe it via the public Seek impl's
        // stream_position (which resolves to seek(Current(0))).
        let pos = w.stream_position().unwrap();
        assert_eq!(pos, 12, "pos not advanced by write count");
        w.sync_all().unwrap();
        drop(w);
        assert_eq!(read_back(&p), b"twelve bytes");
    }

    /// Redundant seek to the CURRENT position must be a no-op for the
    /// pipeline (lines 211-228 only act when `p != self.pos`). This is
    /// the documented sweep optimisation: sweep does
    /// `seek(Current(pos))` before every write and we must not treat it
    /// as a boundary. We can only observe the public effect: the seek
    /// returns the same offset and writes continue contiguously.
    /// Mutation: removing the `if p != self.pos` guard (line 211) would
    /// call handle_seek on every redundant seek — on the noop pipeline
    /// (macOS) this stays correct for data, but the contiguity +
    /// returned-offset invariant still must hold and is asserted here.
    #[test]
    fn seek_to_current_position_is_noop_for_data() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("noop-seek.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        w.write_all(b"AAAA").unwrap();
        // Seek to the current end (offset 4) — a no-move seek.
        let off = w.seek(SeekFrom::Start(4)).unwrap();
        assert_eq!(off, 4);
        w.write_all(b"BBBB").unwrap();
        w.sync_all().unwrap();
        drop(w);
        assert_eq!(
            read_back(&p),
            b"AAAABBBB",
            "redundant seek corrupted contiguous write"
        );
    }

    /// `open` (no-truncate) must preserve existing file contents and
    /// allow in-place patching from offset 0 — distinct from `create`
    /// which truncates (lines 157-160 use OpenOptions write-only, no
    /// truncate). We pre-seed a file, reopen with `open`, overwrite the
    /// first bytes, and confirm the tail survives. Mutation: if `open`
    /// used `File::create` (truncate) the tail would be lost.
    #[test]
    fn open_preserves_existing_contents() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("reopen.bin");
        std::fs::write(&p, b"ORIGINAL-CONTENT").unwrap();
        let mut w = WritebackFile::open(&p).unwrap();
        // open() does NOT truncate; pos starts at 0. Overwrite the
        // first 8 bytes only.
        w.write_all(b"PATCHED!").unwrap();
        w.sync_all().unwrap();
        drop(w);
        // First 8 bytes overwritten; the rest of ORIGINAL-CONTENT
        // ("-CONTENT") survives because there was no truncation.
        assert_eq!(read_back(&p), b"PATCHED!-CONTENT");
    }

    /// `open` on a file whose position is queried must start tracking
    /// from the file's current offset. `WritebackFile::new` calls
    /// `stream_position()` (line 112); a freshly `open`ed file is at
    /// offset 0. After writing, seeking Current(0) must reflect the
    /// bytes written from 0. Mutation: if `new` hardcoded pos=0 instead
    /// of querying, a non-zero starting offset would desync — covered
    /// indirectly; here we assert the offset is exactly the write size.
    #[test]
    fn new_tracks_initial_position() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("pos-init.bin");
        std::fs::write(&p, b"0123456789").unwrap();
        let mut w = WritebackFile::open(&p).unwrap();
        let start = w.stream_position().unwrap();
        assert_eq!(start, 0, "freshly opened file should start at offset 0");
        w.write_all(b"XY").unwrap();
        let after = w.stream_position().unwrap();
        assert_eq!(after, 2, "pos must advance by written length");
    }

    /// Seek past EOF then write must create a sparse hole that reads
    /// back as zeros — standard POSIX file semantics that the wrapper
    /// must not break (it forwards seek to the inner File at line 205).
    /// Mutation: if `seek` clamped or mishandled the offset, the hole
    /// size/zero-fill would be wrong.
    #[test]
    fn seek_past_eof_creates_zero_hole() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("hole.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        w.write_all(b"head").unwrap(); // bytes 0..4
        w.seek(SeekFrom::Start(20)).unwrap(); // jump past EOF
        w.write_all(b"tail").unwrap(); // bytes 20..24
        w.sync_all().unwrap();
        drop(w);
        let bytes = read_back(&p);
        assert_eq!(
            bytes.len(),
            24,
            "file should extend to the last written byte"
        );
        assert_eq!(&bytes[0..4], b"head");
        // The 4..20 gap must read back as zeros (sparse hole).
        assert!(bytes[4..20].iter().all(|&b| b == 0), "hole not zero-filled");
        assert_eq!(&bytes[20..24], b"tail");
    }

    /// `SeekFrom::End` must resolve against the actual file length.
    /// After writing 10 bytes, `seek(End(-2))` lands at offset 8;
    /// overwriting 2 bytes there patches the tail. Mutation: forwarding
    /// the wrong SeekFrom variant would land at the wrong offset.
    #[test]
    fn seek_from_end_resolves_against_length() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("end-seek.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        w.write_all(b"0123456789").unwrap();
        let landed = w.seek(SeekFrom::End(-2)).unwrap();
        assert_eq!(landed, 8, "End(-2) of a 10-byte file is offset 8");
        w.write_all(b"XY").unwrap();
        w.sync_all().unwrap();
        drop(w);
        assert_eq!(read_back(&p), b"01234567XY");
    }

    /// `create_with_size_hint` must produce a normal, writable file
    /// whose *reported size* tracks bytes written (the hint only
    /// reserves extents, per the doc lines 137-145 — it must NOT
    /// pre-grow the logical file length). We write 5 bytes against a
    /// 1 MiB hint and the file must be exactly 5 bytes long.
    /// Mutation: if the hint path truncated/extended to size_bytes the
    /// length would be 1 MiB and this fails.
    #[test]
    fn create_with_size_hint_does_not_inflate_logical_length() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("hint-len.bin");
        let mut w = WritebackFile::create_with_size_hint(&p, 1024 * 1024).unwrap();
        w.write_all(b"hello").unwrap();
        w.sync_all().unwrap();
        drop(w);
        let bytes = read_back(&p);
        assert_eq!(bytes.len(), 5, "size hint must not inflate logical length");
        assert_eq!(&bytes, b"hello");
    }

    /// `sync_all` is idempotent: calling it twice (and then Drop, which
    /// also finalizes) must not corrupt data or panic. Doc lines
    /// 256-262: `finalize` is idempotent so explicit sync_all then drop
    /// is safe. Mutation: a finalize that double-freed or advanced a
    /// cursor would corrupt on the second call.
    #[test]
    fn double_sync_all_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("double-sync.bin");
        let mut w = WritebackFile::create(&p).unwrap();
        w.write_all(b"idempotent").unwrap();
        w.sync_all().unwrap();
        w.sync_all().unwrap(); // second call must be safe
        drop(w); // Drop also finalizes
        assert_eq!(read_back(&p), b"idempotent");
    }

    /// Env-var chunk override parsing (`writeback_chunk_bytes`, lines
    /// 91-98). Out-of-range / unparseable values must fall back to the
    /// 32 MiB default; valid in-range values are converted MiB→bytes.
    /// We can't safely mutate process env in parallel tests for the
    /// default-path branch, but we CAN assert the pure boundary logic
    /// the function encodes by reconstructing it: the filter accepts
    /// `0 < n <= WRITEBACK_CHUNK_MIB_MAX`. This pins the constants and
    /// the MiB→byte multiply. Mutation: changing `* 1024 * 1024` to a
    /// single `* 1024` would break this equality.
    #[test]
    fn writeback_chunk_constants_and_conversion() {
        // Default is exactly 32 MiB.
        assert_eq!(WRITEBACK_CHUNK_BYTES_DEFAULT, 32 * 1024 * 1024);
        // Max MiB bound is 64 GiB expressed in MiB, and the byte value
        // it maps to must not overflow u64.
        assert_eq!(WRITEBACK_CHUNK_MIB_MAX, 64 * 1024);
        let max_bytes = (WRITEBACK_CHUNK_MIB_MAX as u128) * 1024 * 1024;
        assert!(
            max_bytes <= u64::MAX as u128,
            "max chunk MiB * 1MiB must fit in u64"
        );
    }

    /// Env-var override parsing for `writeback_chunk_bytes` (lines
    /// 91-98). All four branches in ONE test to avoid the data race of
    /// several parallel tests mutating the same process-global env var.
    ///
    /// Branches: (1) valid in-range value → MiB→byte conversion; (2)
    /// zero → `n > 0` filter rejects → default; (3) garbage → parse
    /// fails → default; (4) over-max → `n <= MAX` filter rejects →
    /// default.
    ///
    /// Mutations: `* 1024 * 1024` → `* 1024` breaks (1); dropping
    /// `n > 0` breaks (2); `unwrap()` on parse panics (3); dropping
    /// `n <= MAX` breaks (4).
    #[test]
    fn writeback_chunk_env_override_branches() {
        // SAFETY: this is the only test touching this env var, and it
        // sets+reads+clears synchronously within its own body.
        let set = |v: &str| unsafe { std::env::set_var("FREEMKV_WRITEBACK_CHUNK_MIB", v) };
        let clear = || unsafe { std::env::remove_var("FREEMKV_WRITEBACK_CHUNK_MIB") };

        set("8");
        assert_eq!(
            writeback_chunk_bytes(),
            8 * 1024 * 1024,
            "in-range mis-converted"
        );

        set("0");
        assert_eq!(
            writeback_chunk_bytes(),
            WRITEBACK_CHUNK_BYTES_DEFAULT,
            "zero must fall back (n > 0 filter)"
        );

        set("not-a-number");
        assert_eq!(
            writeback_chunk_bytes(),
            WRITEBACK_CHUNK_BYTES_DEFAULT,
            "unparseable must fall back"
        );

        // One past the max: WRITEBACK_CHUNK_MIB_MAX + 1.
        set(&(WRITEBACK_CHUNK_MIB_MAX + 1).to_string());
        assert_eq!(
            writeback_chunk_bytes(),
            WRITEBACK_CHUNK_BYTES_DEFAULT,
            "over-max must fall back (n <= MAX filter)"
        );

        // Exactly at the max boundary is accepted (inclusive bound).
        set(&WRITEBACK_CHUNK_MIB_MAX.to_string());
        assert_eq!(
            writeback_chunk_bytes(),
            WRITEBACK_CHUNK_MIB_MAX * 1024 * 1024,
            "max boundary must be accepted (inclusive)"
        );

        clear();
        // With the var cleared, the default is returned.
        assert_eq!(writeback_chunk_bytes(), WRITEBACK_CHUNK_BYTES_DEFAULT);
    }
}