Skip to content

Commit d315795

Browse files
committed
test(vfs): cover range_upload truncate-past-end, clean->dirty fallback, write+setattr race
Three new tests closing gaps spotted in the manual review: 1. sparse_truncate_shrink_then_flush_drops_tail — drives the synthetic truncate-DirtyInput path in range_upload (xet.rs:228-238): write a dirty patch inside the eventual range, setattr-truncate, fsync, verify the composed CAS file is the truncated prefix + dirty patch and the tail past the new boundary is gone. 2. sparse_pure_truncate_shrink_then_flush_drops_tail — same path with no writes, just truncate + flush, ensuring the synthetic delete still fires when dirty_inputs is otherwise empty. 3. write_lazy_installs_sparse_write_on_clean_inode_transition — exercises the defensive clean->dirty branch in write() (mod.rs:2392-2400) by force-clearing dirty/sparse_write after open and asserting the next write installs a fresh SparseWriteState pinned to entry.xet_hash / entry.size with the write range tracked. Through the public API this branch is unreachable today (open_advanced_write set_dirty's the inode first), but the test locks the documented behavior in case a future code path lands a write on a clean inode. 4. write_setattr_concurrent_keeps_size_consistent_with_staging — stress test for the guard at mod.rs:2384-2385 (effective_end = new_end.min (actual_size)). Two tasks race for 200 iterations each: one writes to advancing offsets, the other truncates to varying sizes. After the storm the invariant entry.size <= staging length must hold, which the guard is exactly there to preserve. Tight race window between pwrite and the metadata read makes deterministic reproduction infeasible, so the test exercises the path under contention and checks the post-condition. 369/369 tests + clippy clean.
1 parent ccbe266 commit d315795

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

src/virtual_fs/tests.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5650,3 +5650,189 @@ fn sparse_post_flush_read_returns_cas_bytes_not_zeros() {
56505650
vfs.release(fh).await.unwrap();
56515651
});
56525652
}
5653+
5654+
/// range_upload truncate-past-end: setattr(truncate to N < original_size) must
5655+
/// produce a CAS file of size N composed of the original prefix [0..N) plus
5656+
/// any dirty patches inside that range. Exercises the synthetic-delete branch
5657+
/// at xet.rs:228-238 (`truncate_start..original_size` DirtyInput with an empty
5658+
/// reader to drop the tail).
5659+
#[test]
5660+
fn sparse_truncate_shrink_then_flush_drops_tail() {
5661+
let hub = MockHub::new();
5662+
hub.add_file("file.txt", 10, Some("orig_hash"), None);
5663+
let xet = MockXet::new();
5664+
xet.add_file("orig_hash", b"0123456789");
5665+
let (rt, vfs) = vfs_advanced(&hub, &xet);
5666+
5667+
rt.block_on(async {
5668+
let attr = vfs.lookup(ROOT_INODE, "file.txt").await.unwrap();
5669+
let ino = attr.ino;
5670+
let fh = vfs.open(ino, true, false, None).await.unwrap();
5671+
5672+
// Dirty a window inside what will remain after truncate.
5673+
write_blocking(&vfs, ino, fh, 1, b"AA").await.unwrap();
5674+
// Truncate to 5 — tail [5..10) must be dropped from the new CAS file.
5675+
vfs.setattr(ino, Some(5), None, None, None, None, None).await.unwrap();
5676+
5677+
// Pre-flush read on the open handle should already reflect the truncate:
5678+
// bytes 0,3,4 from CAS, bytes 1-2 from staging.
5679+
let (data, _) = vfs.read(fh, 0, 5).await.unwrap();
5680+
assert_eq!(&data[..], b"0AA34", "pre-flush read after truncate");
5681+
5682+
// Drive the flush and verify the new CAS file is the truncated composition.
5683+
vfs.fsync(ino, fh, None).await.unwrap();
5684+
tokio::time::sleep(Duration::from_secs(3)).await;
5685+
5686+
let new_hash = {
5687+
let inodes = vfs.inode_table.read().unwrap();
5688+
inodes.get(ino).unwrap().xet_hash.clone().expect("new hash committed")
5689+
};
5690+
assert_ne!(new_hash, "orig_hash", "truncate must produce a fresh CAS hash");
5691+
let new_content = xet.get_file(&new_hash).expect("composed CAS file present");
5692+
assert_eq!(new_content, b"0AA34", "CAS file = original[0..5) with dirty patch");
5693+
assert_eq!(new_content.len(), 5, "tail past truncate boundary was dropped");
5694+
5695+
vfs.release(fh).await.unwrap();
5696+
});
5697+
}
5698+
5699+
/// Pure truncate (no writes) variant: the synthetic-delete branch must still
5700+
/// fire when `dirty_inputs` is otherwise empty.
5701+
#[test]
5702+
fn sparse_pure_truncate_shrink_then_flush_drops_tail() {
5703+
let hub = MockHub::new();
5704+
hub.add_file("file.txt", 10, Some("orig_hash"), None);
5705+
let xet = MockXet::new();
5706+
xet.add_file("orig_hash", b"0123456789");
5707+
let (rt, vfs) = vfs_advanced(&hub, &xet);
5708+
5709+
rt.block_on(async {
5710+
let attr = vfs.lookup(ROOT_INODE, "file.txt").await.unwrap();
5711+
let ino = attr.ino;
5712+
let fh = vfs.open(ino, true, false, None).await.unwrap();
5713+
5714+
vfs.setattr(ino, Some(3), None, None, None, None, None).await.unwrap();
5715+
vfs.fsync(ino, fh, None).await.unwrap();
5716+
tokio::time::sleep(Duration::from_secs(3)).await;
5717+
5718+
let new_hash = {
5719+
let inodes = vfs.inode_table.read().unwrap();
5720+
inodes.get(ino).unwrap().xet_hash.clone().expect("new hash committed")
5721+
};
5722+
let new_content = xet.get_file(&new_hash).expect("composed CAS file present");
5723+
assert_eq!(new_content, b"012", "pure truncate = original[0..3)");
5724+
5725+
vfs.release(fh).await.unwrap();
5726+
});
5727+
}
5728+
5729+
/// Regression for the clean→dirty fallback in write() (mod.rs:2392-2400).
5730+
///
5731+
/// In normal flows open_advanced_write set_dirty's the inode before write()
5732+
/// runs, so this defensive branch never fires through public APIs. But the
5733+
/// branch exists to keep the invariant safe if a write() ever reaches a clean
5734+
/// inode + xet_hash + no sparse_write (e.g. a future NFS upgrade path that
5735+
/// doesn't go through open). Test it by force-clearing dirty + sparse_write
5736+
/// between open and write, then asserting the fallback installs a fresh
5737+
/// SparseWriteState pinned to the current xet_hash / size.
5738+
#[test]
5739+
fn write_lazy_installs_sparse_write_on_clean_inode_transition() {
5740+
let hub = MockHub::new();
5741+
hub.add_file("file.txt", 11, Some("orig_hash"), None);
5742+
let xet = MockXet::new();
5743+
xet.add_file("orig_hash", b"hello world");
5744+
let (rt, vfs) = vfs_advanced(&hub, &xet);
5745+
5746+
rt.block_on(async {
5747+
let attr = vfs.lookup(ROOT_INODE, "file.txt").await.unwrap();
5748+
let ino = attr.ino;
5749+
let fh = vfs.open(ino, true, false, None).await.unwrap();
5750+
5751+
// Simulate an out-of-band transition to clean (the branch's documented
5752+
// trigger — a writable handle existing while the inode is clean and
5753+
// has a hash but no sparse_write). Just clearing what open installed
5754+
// is enough: dirty_generation back to 0, sparse_write gone.
5755+
{
5756+
let mut inodes = vfs.inode_table.write().unwrap();
5757+
let entry = inodes.get_mut(ino).unwrap();
5758+
entry.dirty_generation = 0;
5759+
entry.sparse_write = None;
5760+
assert!(!entry.is_dirty(), "precondition: inode is clean");
5761+
assert!(entry.xet_hash.is_some(), "precondition: hash retained");
5762+
}
5763+
5764+
write_blocking(&vfs, ino, fh, 6, b"RUST!").await.unwrap();
5765+
5766+
let sw = {
5767+
let inodes = vfs.inode_table.read().unwrap();
5768+
inodes.get(ino).unwrap().sparse_write.clone()
5769+
};
5770+
let sw = sw.expect("write() must install sparse_write on the clean→dirty transition");
5771+
assert_eq!(sw.original_hash, "orig_hash", "fallback pins to entry.xet_hash");
5772+
assert_eq!(sw.original_size, 11, "fallback pins to entry.size");
5773+
assert_eq!(sw.dirty_ranges, vec![(6, 11)], "the write range is tracked");
5774+
5775+
vfs.release(fh).await.unwrap();
5776+
});
5777+
}
5778+
5779+
/// Stress: hammer the inode with concurrent writes and setattr-truncates and
5780+
/// verify the (post-write) entry.size never exceeds the staging file length.
5781+
/// This is the invariant the guard at mod.rs:2384-2385 (`new_end.min(actual_size)`)
5782+
/// is supposed to preserve. The race window between pwrite and the metadata
5783+
/// read is too tight to hit deterministically, but the stress loop exercises
5784+
/// it and asserts the resulting state is always consistent.
5785+
#[test]
5786+
fn write_setattr_concurrent_keeps_size_consistent_with_staging() {
5787+
let hub = MockHub::new();
5788+
hub.add_file("file.txt", 16, Some("orig_hash"), None);
5789+
let xet = MockXet::new();
5790+
xet.add_file("orig_hash", &[0u8; 16]);
5791+
let (rt, vfs) = vfs_advanced(&hub, &xet);
5792+
5793+
rt.block_on(async {
5794+
let attr = vfs.lookup(ROOT_INODE, "file.txt").await.unwrap();
5795+
let ino = attr.ino;
5796+
let fh = vfs.open(ino, true, false, None).await.unwrap();
5797+
5798+
let staging_path = vfs.staging.path(ino).expect("staging path");
5799+
5800+
// Spin up two tasks: one writes to advancing offsets, one truncates to
5801+
// sizes that may cross into the write window. We don't try to force the
5802+
// race window; we just iterate enough to exercise the path and check
5803+
// the invariant after each settle.
5804+
let vfs_w = vfs.clone();
5805+
let writer = tokio::spawn(async move {
5806+
for i in 0..200u64 {
5807+
let off = i % 12;
5808+
let _ = write_blocking(&vfs_w, ino, fh, off, b"AA").await;
5809+
}
5810+
});
5811+
5812+
let vfs_t = vfs.clone();
5813+
let truncator = tokio::spawn(async move {
5814+
for i in 0..200u64 {
5815+
let new_size = (i % 16) + 1; // 1..=16
5816+
let _ = vfs_t
5817+
.setattr(ino, Some(new_size), None, None, None, None, None)
5818+
.await;
5819+
}
5820+
});
5821+
5822+
let _ = tokio::join!(writer, truncator);
5823+
5824+
// After the storm: the invariant must hold — entry.size must not
5825+
// exceed the staging file length. Without the guard, the writer could
5826+
// record entry.size = new_end while a concurrent truncate had already
5827+
// shrunk staging below new_end, leaving entry.size > staging len and
5828+
// breaking future reads/flushes.
5829+
let entry_size = vfs.inode_table.read().unwrap().get(ino).unwrap().size;
5830+
let staging_len = std::fs::metadata(&staging_path).map(|m| m.len()).unwrap_or(0);
5831+
assert!(
5832+
entry_size <= staging_len,
5833+
"invariant violated: entry.size ({entry_size}) > staging len ({staging_len})"
5834+
);
5835+
5836+
vfs.release(fh).await.unwrap();
5837+
});
5838+
}

0 commit comments

Comments
 (0)