@@ -118,6 +118,22 @@ pub trait LeaseStore: Send + Sync {
118118 /// Acquire the lease, or `Err(Held)` if a live writer holds it. The returned
119119 /// handle must outlive the writer; dropping it releases the lease.
120120 fn acquire ( & self , key : & LeaseKey ) -> Result < Box < dyn LeaseHandle > , LeaseError > ;
121+
122+ /// Acquire the lease in SHARED mode: any number of shared holders may
123+ /// coexist, but a shared holder blocks [`LeaseStore::acquire`] (exclusive)
124+ /// and an exclusive holder blocks shared acquisition.
125+ ///
126+ /// Use for reader-side protection of shared resources: e.g. a model-cache
127+ /// consumer takes a shared lease on a blob's digest while validating or
128+ /// mmap-ing it, so a GC (exclusive holder) can never delete the file out
129+ /// from under a live reader, while concurrent readers never serialize each
130+ /// other.
131+ ///
132+ /// Shared handles do NOT bump the fence epoch (they are not writers; the
133+ /// epoch fences durable writes). [`LeaseHandle::epoch`] on a shared handle
134+ /// returns the last persisted writer epoch at acquisition time, for
135+ /// observability only — never use it as a write fence.
136+ fn acquire_shared ( & self , key : & LeaseKey ) -> Result < Box < dyn LeaseHandle > , LeaseError > ;
121137}
122138
123139/// File-based lease store: one lock file per key under `base_dir`. The OS advisory
@@ -143,7 +159,8 @@ impl FileLeaseStore {
143159 }
144160}
145161
146- /// A file-backed held lease: holds the OS advisory lock for its lifetime.
162+ /// A file-backed held lease: holds the OS advisory lock (exclusive or shared)
163+ /// for its lifetime.
147164#[ derive( Debug ) ]
148165struct FileLeaseHandle {
149166 epoch : u64 ,
@@ -202,6 +219,45 @@ impl LeaseStore for FileLeaseStore {
202219 key : key. clone ( ) ,
203220 } ) )
204221 }
222+
223+ fn acquire_shared ( & self , key : & LeaseKey ) -> Result < Box < dyn LeaseHandle > , LeaseError > {
224+ std:: fs:: create_dir_all ( & self . base_dir ) . map_err ( LeaseError :: Io ) ?;
225+ let path = self . lease_path ( key) ;
226+ let mut file = OpenOptions :: new ( )
227+ . read ( true )
228+ . write ( true )
229+ . create ( true )
230+ . truncate ( false )
231+ . open ( & path)
232+ . map_err ( LeaseError :: Io ) ?;
233+
234+ // Shared liveness gate: only an exclusive holder contends; other shared
235+ // holders coexist. On unix this is flock(LOCK_SH|LOCK_NB); on Windows,
236+ // LockFileEx without LOCKFILE_EXCLUSIVE_LOCK (both via fs2).
237+ // Fully-qualified call: std >= 1.89 has an inherent File::try_lock_shared
238+ // (returning TryLockError) that would otherwise shadow the fs2 trait
239+ // method this crate's error handling is built around.
240+ match FileExt :: try_lock_shared ( & file) {
241+ Ok ( ( ) ) => { }
242+ Err ( e) if is_lock_contended ( & e) => {
243+ return Err ( LeaseError :: Held { key : key. clone ( ) } ) ;
244+ }
245+ Err ( e) => return Err ( LeaseError :: Io ( e) ) ,
246+ }
247+
248+ // Read-only peek at the persisted writer epoch: shared holders are not
249+ // writers, so the epoch is NOT bumped (it fences durable writes only).
250+ let epoch = read_epoch ( & mut file) . map_err ( |e| {
251+ let _ = file. unlock ( ) ;
252+ LeaseError :: Io ( e)
253+ } ) ?;
254+
255+ Ok ( Box :: new ( FileLeaseHandle {
256+ epoch,
257+ file,
258+ key : key. clone ( ) ,
259+ } ) )
260+ }
205261}
206262
207263/// Whether a `try_lock_exclusive` error means "another live holder owns the lock"
@@ -215,6 +271,16 @@ fn is_lock_contended(e: &std::io::Error) -> bool {
215271 e. raw_os_error ( ) == fs2:: lock_contended_error ( ) . raw_os_error ( )
216272}
217273
274+ /// Read the persisted epoch without modifying it (0 if new/empty). Called while
275+ /// holding a shared OS lock; must not write (concurrent shared holders read the
276+ /// same file).
277+ fn read_epoch ( file : & mut File ) -> std:: io:: Result < u64 > {
278+ let mut buf = String :: new ( ) ;
279+ file. seek ( SeekFrom :: Start ( 0 ) ) ?;
280+ file. read_to_string ( & mut buf) ?;
281+ Ok ( buf. trim ( ) . parse ( ) . unwrap_or ( 0 ) )
282+ }
283+
218284/// Read the persisted epoch (0 if new/empty), increment, write it back, return the
219285/// new value. Called while holding the OS lock.
220286fn bump_epoch ( file : & mut File ) -> std:: io:: Result < u64 > {
@@ -314,6 +380,152 @@ mod tests {
314380 let _ = std:: fs:: remove_dir_all ( dir) ;
315381 }
316382
383+ #[ test]
384+ fn shared_holders_coexist_but_block_exclusive ( ) {
385+ let ( store, dir) = tmp_store ( ) ;
386+ let k = key ( "shared" ) ;
387+
388+ let s1 = store. acquire_shared ( & k) . expect ( "first shared" ) ;
389+ let s2 = store
390+ . acquire_shared ( & k)
391+ . expect ( "second shared holder coexists" ) ;
392+
393+ // A shared holder blocks the exclusive writer — this is the property
394+ // the model-cache GC relies on (never delete under a live reader).
395+ match store. acquire ( & k) {
396+ Err ( LeaseError :: Held { key } ) => assert_eq ! ( key. scope_key, "shared" ) ,
397+ other => panic ! ( "exclusive must be Held while shared holders live, got {other:?}" ) ,
398+ }
399+
400+ drop ( s1) ;
401+ // Still one shared holder alive: exclusive must STILL be blocked.
402+ match store. acquire ( & k) {
403+ Err ( LeaseError :: Held { .. } ) => { }
404+ other => {
405+ panic ! ( "exclusive must stay Held until the last shared holder drops, got {other:?}" )
406+ }
407+ }
408+
409+ drop ( s2) ;
410+ let g = store
411+ . acquire ( & k)
412+ . expect ( "exclusive after all shared holders released" ) ;
413+ drop ( g) ;
414+ let _ = std:: fs:: remove_dir_all ( dir) ;
415+ }
416+
417+ #[ test]
418+ fn exclusive_holder_blocks_shared ( ) {
419+ let ( store, dir) = tmp_store ( ) ;
420+ let k = key ( "excl-blocks-shared" ) ;
421+
422+ let g = store. acquire ( & k) . expect ( "exclusive" ) ;
423+ match store. acquire_shared ( & k) {
424+ Err ( LeaseError :: Held { key } ) => assert_eq ! ( key. scope_key, "excl-blocks-shared" ) ,
425+ other => panic ! ( "shared must be Held while exclusive holder lives, got {other:?}" ) ,
426+ }
427+ drop ( g) ;
428+ let s = store
429+ . acquire_shared ( & k)
430+ . expect ( "shared after exclusive released" ) ;
431+ drop ( s) ;
432+ let _ = std:: fs:: remove_dir_all ( dir) ;
433+ }
434+
435+ #[ test]
436+ fn shared_acquisition_does_not_bump_the_write_epoch ( ) {
437+ let ( store, dir) = tmp_store ( ) ;
438+ let k = key ( "epoch-neutral" ) ;
439+
440+ let g = store. acquire ( & k) . expect ( "writer" ) ;
441+ assert_eq ! ( g. epoch( ) , 1 ) ;
442+ drop ( g) ;
443+
444+ // Shared holders observe the persisted epoch but never advance it.
445+ let s1 = store. acquire_shared ( & k) . expect ( "shared" ) ;
446+ assert_eq ! ( s1. epoch( ) , 1 , "shared handle reports last writer epoch" ) ;
447+ drop ( s1) ;
448+ let s2 = store. acquire_shared ( & k) . expect ( "shared again" ) ;
449+ assert_eq ! ( s2. epoch( ) , 1 ) ;
450+ drop ( s2) ;
451+
452+ let g2 = store. acquire ( & k) . expect ( "writer again" ) ;
453+ assert_eq ! (
454+ g2. epoch( ) ,
455+ 2 ,
456+ "writer epoch continues from 1: shared holders did not consume epochs"
457+ ) ;
458+ drop ( g2) ;
459+ let _ = std:: fs:: remove_dir_all ( dir) ;
460+ }
461+
462+ // Unix-only: the child uses fcntl.flock. On Windows the same-process tests
463+ // above still exercise the real LockFileEx shared/exclusive semantics via
464+ // fs2, because LockFileEx locks are per-handle (two handles in one process
465+ // behave like two processes for contention purposes).
466+ #[ cfg( unix) ]
467+ #[ test]
468+ fn shared_lease_across_processes_blocks_exclusive ( ) {
469+ // Cross-PROCESS proof (not just same-process flock semantics): a child
470+ // process holds a shared lease while the parent tries exclusive.
471+ // flock/LockFileEx semantics are per-open-file-description, so the
472+ // same-process tests above could in principle pass with per-fd
473+ // semantics that differ across processes; this pins the real contract.
474+ let ( store, dir) = tmp_store ( ) ;
475+ let k = key ( "xproc" ) ;
476+
477+ // Learn the exact lock file path by acquiring+releasing once (also
478+ // seeds the epoch file).
479+ let g = store. acquire ( & k) . expect ( "seed" ) ;
480+ drop ( g) ;
481+ let lock_path = {
482+ let mut entries = std:: fs:: read_dir ( & dir) . expect ( "lease dir" ) ;
483+ let entry = entries. next ( ) . expect ( "one lease file" ) . expect ( "dir entry" ) ;
484+ entry. path ( )
485+ } ;
486+
487+ // Child: hold a SHARED flock on the lease file for 2 seconds.
488+ // `flock(1)` from util-linux is absent on macOS, so use a tiny python
489+ // child — python is available on every dev/CI platform we run.
490+ let mut child = std:: process:: Command :: new ( "python3" )
491+ . arg ( "-c" )
492+ . arg ( format ! (
493+ "import fcntl,time\n f=open({lock_path:?},'r+')\n fcntl.flock(f,fcntl.LOCK_SH)\n print('held',flush=True)\n time.sleep(2)" ,
494+ ) )
495+ . stdout ( std:: process:: Stdio :: piped ( ) )
496+ . spawn ( )
497+ . expect ( "spawn shared-holder child" ) ;
498+
499+ // Wait until the child confirms it holds the shared lock.
500+ {
501+ use std:: io:: BufRead ;
502+ let stdout = child. stdout . take ( ) . expect ( "child stdout" ) ;
503+ let mut line = String :: new ( ) ;
504+ std:: io:: BufReader :: new ( stdout)
505+ . read_line ( & mut line)
506+ . expect ( "child readiness line" ) ;
507+ assert_eq ! ( line. trim( ) , "held" ) ;
508+ }
509+
510+ // Parent: exclusive must be Held while the child's shared lock lives.
511+ match store. acquire ( & k) {
512+ Err ( LeaseError :: Held { .. } ) => { }
513+ other => {
514+ panic ! ( "exclusive must be Held under cross-process shared lock, got {other:?}" )
515+ }
516+ }
517+ // Shared, however, coexists with the child's shared lock.
518+ let s = store
519+ . acquire_shared ( & k)
520+ . expect ( "shared coexists with cross-process shared holder" ) ;
521+ drop ( s) ;
522+
523+ child. wait ( ) . expect ( "child exit" ) ;
524+ let g = store. acquire ( & k) . expect ( "exclusive after child released" ) ;
525+ drop ( g) ;
526+ let _ = std:: fs:: remove_dir_all ( dir) ;
527+ }
528+
317529 #[ test]
318530 fn epoch_persists_across_store_instances ( ) {
319531 let ( store, dir) = tmp_store ( ) ;
0 commit comments