@@ -95,18 +95,21 @@ pub(crate) struct CatalogDeclarationWatcher {
9595 failed : BTreeSet < PathBuf > ,
9696}
9797
98- /// Identity of a watched directory. An inotify watch attaches to an INODE, not a name, so a
99- /// directory deleted and recreated at the same pathname is a DIFFERENT directory: matching on
100- /// identity alone lets `refresh` force re-registration for replacements.
98+ /// Identity of a watched directory. A backend watch attaches to an inode, not a name, so a
99+ /// directory deleted and recreated at the same pathname is a different subscription target.
100+ /// Include ctime as the inode generation discriminator: filesystems may immediately reuse an inode
101+ /// number, but recreating that directory still changes its metadata-change generation.
101102#[ cfg( unix) ]
102- type DirIdentity = ( u64 , u64 ) ;
103+ type DirIdentity = ( u64 , u64 , i64 , i64 ) ;
103104#[ cfg( not( unix) ) ]
104105type DirIdentity = ( ) ;
105106
106107#[ cfg( unix) ]
107108fn dir_identity ( path : & Path ) -> Option < DirIdentity > {
108109 use std:: os:: unix:: fs:: MetadataExt ;
109- fs:: metadata ( path) . ok ( ) . map ( |meta| ( meta. dev ( ) , meta. ino ( ) ) )
110+ fs:: metadata ( path)
111+ . ok ( )
112+ . map ( |meta| ( meta. dev ( ) , meta. ino ( ) , meta. ctime ( ) , meta. ctime_nsec ( ) ) )
110113}
111114
112115#[ cfg( not( unix) ) ]
@@ -160,36 +163,68 @@ impl CatalogDeclarationWatcher {
160163 false
161164 }
162165 } ) ;
166+
167+ // Stage additions without publishing them yet. A stale watch's queued removal callback
168+ // must finish before the same pathname is reserved for its replacement, or that old event
169+ // can consume the new reservation and make refresh discard a successfully registered watch.
170+ let additions = desired
171+ . into_iter ( )
172+ . filter ( |( path, _) | !watched. contains_key ( path) )
173+ . collect :: < Vec < _ > > ( ) ;
174+ drop ( watched) ;
175+
176+ // notify may synchronously wait for its event loop while registering or unregistering.
177+ // Never call it while holding the map mutex: the event-loop callback also needs that mutex
178+ // to invalidate a removed directory, so doing both at once can deadlock watcher teardown.
163179 for path in & stale {
164180 // Backends normally discard a watch when its directory disappears. An explicit
165181 // best-effort unwatch also handles moves that leave the watched inode alive elsewhere.
182+ // The synchronous backend call is also the ordering boundary after which callbacks for
183+ // the stale registration have run, so only then may this pathname represent a new watch.
166184 let _ = self . watcher . unwatch ( path) ;
167185 }
168- for added in desired. into_keys ( ) {
186+ for ( added, expected_identity) in additions {
187+ let mut watched = self . watched . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
169188 if watched. contains_key ( & added) {
170189 continue ;
171190 }
191+ watched. insert ( added. clone ( ) , None ) ;
192+ drop ( watched) ;
172193 match self . watcher . watch ( & added, RecursiveMode :: NonRecursive ) {
173194 Ok ( ( ) ) => {
174195 self . failed . remove ( & added) ;
175- match dir_identity ( & added) {
176- Some ( identity) => {
177- watched. insert ( added, Some ( identity) ) ;
178- }
179- // Vanished between registration and stat: leave it unrecorded so the
180- // next refresh retries from scratch.
181- None => {
182- let _ = self . watcher . unwatch ( & added) ;
183- }
196+ let fresh_identity = dir_identity ( & added) ;
197+ let mut watched = self . watched . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
198+ let pending = watched. get ( & added) . is_some_and ( Option :: is_none) ;
199+ let registered =
200+ pending && fresh_identity. is_some ( ) && fresh_identity == expected_identity;
201+ if registered {
202+ watched. insert ( added. clone ( ) , fresh_identity) ;
203+ } else if pending {
204+ watched. remove ( & added) ;
205+ }
206+ drop ( watched) ;
207+
208+ // The directory vanished or was replaced during registration, or its queued
209+ // removal callback already consumed the reservation. Discard this backend
210+ // watch and let the next refresh retry from a fresh identity.
211+ if !registered {
212+ let _ = self . watcher . unwatch ( & added) ;
184213 }
185214 }
186- Err ( error) if self . failed . insert ( added. clone ( ) ) => {
187- tracing:: warn!(
188- "st2: cannot watch catalog declaration directory '{}': {error}; immediate changes below it are unavailable, continuing with timer polling." ,
189- added. display( )
190- ) ;
215+ Err ( error) => {
216+ let mut watched = self . watched . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
217+ if watched. get ( & added) . is_some_and ( Option :: is_none) {
218+ watched. remove ( & added) ;
219+ }
220+ drop ( watched) ;
221+ if self . failed . insert ( added. clone ( ) ) {
222+ tracing:: warn!(
223+ "st2: cannot watch catalog declaration directory '{}': {error}; immediate changes below it are unavailable, continuing with timer polling." ,
224+ added. display( )
225+ ) ;
226+ }
191227 }
192- Err ( _) => { }
193228 }
194229 }
195230 }
@@ -205,10 +240,10 @@ impl CatalogDeclarationWatcher {
205240 }
206241}
207242
208- /// Eagerly drop tracked directories the moment the backend reports them removed or renamed
209- /// away — an inotify watch dies with its inode, and a same-pathname replacement can reuse the
210- /// old identity, so only event-time invalidation makes the next [`refresh`] re-register
211- /// deterministically instead of trusting a stat race .
243+ /// Invalidate a removed subscription without letting its delayed callback erase a replacement
244+ /// already registered at the same pathname. A live identity equal to the recorded identity proves
245+ /// the event belongs to an older registration; a `None` value is refresh's in-flight reservation,
246+ /// whose before/after identity check owns rollback if the directory changes during registration .
212247fn invalidate_removed_dirs ( watched : & Mutex < BTreeMap < PathBuf , Option < DirIdentity > > > , event : & Event ) {
213248 let torn_down = matches ! (
214249 & event. kind,
@@ -222,9 +257,17 @@ fn invalidate_removed_dirs(watched: &Mutex<BTreeMap<PathBuf, Option<DirIdentity>
222257 . lock ( )
223258 . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) ) ;
224259 for path in & event. paths {
225- watched. remove ( path) ;
226- // Removing or moving away a parent retires every watch beneath it too.
227- watched. retain ( |tracked, _| !tracked. starts_with ( path) ) ;
260+ // Removing or moving away a parent retires every watch beneath it too, except for a
261+ // replacement that refresh has already registered with a new, still-live identity.
262+ watched. retain ( |tracked, identity| {
263+ if !tracked. starts_with ( path) {
264+ return true ;
265+ }
266+ match identity {
267+ None => true ,
268+ Some ( recorded) => dir_identity ( tracked) . is_some_and ( |current| current == * recorded) ,
269+ }
270+ } ) ;
228271 }
229272}
230273
0 commit comments