Skip to main content

videre_core/
library_locks.rs

1//! Library-scoped advisory locks: how videre processes coordinate around one
2//! library, keyed by the library's own state directory instead of a
3//! process-global home.
4//!
5//! Three lock files under `<root>/.videre/locks`, one per concern:
6//! `activity.lock` for "the library is in use" (shared while its database is
7//! only being read, exclusive while its state changes shape),
8//! `init.lock` for "state is being brought into being or reconfigured", and
9//! `<command>.lock` so one command runs against the library at a time. Every
10//! lock is a non-blocking `flock`: a contender fails immediately with an
11//! error naming the library, never waits, the same refusal the global
12//! `pipeline_runs::acquire_lock` established for commands.
13//!
14//! The acquisition order is fixed: activity first, then init when needed,
15//! then command. Every writer takes them in that order, so two writers can
16//! never deadlock by holding what the other wants. A config edit takes init
17//! only and never the activity lock, so configuring a library neither waits
18//! for nor delays running work.
19//!
20//! Lock files are created inside an already existing locks directory and are
21//! never unlinked: the lock lives on the inode, so removing a held lock file
22//! would not release anything, it would only let the next process create a
23//! fresh file and a second, independent lock. A reader whose library has no
24//! state directory fails before creating anything, not even the locks
25//! directory, so merely looking at a library never litters it.
26//!
27//! Redirected state is refused rather than followed. A lock file that is a
28//! symlink, or that is hard-linked into another location, is an error, and
29//! so is a `.videre` that is itself a symlink: any of those would silently
30//! couple two libraries' coordination, exactly the way a hard-linked
31//! database would couple their data.
32//!
33//! One known, accepted gap, documented rather than engineered around:
34//! releasing the init lock waits for no one, so an `io_timeout` worker
35//! thread abandoned by a timed-out config edit may still complete its
36//! rename after the lock has been released (see `library_config::edit`).
37//! Closing the gap would mean blocking lock release on a thread that may
38//! never finish, trading a rare stale read for a hang.
39
40use crate::io_timeout::STAT_TIMEOUT;
41use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext};
42use anyhow::{bail, Context, Result};
43use fs2::FileExt;
44use std::fs::{File, OpenOptions};
45use std::os::unix::fs::MetadataExt;
46use std::path::{Path, PathBuf};
47
48/// How the library's activity is held: many concurrent readers, or one
49/// process changing the library's state.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ActivityMode {
52    /// The holder only reads; any number of shared holders may coexist.
53    Shared,
54    /// The holder changes the library's state; no other holder is allowed.
55    Exclusive,
56}
57
58/// A held library activity lock. Dropping releases it (closing the file
59/// releases the `flock`), and process death releases it too, so nothing
60/// correct depends on `Drop` running: the same OS backstop
61/// `pipeline_runs::LockGuard` relies on.
62#[derive(Debug)]
63pub struct ActivityGuard(#[allow(dead_code)] File);
64
65/// A held library init lock, serializing initialization and config edits.
66/// Same release semantics as [`ActivityGuard`].
67#[derive(Debug)]
68pub struct InitGuard(#[allow(dead_code)] File);
69
70/// A held per-command lock. Carries the library and command it was taken
71/// for, so a caller handing it to bookkeeping such as
72/// `pipeline_runs::track_in` can be verified against what it claims to be
73/// rather than trusted.
74#[derive(Debug)]
75pub struct CommandGuard {
76    #[allow(dead_code)]
77    file: File,
78    root: PathBuf,
79    command: String,
80}
81
82impl CommandGuard {
83    /// Verify this guard was taken for exactly `ctx` and `command`.
84    ///
85    /// A guard handed to the wrong library or command is a wiring bug, and
86    /// silently accepting it would record one library's run against another
87    /// (or one command's row against another command's), so it is refused
88    /// before anything is written. This is a wiring check, not a
89    /// root-identity check: it compares canonical path strings, and root
90    /// identity across time is enforced by the context's
91    /// [`LibraryContext::ensure_root_identity`] at open boundaries.
92    pub(crate) fn ensure_matches(&self, ctx: &LibraryContext, command: &str) -> Result<()> {
93        anyhow::ensure!(
94            self.command == command,
95            "the command lock is held for '{}', not '{}'",
96            self.command,
97            command
98        );
99        anyhow::ensure!(
100            self.root == ctx.paths.root,
101            "the command lock was taken for library {}, not {}",
102            self.root.display(),
103            ctx.paths.root.display()
104        );
105        Ok(())
106    }
107}
108
109/// The lock file for one concern inside the library's locks directory.
110fn lock_path(ctx: &LibraryContext, kind: &str) -> PathBuf {
111    ctx.paths.locks.join(format!("{kind}.lock"))
112}
113
114/// Refuse a command name that would not name one file inside the locks
115/// directory. Command names come from videre's own call sites, so this is
116/// hygiene rather than a security boundary, but a `/` or a leading dot would
117/// escape the locks directory or hide the file, and neither should pass
118/// unnoticed.
119fn validate_command_name(command: &str) -> Result<()> {
120    anyhow::ensure!(!command.is_empty(), "a command lock needs a command name");
121    anyhow::ensure!(
122        !command.contains('/') && !command.contains('\\') && !command.starts_with('.'),
123        "{command:?} is not a valid command lock name"
124    );
125    Ok(())
126}
127
128/// `lstat`, mapped to `Ok(None)` for a missing path. Not following symlinks
129/// is the point: this is how a redirect is seen as a redirect rather than as
130/// whatever it points at.
131fn lstat_maybe(path: &Path) -> Result<Option<std::fs::Metadata>> {
132    let owned = path.to_path_buf();
133    match bounded_op(path, "read", STAT_TIMEOUT, move || {
134        std::fs::symlink_metadata(&owned)
135    }) {
136        Ok(meta) => Ok(Some(meta)),
137        Err(e) if root_cause_is_not_found(&e) => Ok(None),
138        Err(e) => Err(e),
139    }
140}
141
142/// Refuse redirected state: `path`, when it exists, must be a regular file
143/// with exactly one hard link.
144///
145/// A symlink could point anywhere, outside the library and outside the
146/// volume, and a multiply-linked file makes two names share one underlying
147/// state, coupling two libraries without any visible sign. Neither is
148/// something videre itself ever creates, so both are treated as the
149/// misconfiguration they are. Absent is fine: creation is the caller's
150/// decision.
151pub(crate) fn reject_redirect(path: &Path, what: &str) -> Result<()> {
152    let Some(meta) = lstat_maybe(path)? else {
153        return Ok(());
154    };
155    if meta.file_type().is_symlink() {
156        bail!(
157            "{what} {} must not be a symlink; redirected library state is not supported",
158            path.display()
159        );
160    }
161    if meta.nlink() > 1 {
162        bail!(
163            "{what} {} is hard-linked into another location; two libraries cannot share one {}",
164            path.display(),
165            what
166        );
167    }
168    Ok(())
169}
170
171/// Refuse a redirected state directory: `path`, when it exists, must not be
172/// a symlink. The directory counterpart of [`reject_redirect`], narrowed to
173/// symlinks because a directory's `nlink` counts its subdirectories rather
174/// than names sharing an inode, so the multi-link rule for files is
175/// meaningless here (and hard links to directories are not creatable to
176/// begin with). Absent is fine: creation is the caller's decision.
177pub(crate) fn reject_dir_redirect(path: &Path, what: &str) -> Result<()> {
178    let Some(meta) = lstat_maybe(path)? else {
179        return Ok(());
180    };
181    if meta.file_type().is_symlink() {
182        bail!(
183            "{what} {} must not be a symlink; redirected library state is not supported",
184            path.display()
185        );
186    }
187    Ok(())
188}
189
190/// `path`, when it exists, must be a real directory, not reached through a
191/// symlink. `create_dir_all` would happily follow a symlinked directory and
192/// build videre's state somewhere else entirely, so the check comes first.
193/// `Ok(None)` means absent, which callers may go on to create.
194fn existing_dir(path: &Path) -> Result<Option<std::fs::Metadata>> {
195    let Some(meta) = lstat_maybe(path)? else {
196        return Ok(None);
197    };
198    if meta.file_type().is_symlink() {
199        bail!(
200            "{} must not be a symlink; redirected library state is not supported",
201            path.display()
202        );
203    }
204    anyhow::ensure!(meta.is_dir(), "{} is not a directory", path.display());
205    Ok(Some(meta))
206}
207
208/// A reader's state check: the pinned root must still be the pinned root,
209/// and its `.videre` must already exist. Fails before creating anything, so
210/// looking at an uninitialized library leaves it exactly as it was.
211pub(crate) fn verify_state(ctx: &LibraryContext) -> Result<()> {
212    ctx.ensure_root_identity()?;
213    if existing_dir(&ctx.paths.state)?.is_none() {
214        bail!(
215            "library {} is not initialized: {} does not exist",
216            ctx.paths.root.display(),
217            ctx.paths.state.display()
218        );
219    }
220    Ok(())
221}
222
223/// The state and locks directories, which only writers create. A state
224/// directory that already exists is validated rather than trusted, so a
225/// symlinked or non-directory `.videre` is refused before anything is built
226/// on top of it.
227pub(crate) fn ensure_state_and_locks(ctx: &LibraryContext) -> Result<()> {
228    ctx.ensure_root_identity()?;
229    existing_dir(&ctx.paths.state)?;
230    existing_dir(&ctx.paths.locks)?;
231    let owned = ctx.paths.locks.clone();
232    bounded_op(&ctx.paths.locks, "create", STAT_TIMEOUT, move || {
233        std::fs::create_dir_all(&owned)
234    })
235    .with_context(|| format!("create {}", ctx.paths.locks.display()))?;
236    Ok(())
237}
238
239/// The checks every lock acquisition runs first: the state directory exists
240/// (created by initialization, never by locking), and so does the locks
241/// directory. Failing here is what keeps a reader from bringing either into
242/// existence.
243fn require_locks(ctx: &LibraryContext) -> Result<()> {
244    verify_state(ctx)?;
245    if existing_dir(&ctx.paths.locks)?.is_none() {
246        bail!(
247            "library {} is not initialized: {} does not exist",
248            ctx.paths.root.display(),
249            ctx.paths.locks.display()
250        );
251    }
252    Ok(())
253}
254
255/// Open (creating if absent) and non-blockingly lock one lock file. The file
256/// is never removed afterwards; the lock is released by closing it.
257fn acquire_lock_file(path: &Path, exclusive: bool, busy: String, what: &str) -> Result<File> {
258    reject_redirect(path, what)?;
259    let owned = path.to_path_buf();
260    let file = bounded_op(path, "open", STAT_TIMEOUT, move || {
261        OpenOptions::new()
262            .read(true)
263            .write(true)
264            .create(true)
265            .open(&owned)
266    })
267    .with_context(|| format!("open lock file {}", path.display()))?;
268    // fs2's trait methods are called by their full path on purpose: std grew
269    // inherent file-locking methods of the same names (1.89), and an inherent
270    // method silently wins over a trait method, which would mix two locking
271    // implementations without any error to notice.
272    let taken = if exclusive {
273        FileExt::try_lock_exclusive(&file)
274    } else {
275        FileExt::try_lock_shared(&file)
276    };
277    taken.map_err(|_| anyhow::anyhow!("{busy}"))?;
278    Ok(file)
279}
280
281/// Take the library's activity lock: shared by readers, exclusive for
282/// whoever changes the library's state (initialization, a schema upgrade).
283/// Non-blocking; a held lock is an immediate error naming the library.
284pub fn try_activity(ctx: &LibraryContext, mode: ActivityMode) -> Result<ActivityGuard> {
285    require_locks(ctx)?;
286    let busy = format!(
287        "library {} is in use by another videre process (its activity lock is held)",
288        ctx.paths.root.display()
289    );
290    let file = acquire_lock_file(
291        &lock_path(ctx, "activity"),
292        mode == ActivityMode::Exclusive,
293        busy,
294        "the library activity lock file",
295    )?;
296    Ok(ActivityGuard(file))
297}
298
299/// Take the library's init lock, serializing state creation and config
300/// edits. Never taken after the activity lock by the same process (see the
301/// module's acquisition order); config edits take it without activity.
302pub fn try_init(ctx: &LibraryContext) -> Result<InitGuard> {
303    require_locks(ctx)?;
304    let busy = format!(
305        "another videre process is initializing library {}",
306        ctx.paths.root.display()
307    );
308    let file = acquire_lock_file(
309        &lock_path(ctx, "init"),
310        true,
311        busy,
312        "the library init lock file",
313    )?;
314    Ok(InitGuard(file))
315}
316
317/// Take the library's per-command lock, so one command runs against the
318/// library at a time. Different commands do not contend with each other,
319/// only with themselves.
320pub fn try_command(ctx: &LibraryContext, command: &str) -> Result<CommandGuard> {
321    validate_command_name(command)?;
322    require_locks(ctx)?;
323    let busy = format!(
324        "{command} is already running against library {}",
325        ctx.paths.root.display()
326    );
327    let file = acquire_lock_file(
328        &lock_path(ctx, command),
329        true,
330        busy,
331        "the library command lock file",
332    )?;
333    Ok(CommandGuard {
334        file,
335        root: ctx.paths.root.clone(),
336        command: command.to_string(),
337    })
338}
339
340/// Whether another live process currently holds `command`'s lock for this
341/// library. A pure probe: creates nothing and never blocks, so it is safe on
342/// any read path; a missing lock file (or a library with no state at all)
343/// is simply not running.
344pub fn command_locked(ctx: &LibraryContext, command: &str) -> Result<bool> {
345    validate_command_name(command)?;
346    let path = lock_path(ctx, command);
347    // The redirected-state rules apply to a probe too: a symlinked or
348    // multiply-linked lock file is a misconfiguration to report, not a lock
349    // to answer for.
350    reject_redirect(&path, "the library command lock file")?;
351    let Some(meta) = lstat_maybe(&path)? else {
352        return Ok(false);
353    };
354    anyhow::ensure!(
355        !meta.is_dir(),
356        "lock file {} is a directory",
357        path.display()
358    );
359    let owned = path.clone();
360    let file = bounded_op(&path, "open", STAT_TIMEOUT, move || {
361        OpenOptions::new().read(true).write(true).open(&owned)
362    })
363    .with_context(|| format!("open lock file {}", path.display()))?;
364    match file.try_lock_exclusive() {
365        // Free for the taking means nobody holds it; release immediately so
366        // the probe itself never shows up as contention.
367        Ok(()) => {
368            FileExt::unlock(&file).ok();
369            Ok(false)
370        }
371        Err(_) => Ok(true),
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::library::LibraryContext;
379
380    /// One library root plus a context on it, with the state directory and
381    /// its locks directory already in place: the minimum locking needs, so
382    /// lock behaviour can be tested without pulling the database layer in.
383    fn locked_library() -> (tempfile::TempDir, LibraryContext) {
384        let temp = tempfile::tempdir().unwrap();
385        let root = temp.path().join("photos");
386        std::fs::create_dir(&root).unwrap();
387        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
388        std::fs::create_dir_all(&ctx.paths.locks).unwrap();
389        (temp, ctx)
390    }
391
392    #[test]
393    fn shared_activity_coexists_but_exclusive_does_not() {
394        let (_t, ctx) = locked_library();
395        let one = try_activity(&ctx, ActivityMode::Shared).unwrap();
396        let two = try_activity(&ctx, ActivityMode::Shared).unwrap();
397        // Any shared holder excludes an exclusive one...
398        assert!(
399            try_activity(&ctx, ActivityMode::Exclusive).is_err(),
400            "a shared activity lock must refuse an exclusive taker"
401        );
402        drop(one);
403        assert!(
404            try_activity(&ctx, ActivityMode::Exclusive).is_err(),
405            "one remaining shared holder is still enough to refuse exclusive"
406        );
407        drop(two);
408        // ...and with none left, exclusive is available again.
409        let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
410    }
411
412    #[test]
413    fn an_exclusive_activity_lock_refuses_both_modes() {
414        let (_t, ctx) = locked_library();
415        let _ex = try_activity(&ctx, ActivityMode::Exclusive).unwrap();
416        assert!(try_activity(&ctx, ActivityMode::Exclusive).is_err());
417        assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
418    }
419
420    #[test]
421    fn an_held_init_lock_refuses_a_second_taker_and_an_edit() {
422        let (_t, ctx) = locked_library();
423        let held = try_init(&ctx).unwrap();
424        let err = try_init(&ctx).unwrap_err();
425        assert!(format!("{err:#}").contains("initializing"), "{err:#}");
426        // A config edit is serialized by the same lock, and fails cleanly
427        // rather than waiting for the holder.
428        let err = crate::library_config::edit(
429            &ctx,
430            crate::library_config::ConfigKey::ReadRate,
431            Some(toml::Value::Integer(9)),
432        )
433        .unwrap_err();
434        assert!(
435            format!("{err:#}").contains("initializing"),
436            "an edit must fail while the init lock is held: {err:#}"
437        );
438        drop(held);
439        crate::library_config::edit(
440            &ctx,
441            crate::library_config::ConfigKey::ReadRate,
442            Some(toml::Value::Integer(9)),
443        )
444        .unwrap();
445    }
446
447    #[test]
448    fn command_locks_contend_only_with_themselves() {
449        let (_t, ctx) = locked_library();
450        let scan = try_command(&ctx, "scan").unwrap();
451        assert!(try_command(&ctx, "scan").is_err());
452        let _faces = try_command(&ctx, "faces").unwrap();
453        // The probe answers for a held command and for a free one; faces and
454        // scan are held, watch was never taken.
455        assert!(command_locked(&ctx, "scan").unwrap());
456        assert!(command_locked(&ctx, "faces").unwrap());
457        assert!(!command_locked(&ctx, "watch").unwrap());
458        drop(scan);
459        assert!(!command_locked(&ctx, "scan").unwrap());
460        try_command(&ctx, "scan").unwrap();
461    }
462
463    #[test]
464    fn a_missing_state_directory_fails_without_creating_anything() {
465        let temp = tempfile::tempdir().unwrap();
466        let root = temp.path().join("photos");
467        std::fs::create_dir(&root).unwrap();
468        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
469        assert!(try_activity(&ctx, ActivityMode::Shared).is_err());
470        assert!(try_init(&ctx).is_err());
471        assert!(try_command(&ctx, "scan").is_err());
472        // Not even the state directory may appear, let alone a locks
473        // directory or a lock file.
474        assert!(!ctx.paths.state.exists());
475        // The pure probe answers false rather than erroring, and still
476        // creates nothing.
477        assert!(!command_locked(&ctx, "scan").unwrap());
478        assert!(!ctx.paths.state.exists());
479    }
480
481    #[test]
482    fn lock_files_are_never_unlinked() {
483        let (_t, ctx) = locked_library();
484        {
485            let _a = try_activity(&ctx, ActivityMode::Shared).unwrap();
486            let _i = try_init(&ctx).unwrap();
487            let _c = try_command(&ctx, "scan").unwrap();
488        }
489        // Released, but still present: unlinking a lock file lets the next
490        // process create a fresh one and a second, independent lock.
491        assert!(lock_path(&ctx, "activity").exists());
492        assert!(lock_path(&ctx, "init").exists());
493        assert!(lock_path(&ctx, "scan").exists());
494    }
495
496    #[test]
497    fn redirected_lock_files_are_refused() {
498        let (_t, ctx) = locked_library();
499        // A symlinked activity lock would coordinate whichever file it
500        // points at, not this library's.
501        let outside = ctx.paths.root.join("outside.lock");
502        std::os::unix::fs::symlink(&outside, lock_path(&ctx, "activity")).unwrap();
503        let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
504        assert!(format!("{err:#}").contains("symlink"), "{err:#}");
505        std::fs::remove_file(lock_path(&ctx, "activity")).unwrap();
506
507        // A hard-linked lock file couples two libraries' coordination.
508        let real = ctx.paths.locks.join("real.lock");
509        std::fs::write(&real, b"").unwrap();
510        std::fs::hard_link(&real, lock_path(&ctx, "init")).unwrap();
511        let err = try_init(&ctx).unwrap_err();
512        assert!(format!("{err:#}").contains("hard-linked"), "{err:#}");
513    }
514
515    #[test]
516    fn a_symlinked_state_directory_is_refused_for_locking() {
517        let temp = tempfile::tempdir().unwrap();
518        let root = temp.path().join("photos");
519        let elsewhere = temp.path().join("elsewhere");
520        std::fs::create_dir(&root).unwrap();
521        std::fs::create_dir(&elsewhere).unwrap();
522        std::fs::create_dir(elsewhere.join("locks")).unwrap();
523        let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
524        // .videre pointing outside the library root: an externally linked
525        // state directory must not be used, even when it appeared after the
526        // context was constructed.
527        std::os::unix::fs::symlink(&elsewhere, root.join(".videre")).unwrap();
528        let err = try_activity(&ctx, ActivityMode::Shared).unwrap_err();
529        assert!(format!("{err:#}").contains("symlink"), "{err:#}");
530    }
531
532    #[test]
533    fn root_aliases_share_one_librarys_locks() {
534        let (temp, ctx) = locked_library();
535        let alias = temp.path().join("alias");
536        std::os::unix::fs::symlink(&ctx.paths.root, &alias).unwrap();
537        // The alias context canonicalizes to the same root, so it locks the
538        // same files: a library is one library however it was reached.
539        let via_alias = LibraryContext::new(&alias, &temp.path().join("cache")).unwrap();
540        assert_eq!(via_alias.paths.locks, ctx.paths.locks);
541        let _held = try_init(&via_alias).unwrap();
542        assert!(
543            try_init(&ctx).is_err(),
544            "a lock taken through the alias must be visible through the root"
545        );
546        assert!(try_activity(&ctx, ActivityMode::Exclusive).is_ok());
547    }
548
549    #[test]
550    fn command_names_that_cannot_be_one_file_are_refused() {
551        let (_t, ctx) = locked_library();
552        for bad in ["", "a/b", "..", ".hidden", "a\\b"] {
553            assert!(try_command(&ctx, bad).is_err(), "{bad:?}");
554            assert!(command_locked(&ctx, bad).is_err(), "{bad:?}");
555        }
556    }
557
558    #[test]
559    fn a_command_guard_reports_the_library_and_command_it_was_taken_for() {
560        let (temp, ctx) = locked_library();
561        let guard = try_command(&ctx, "scan").unwrap();
562        assert!(guard.ensure_matches(&ctx, "scan").is_ok());
563        assert!(guard.ensure_matches(&ctx, "faces").is_err());
564        // A different library's context, with its own locks, is not this
565        // guard's library even though the command name matches.
566        let other_root = temp.path().join("other");
567        std::fs::create_dir(&other_root).unwrap();
568        let other = LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
569        assert!(guard.ensure_matches(&other, "scan").is_err());
570    }
571
572    #[test]
573    fn a_lock_file_open_past_its_budget_fails_closed_without_restatting_it() {
574        // Lock acquisition itself cannot time out: the flock is non-blocking
575        // by design and fails immediately when held, which the contention
576        // tests above prove. The bounded surface is the stat-and-open path
577        // every acquisition runs first (`lstat_maybe`, then
578        // `acquire_lock_file`'s open), so that is what carries the injected
579        // budget, driven with a body that reliably outlasts it. A tiny budget
580        // against an instantaneous open would race (the worker can buffer its
581        // result before the main thread reaches recv_timeout); a 50ms budget
582        // against a 5s-sleeping body always times out first.
583        let (_t, ctx) = locked_library();
584        let lock = lock_path(&ctx, "activity");
585        std::fs::write(&lock, b"").unwrap();
586        let start = std::time::Instant::now();
587        let owned = lock.clone();
588        let err = crate::library::bounded_op(
589            &lock,
590            "open",
591            std::time::Duration::from_millis(50),
592            move || {
593                std::thread::sleep(std::time::Duration::from_secs(5));
594                OpenOptions::new()
595                    .read(true)
596                    .write(true)
597                    .create(true)
598                    .open(&owned)
599                    .map(|_| ())
600            },
601        )
602        .unwrap_err();
603        // The file is removed before the message is formatted: an error that
604        // still names the exact path and phrasing cannot have consulted the
605        // filesystem to build itself, which is the unbounded re-stat mistake
606        // `TimedOutAfter::describe` exists to prevent. The abandoned worker
607        // thread may open the (existing) file before or after the removal;
608        // either way its result is discarded and nothing is asserted on it.
609        std::fs::remove_file(&lock).unwrap();
610        let msg = format!("{err:#}");
611        assert!(msg.contains("did not respond"), "{msg}");
612        assert!(msg.contains("activity.lock"), "{msg}");
613        assert!(start.elapsed() < std::time::Duration::from_secs(2));
614    }
615}