Skip to main content

fs_ext4/
block_cache.rs

1//! Buffer cache wrapping a `BlockDevice`.
2//!
3//! `CachedDevice` is the single source of truth for block contents
4//! within a mount session. It plays the part a buffer cache plays for a
5//! journaled filesystem: reads are served from the cache, writes
6//! update the cache, and the cache holds journaled-but-not-yet-
7//! checkpointed bytes so that subsequent reads see them before the
8//! data area on disk catches up.
9//!
10//! Design:
11//! - **LRU clean entries** — `entries` holds blocks read from disk
12//!   or written through `write_at`. LRU-evictable; the disk has the
13//!   same bytes so eviction is safe.
14//! - **Pinned entries** — `pinned` holds blocks whose bytes only
15//!   exist in this map and the journal log on disk; the data area on
16//!   disk still has the pre-commit content. Pinned entries are
17//!   NEVER evicted, since evicting them would lose the only
18//!   in-memory copy and the next allocator scan would re-read stale
19//!   bytes from disk. A read-only mount's journal replay
20//!   (`journal_apply::replay_into_cache`) populates `pinned`; the
21//!   `Filesystem::replay_journal_if_dirty` hook calls `unpin_all`
22//!   when the journal has been checkpointed. A journaled commit pins
23//!   nothing: it checkpoints before returning, and its write-through
24//!   leaves clean entries (#328).
25//! - **Write-through update** — `write_at` UPDATES the cache (not
26//!   invalidates) and forwards to the inner device. This keeps the
27//!   cache consistent with disk for direct writes (e.g.
28//!   `write_inode_raw`) and means a read-after-write is satisfied
29//!   from the cache without bouncing to disk.
30//! - **Block-aligned reads only.** Multi-block reads bypass the
31//!   cache and pass through.
32//! - **Crash safety unchanged.** Pinned bytes are also persisted in
33//!   the journal log (the caller replayed them from it); on crash,
34//!   replay applies them. Clean LRU
35//!   entries match disk by construction.
36//! - **No external LRU crate** — hand-rolled to avoid pulling in
37//!   GPL/LGPL deps and to keep the cache logic auditable.
38
39use crate::block_io::BlockDevice;
40use crate::error::Result;
41use std::collections::HashMap;
42use std::sync::Mutex;
43
44/// Inner cache state held under a Mutex on `CachedDevice`.
45///
46/// Two maps:
47/// - `entries`: clean blocks (LRU-evictable; disk has the same bytes).
48/// - `pinned`: blocks whose bytes only exist here and in the journal
49///   log on disk. NEVER evicted until `unpin_all`.
50///
51/// Read order: `pinned` → `entries` → inner device.
52/// Write through `write_at`: updates `entries` only; pinned stays.
53/// Populate via `populate`: inserts into `pinned`.
54struct CacheState {
55    capacity: usize,
56    entries: HashMap<u64, (Vec<u8>, u64)>,
57    pinned: HashMap<u64, Vec<u8>>,
58    next_seq: u64,
59    hits: u64,
60    misses: u64,
61}
62
63impl CacheState {
64    fn new(capacity: usize) -> Self {
65        Self {
66            capacity,
67            entries: HashMap::with_capacity(capacity.min(1024)),
68            pinned: HashMap::new(),
69            next_seq: 0,
70            hits: 0,
71            misses: 0,
72        }
73    }
74
75    fn next_seq(&mut self) -> u64 {
76        let s = self.next_seq;
77        self.next_seq = s.wrapping_add(1);
78        s
79    }
80
81    /// Look up `block`. Pinned wins over LRU. On miss: None.
82    fn get(&mut self, block: u64) -> Option<Vec<u8>> {
83        if let Some(bytes) = self.pinned.get(&block) {
84            self.hits += 1;
85            return Some(bytes.clone());
86        }
87        let seq = self.next_seq();
88        if let Some(slot) = self.entries.get_mut(&block) {
89            slot.1 = seq;
90            self.hits += 1;
91            Some(slot.0.clone())
92        } else {
93            self.misses += 1;
94            None
95        }
96    }
97
98    /// Insert/update a clean LRU entry. Evicts the LRU victim if full.
99    /// Pinned entries are never considered for eviction.
100    fn put(&mut self, block: u64, bytes: Vec<u8>) {
101        // If the block is already pinned, replace its pinned bytes
102        // (a journaled write superseded by a direct write — unlikely
103        // in practice but kept consistent).
104        if let std::collections::hash_map::Entry::Occupied(mut e) = self.pinned.entry(block) {
105            e.insert(bytes);
106            return;
107        }
108        // Capacity zero keeps no clean blocks: every read reaches the
109        // device, which is the uncached baseline `read_path_cost` measures
110        // (#68). Pinned blocks are unaffected.
111        if self.capacity == 0 {
112            return;
113        }
114        if self.entries.len() >= self.capacity {
115            if let Some((&victim, _)) = self.entries.iter().min_by_key(|(_, (_, seq))| *seq) {
116                self.entries.remove(&victim);
117            }
118        }
119        let seq = self.next_seq();
120        self.entries.insert(block, (bytes, seq));
121    }
122
123    /// Stash a block whose bytes live only here and in the journal
124    /// log. Will not be evicted until `unpin_all`. If the block was
125    /// in the LRU, the LRU entry is dropped — pinned takes priority.
126    fn pin(&mut self, block: u64, bytes: Vec<u8>) {
127        self.entries.remove(&block);
128        self.pinned.insert(block, bytes);
129    }
130
131    /// Move all pinned entries into the LRU (now safe to evict).
132    fn unpin_all(&mut self) {
133        // Drain pinned. Each entry becomes an LRU candidate;
134        // capacity-bound eviction kicks in if the LRU was already full.
135        let drained: Vec<(u64, Vec<u8>)> = self.pinned.drain().collect();
136        for (block, bytes) in drained {
137            if self.capacity == 0 {
138                continue;
139            }
140            if self.entries.len() >= self.capacity {
141                if let Some((&victim, _)) = self.entries.iter().min_by_key(|(_, (_, seq))| *seq) {
142                    self.entries.remove(&victim);
143                }
144            }
145            let seq = self.next_seq();
146            self.entries.insert(block, (bytes, seq));
147        }
148    }
149}
150
151/// LRU-cached BlockDevice. Pass-through for is_writable + size_bytes;
152/// caches block-aligned reads, invalidates on writes.
153pub struct CachedDevice {
154    inner: std::sync::Arc<dyn BlockDevice>,
155    block_size: u32,
156    state: Mutex<CacheState>,
157}
158
159impl CachedDevice {
160    /// Wrap `inner` with an LRU of `capacity` blocks. Pick `capacity`
161    /// based on workload: 64 blocks (256 KiB at 4 KiB) is a reasonable
162    /// default for general use; bigger directory walks benefit from 256+.
163    pub fn new(inner: std::sync::Arc<dyn BlockDevice>, block_size: u32, capacity: usize) -> Self {
164        Self {
165            inner,
166            block_size,
167            state: Mutex::new(CacheState::new(capacity)),
168        }
169    }
170
171    /// Snapshot (hits, misses) — useful for benchmarks and tests.
172    pub fn stats(&self) -> (u64, u64) {
173        let s = self.state.lock().expect("cache mutex poisoned");
174        (s.hits, s.misses)
175    }
176
177    /// How many blocks are pinned: held whatever the capacity, because their
178    /// bytes are not yet at their final location on the device.
179    pub fn pinned_blocks(&self) -> usize {
180        let s = self.state.lock().expect("cache mutex poisoned");
181        s.pinned.len()
182    }
183}
184
185impl BlockDevice for CachedDevice {
186    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
187        let bs = self.block_size as u64;
188        let block = offset / bs;
189        let off_in_block = (offset % bs) as usize;
190        let len = buf.len();
191
192        // Block-aligned single-block read: cache fast path.
193        if off_in_block + len <= bs as usize {
194            // Try the cache first.
195            {
196                let mut state = self.state.lock().expect("cache mutex poisoned");
197                if let Some(blk) = state.get(block) {
198                    buf.copy_from_slice(&blk[off_in_block..off_in_block + len]);
199                    return Ok(());
200                }
201            }
202            // Miss: read the whole block from the inner device, then cache.
203            let mut blk = vec![0u8; bs as usize];
204            self.inner.read_at(block * bs, &mut blk)?;
205            buf.copy_from_slice(&blk[off_in_block..off_in_block + len]);
206            let mut state = self.state.lock().expect("cache mutex poisoned");
207            state.put(block, blk);
208            return Ok(());
209        }
210
211        // Multi-block read (rare): pass through, then lay any pinned
212        // blocks over it. Pinned bytes are not on disk yet, and the group
213        // descriptor table -- read this way -- is exactly what a journal
214        // replayed into the cache pins (#72). Clean entries match disk.
215        self.inner.read_at(offset, buf)?;
216        let state = self.state.lock().expect("cache mutex poisoned");
217        if state.pinned.is_empty() {
218            return Ok(());
219        }
220        let end = offset + len as u64;
221        for block in offset / bs..end.div_ceil(bs) {
222            let Some(img) = state.pinned.get(&block) else {
223                continue;
224            };
225            let block_start = block * bs;
226            let from = offset.max(block_start);
227            let to = end.min(block_start + bs);
228            buf[(from - offset) as usize..(to - offset) as usize]
229                .copy_from_slice(&img[(from - block_start) as usize..(to - block_start) as usize]);
230        }
231        Ok(())
232    }
233
234    fn size_bytes(&self) -> u64 {
235        self.inner.size_bytes()
236    }
237
238    fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
239        let bs = self.block_size as u64;
240        // Forward to disk first; if the inner write fails, the cache
241        // still reflects whatever was there before — no partial update.
242        self.inner.write_at(offset, buf)?;
243
244        // Single-block, block-aligned writes update the cache directly
245        // (write-through). Multi-block / unaligned writes fall through
246        // to invalidation since we don't have the full block image
247        // for each affected block.
248        let off_in_block = (offset % bs) as usize;
249        let len = buf.len();
250        if off_in_block == 0 && len == bs as usize {
251            let block = offset / bs;
252            let mut state = self.state.lock().expect("cache mutex poisoned");
253            state.put(block, buf.to_vec());
254            return Ok(());
255        }
256
257        // Unaligned / multi-block write. Two layers to handle:
258        //
259        // - PINNED blocks hold post-commit-pre-checkpoint journaled
260        //   bytes that don't yet exist on disk. We MUST NOT
261        //   invalidate them — the on-disk data area still has the
262        //   pre-commit version, so dropping the pinned image would
263        //   make the next read serve stale bytes for the unwritten
264        //   portion. Instead, overlay the new sub-block bytes onto
265        //   the pinned image so reads see (journaled bytes for the
266        //   unwritten portion + new bytes for the written portion).
267        //
268        // - LRU entries (clean, not pinned) can be dropped: we just
269        //   wrote to disk above, so the disk holds the merged truth
270        //   (old bytes for unwritten portion + new bytes for written
271        //   portion). The next read fetches that merged image.
272        //
273        // The only sub-block write site in the crate today is
274        // `write_inode_raw` (writes a single inode at its offset
275        // within a 4 KiB inode-table block); other direct writes
276        // (`set_block_run_used`, BGD blocks, indirect blocks) are
277        // full-block-aligned and take the fast path above.
278        let first_block = offset / bs;
279        let last_block = (offset + buf.len() as u64).saturating_sub(1) / bs;
280        let buf_end_byte = offset + buf.len() as u64;
281        let mut state = self.state.lock().expect("cache mutex poisoned");
282        for b in first_block..=last_block {
283            let block_start = b * bs;
284            let block_end = block_start + bs;
285            let write_start = offset.max(block_start);
286            let write_end = buf_end_byte.min(block_end);
287            let in_block_off = (write_start - block_start) as usize;
288            let in_block_end = (write_end - block_start) as usize;
289            let buf_start = (write_start - offset) as usize;
290            let buf_end = (write_end - offset) as usize;
291
292            if let Some(img) = state.pinned.get_mut(&b) {
293                img[in_block_off..in_block_end].copy_from_slice(&buf[buf_start..buf_end]);
294            }
295            state.entries.remove(&b);
296        }
297        Ok(())
298    }
299
300    fn flush(&self) -> Result<()> {
301        self.inner.flush()
302    }
303
304    fn is_writable(&self) -> bool {
305        self.inner.is_writable()
306    }
307
308    fn populate_cache(&self, block: u64, bytes: Vec<u8>) {
309        let mut state = self.state.lock().expect("cache mutex poisoned");
310        state.pin(block, bytes);
311    }
312
313    fn unpin_all(&self) {
314        let mut state = self.state.lock().expect("cache mutex poisoned");
315        state.unpin_all();
316    }
317
318    fn invalidate_cache(&self) -> Result<()> {
319        let mut state = self.state.lock().expect("cache mutex poisoned");
320        if !state.pinned.is_empty() {
321            return Err(crate::error::Error::Corrupt(
322                "cache contains uncheckpointed journal metadata",
323            ));
324        }
325        self.inner.invalidate_cache()?;
326        state.entries.clear();
327        Ok(())
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::sync::Arc;
335
336    #[test]
337    fn invalidation_requires_checkpoint_and_then_reads_physical_bytes() {
338        let raw = CountingDevice::new(8192, true);
339        let cache = CachedDevice::new(raw.clone(), 4096, 8);
340        let mut bytes = vec![0; 4096];
341        cache.read_at(0, &mut bytes).unwrap();
342        raw.write_at(0, &vec![7; 4096]).unwrap();
343        cache.read_at(0, &mut bytes).unwrap();
344        assert_eq!(bytes[0], 0);
345        cache.invalidate_cache().unwrap();
346        cache.read_at(0, &mut bytes).unwrap();
347        assert_eq!(bytes[0], 7);
348        cache.populate_cache(0, vec![9; 4096]);
349        assert!(
350            cache.invalidate_cache().is_err(),
351            "uncheckpointed metadata cannot be discarded"
352        );
353        cache.read_at(0, &mut bytes).unwrap();
354        assert_eq!(bytes[0], 9);
355        raw.write_at(0, &vec![9; 4096]).unwrap();
356        cache.unpin_all();
357        cache.invalidate_cache().unwrap();
358        cache.read_at(0, &mut bytes).unwrap();
359        assert_eq!(bytes[0], 9);
360        assert_eq!(raw.reads(), 3);
361    }
362
363    /// Counts every read/write so we can prove the cache eliminates them.
364    struct CountingDevice {
365        bytes: Mutex<Vec<u8>>,
366        reads: std::sync::atomic::AtomicU64,
367        writes: std::sync::atomic::AtomicU64,
368        writable: bool,
369    }
370
371    impl CountingDevice {
372        fn new(size: usize, writable: bool) -> Arc<Self> {
373            Arc::new(Self {
374                bytes: Mutex::new(vec![0u8; size]),
375                reads: std::sync::atomic::AtomicU64::new(0),
376                writes: std::sync::atomic::AtomicU64::new(0),
377                writable,
378            })
379        }
380        fn reads(&self) -> u64 {
381            self.reads.load(std::sync::atomic::Ordering::SeqCst)
382        }
383        fn writes(&self) -> u64 {
384            self.writes.load(std::sync::atomic::Ordering::SeqCst)
385        }
386    }
387
388    impl BlockDevice for CountingDevice {
389        fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
390            self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
391            let b = self.bytes.lock().unwrap();
392            let off = offset as usize;
393            buf.copy_from_slice(&b[off..off + buf.len()]);
394            Ok(())
395        }
396        fn size_bytes(&self) -> u64 {
397            self.bytes.lock().unwrap().len() as u64
398        }
399        fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
400            self.writes
401                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
402            let mut b = self.bytes.lock().unwrap();
403            let off = offset as usize;
404            b[off..off + buf.len()].copy_from_slice(buf);
405            Ok(())
406        }
407        fn is_writable(&self) -> bool {
408            self.writable
409        }
410    }
411
412    /// A read spanning several blocks sees the pinned ones. It went straight
413    /// to the device, so a group descriptor table longer than a block read
414    /// the on-disk descriptors under a journal replayed into the cache (#72).
415    #[test]
416    fn a_multi_block_read_sees_pinned_blocks() {
417        let inner = CountingDevice::new(4096 * 16, false);
418        let cached = CachedDevice::new(inner.clone(), 4096, 8);
419        cached.populate_cache(3, vec![0xBBu8; 4096]);
420        // From the middle of block 2 to the middle of block 4.
421        let mut buf = vec![0u8; 8192];
422        cached.read_at(2 * 4096 + 2048, &mut buf).unwrap();
423        assert_eq!(&buf[..2048], &[0u8; 2048][..], "block 2 is the device's");
424        assert_eq!(&buf[2048..6144], &[0xBBu8; 4096][..], "block 3 is pinned");
425        assert_eq!(&buf[6144..], &[0u8; 2048][..], "block 4 is the device's");
426    }
427
428    #[test]
429    fn second_read_of_same_block_is_a_cache_hit() {
430        let inner = CountingDevice::new(4096 * 16, false);
431        let cached = CachedDevice::new(inner.clone(), 4096, 8);
432        let mut buf = vec![0u8; 100];
433        cached.read_at(0, &mut buf).unwrap();
434        cached.read_at(0, &mut buf).unwrap();
435        cached.read_at(50, &mut buf).unwrap(); // still in same block 0
436        assert_eq!(inner.reads(), 1, "cache should serve all 3 from one read");
437        let (hits, misses) = cached.stats();
438        assert_eq!(hits, 2);
439        assert_eq!(misses, 1);
440    }
441
442    #[test]
443    fn unaligned_write_merges_into_pinned_block() {
444        // Pinned blocks hold post-commit-pre-checkpoint journaled
445        // bytes that don't yet exist on disk. A sub-block write must
446        // NOT invalidate the pinned image — that would let later
447        // reads serve stale data-area bytes for the untouched portion.
448        // The cache must overlay the new sub-block bytes onto the
449        // pinned image, preserving everything else.
450        let inner = CountingDevice::new(4096 * 16, true);
451        let cached = CachedDevice::new(inner.clone(), 4096, 8);
452        // Pin block 5 with a synthetic post-commit image. Disk
453        // (CountingDevice) still holds zeros — the pre-commit version.
454        let mut journaled = vec![0u8; 4096];
455        journaled[0] = 0xAA;
456        journaled[3000] = 0xBB;
457        cached.populate_cache(5, journaled);
458        // Sub-block direct write touching bytes 100..200 of block 5.
459        cached.write_at(5 * 4096 + 100, &[0xCCu8; 100]).unwrap();
460        // Read back the full block via the cache.
461        let mut buf = vec![0u8; 4096];
462        for i in 0..4096 {
463            cached
464                .read_at(5 * 4096 + i as u64, &mut buf[i..i + 1])
465                .unwrap();
466        }
467        assert_eq!(buf[0], 0xAA, "pinned journaled byte 0 must survive");
468        assert_eq!(buf[100], 0xCC, "new sub-block bytes must be visible");
469        assert_eq!(buf[199], 0xCC);
470        assert_eq!(buf[200], 0x00, "untouched portion stays at journaled value");
471        assert_eq!(
472            buf[3000], 0xBB,
473            "pinned bytes outside the write window survive"
474        );
475    }
476
477    #[test]
478    fn unaligned_write_invalidates_cache_entry() {
479        // Partial-block writes don't have a full block image to
480        // update the cache with, so we drop the affected entries and
481        // the next read pays the cost of a fresh disk read.
482        let inner = CountingDevice::new(4096 * 16, true);
483        let cached = CachedDevice::new(inner.clone(), 4096, 8);
484        let mut buf = vec![0u8; 100];
485        cached.read_at(0, &mut buf).unwrap();
486        cached.write_at(0, &[42u8; 100]).unwrap(); // unaligned: 100 < 4096
487        cached.read_at(0, &mut buf).unwrap();
488        assert_eq!(inner.reads(), 2);
489        assert_eq!(buf[0], 42, "post-write read should see the new bytes");
490        // The cache is write-through, not write-back: the bytes reach
491        // the device now, exactly once. Nothing asserted this before —
492        // `CountingDevice::writes()` existed and had no caller, which is
493        // what the crate-wide `allow(dead_code)` was hiding.
494        assert_eq!(
495            inner.writes(),
496            1,
497            "an unaligned write must reach the device once, not be held in cache"
498        );
499    }
500
501    #[test]
502    fn aligned_write_updates_cache_entry() {
503        // Full-block, block-aligned writes go straight into the cache
504        // (write-through). The next read is served from cache without
505        // touching the inner device — read-after-write coherence with
506        // zero extra device reads.
507        let inner = CountingDevice::new(4096 * 16, true);
508        let cached = CachedDevice::new(inner.clone(), 4096, 8);
509        // Prime the cache with a read.
510        let mut buf = vec![0u8; 100];
511        cached.read_at(0, &mut buf).unwrap();
512        assert_eq!(inner.reads(), 1);
513        // Full-block write — cache should hold the new bytes.
514        let new_block = vec![0xCDu8; 4096];
515        cached.write_at(0, &new_block).unwrap();
516        // Read back: served from cache, no additional device read.
517        let mut readback = vec![0u8; 100];
518        cached.read_at(0, &mut readback).unwrap();
519        assert_eq!(inner.reads(), 1, "aligned write-through skips disk read");
520        assert_eq!(readback[0], 0xCD);
521        // Verify the inner device also got the bytes.
522        let mut from_disk = vec![0u8; 100];
523        inner.read_at(0, &mut from_disk).unwrap();
524        assert_eq!(from_disk[0], 0xCD);
525    }
526
527    #[test]
528    fn populate_cache_pins_entry_against_lru() {
529        // Pinned entries hold journaled-but-not-checkpointed bytes —
530        // they're the only in-memory copy, so eviction would lose
531        // data. Verify they survive even when the LRU is hammered.
532        let inner = CountingDevice::new(4096 * 16, false);
533        let cached = CachedDevice::new(inner.clone(), 4096, 2); // tiny capacity
534                                                                // Pin block 7 with synthetic journaled bytes.
535        cached.populate_cache(7, vec![0xAAu8; 4096]);
536        // Saturate the LRU with reads of other blocks.
537        let mut throwaway = vec![0u8; 8];
538        for blk in 0..5u64 {
539            cached.read_at(blk * 4096, &mut throwaway).unwrap();
540        }
541        // Block 7 must still be served from the pin, NOT from disk
542        // (which has zeros).
543        let mut buf = vec![0u8; 8];
544        cached.read_at(7 * 4096, &mut buf).unwrap();
545        assert_eq!(buf, vec![0xAA; 8],
546                   "pinned entry must survive LRU pressure — otherwise journaled writes vanish before checkpoint");
547    }
548
549    #[test]
550    fn unpin_all_lets_pinned_entries_evict_normally() {
551        // After journal replay, pinned bytes are also on disk —
552        // unpin_all moves them into the LRU where they can be evicted
553        // under normal pressure, freeing memory.
554        let inner = CountingDevice::new(4096 * 16, false);
555        let cached = CachedDevice::new(inner.clone(), 4096, 1); // capacity=1
556        cached.populate_cache(3, vec![0xBBu8; 4096]);
557        cached.unpin_all();
558        // Now read another block — block 3 should be evicted under
559        // capacity pressure.
560        let mut throwaway = vec![0u8; 8];
561        cached.read_at(0, &mut throwaway).unwrap();
562        // Re-read block 3 — should miss (evicted), serve from disk
563        // (which is zeros — `unpin_all` is purely an in-memory
564        // re-classification, the cache still hands out the pinned
565        // bytes that are STILL there until eviction picks them).
566        // Actually since capacity=1 and we just read block 0, block 3
567        // got evicted; the next read of block 3 misses the cache and
568        // hits the inner device. Inner has zeros (no journal replay
569        // really happened here — this test only exercises the
570        // pin/unpin state machine).
571        let mut buf3 = vec![0u8; 8];
572        cached.read_at(3 * 4096, &mut buf3).unwrap();
573        assert_eq!(
574            buf3,
575            vec![0; 8],
576            "after unpin + LRU eviction, the inner device's bytes win"
577        );
578    }
579
580    #[test]
581    fn multi_block_read_bypasses_cache() {
582        let inner = CountingDevice::new(4096 * 16, false);
583        let cached = CachedDevice::new(inner.clone(), 4096, 8);
584        let mut buf = vec![0u8; 8000]; // spans blocks 0 + 1
585        cached.read_at(0, &mut buf).unwrap();
586        // Cache wasn't populated → second multi-block read goes to disk again.
587        cached.read_at(0, &mut buf).unwrap();
588        assert_eq!(inner.reads(), 2);
589        let (hits, misses) = cached.stats();
590        assert_eq!(hits, 0);
591        assert_eq!(misses, 0, "multi-block reads bypass entirely");
592    }
593
594    #[test]
595    fn lru_evicts_oldest_when_capacity_exceeded() {
596        let inner = CountingDevice::new(4096 * 16, false);
597        let cached = CachedDevice::new(inner.clone(), 4096, 2); // capacity=2
598        let mut buf = vec![0u8; 8];
599        // Read blocks 0, 1, 2 — block 0 should be evicted.
600        for blk in 0..3u64 {
601            cached.read_at(blk * 4096, &mut buf).unwrap();
602        }
603        // Re-read block 0 → should miss (evicted).
604        cached.read_at(0, &mut buf).unwrap();
605        // Re-read block 2 → should hit (most recent).
606        cached.read_at(2 * 4096, &mut buf).unwrap();
607        let (hits, misses) = cached.stats();
608        assert_eq!(misses, 4, "blocks 0,1,2 + re-read of 0 (evicted)");
609        assert_eq!(hits, 1, "re-read of 2 still cached");
610    }
611
612    #[test]
613    fn lru_keeps_recently_touched_block_alive() {
614        // With capacity=2, reading [0, 1, 0, 2] keeps block 0 alive
615        // because it was touched between 1 and 2 — block 1 should be
616        // the eviction victim, not 0.
617        let inner = CountingDevice::new(4096 * 16, false);
618        let cached = CachedDevice::new(inner.clone(), 4096, 2);
619        let mut buf = vec![0u8; 8];
620        cached.read_at(0, &mut buf).unwrap(); // miss → cache
621        cached.read_at(4096, &mut buf).unwrap(); // miss → cache
622        cached.read_at(0, &mut buf).unwrap(); // hit, bumps recency
623        cached.read_at(2 * 4096, &mut buf).unwrap(); // miss → evicts block 1
624        cached.read_at(0, &mut buf).unwrap(); // hit (still cached)
625        let (hits, misses) = cached.stats();
626        assert_eq!(hits, 2);
627        assert_eq!(misses, 3);
628        // Verify block 1 was the eviction victim, not 0.
629        cached.read_at(4096, &mut buf).unwrap(); // should miss
630        let (_, m_after) = cached.stats();
631        assert_eq!(m_after, 4, "block 1 was evicted, re-read is a miss");
632    }
633}