@@ -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