Skip to content

Commit 593c2a1

Browse files
Fix catalog watcher refresh deadlock
agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@e4789b0
1 parent 439bc5a commit 593c2a1

1 file changed

Lines changed: 71 additions & 28 deletions

File tree

src/watch.rs

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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))]
104105
type DirIdentity = ();
105106

106107
#[cfg(unix)]
107108
fn 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.
212247
fn 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

Comments
 (0)