Skip to main content

espresso_node/persistence/
storage_probe.rs

1//! One-shot startup probe of the SQLite and file-system storage backends.
2//!
3//! Consensus writes to disk on every view, taking the database's (or data directory's) single
4//! writer lock. A validator on a network filesystem, a volatile filesystem, or a disk with slow
5//! fsync sees that cost as consensus timeouts, with nothing pointing at the storage layer. This
6//! module answers that once per process start, and never fails startup: every part that can
7//! error degrades to an unknown/absent value and logs at DEBUG.
8//!
9//! The fs backend never calls fsync itself (`fs::Persistence` writes via `std::fs::write`, which
10//! does not sync); the fsync number still bounds `sync_data` cost there, and the filesystem
11//! classification is backend-neutral either way.
12
13use std::{
14    io::{self, Seek, Write},
15    path::{Path, PathBuf},
16    time::{Duration, Instant},
17};
18
19use hotshot_types::traits::metrics::Metrics;
20use rand::Rng;
21use tempfile::Builder;
22
23#[cfg(target_os = "linux")]
24const MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
25
26/// Hard ceiling on the whole blocking half of the probe. Sampling is bounded to [`BUDGET`], so
27/// only an unresponsive filesystem gets near this: a single uninterruptible `fdatasync` or `stat`
28/// against a dead hard-mounted NFS server. Reaching it aborts startup, because a node that cannot
29/// touch its data directory cannot persist consensus state either.
30const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
31
32const MAX_SAMPLES: usize = 64;
33const BUDGET: Duration = Duration::from_secs(1);
34
35/// p50 fsync latency above which the disk is logged as ERROR. Deliberately far above anything
36/// healthy, because there is no validated threshold yet: ordinary cloud block storage sits at
37/// 0.5-2ms and ZFS on an encrypted volume at ~3ms, so a lower bar flags working nodes. At half a
38/// second per commit a single durable write already costs a quarter of a ~2s view. Everything
39/// below this is reported as a number and left to the operator.
40const FSYNC_ERROR: Duration = Duration::from_millis(500);
41
42/// Filesystem types where POSIX advisory locking is unreliable: remote/distributed filesystems,
43/// the FUSE drivers that proxy to one, and the host-shared mounts Docker Desktop, Colima, Lima
44/// and VirtualBox use to bind a directory from outside the VM/container. Other `fuse.*` drivers
45/// (e.g. `fuse.gocryptfs`, `fuse.bindfs`) are local-backed, so they fall through to
46/// [`FsClass::Unknown`] instead.
47#[cfg(target_os = "linux")]
48const NETWORK_FS: &[&str] = &[
49    "nfs",
50    "nfs4",
51    "cifs",
52    "smb3",
53    "smbfs",
54    "9p",
55    "ceph",
56    "glusterfs",
57    "lustre",
58    "afs",
59    "fuse.sshfs",
60    "fuse.s3fs",
61    "fuse.rclone",
62    "fuse.juicefs",
63    "fuse.gcsfuse",
64    "fuse.blobfuse",
65    "fuse.davfs",
66    "fuse.glusterfs",
67    "fuse.cephfs",
68    "fuse.ceph",
69    "virtiofs",
70    "vboxsf",
71    "fuse.grpcfuse",
72    "fuse.osxfs",
73];
74#[cfg(target_os = "linux")]
75const VOLATILE_FS: &[&str] = &["tmpfs", "ramfs"];
76#[cfg(target_os = "linux")]
77const CONTAINER_FS: &[&str] = &["overlay", "aufs"];
78#[cfg(target_os = "linux")]
79const LOCAL_FS: &[&str] = &[
80    "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "bcachefs", "jfs", "reiserfs",
81];
82
83#[derive(Clone, Debug)]
84pub struct StorageProbe {
85    /// `None` when the filesystem wasn't classified: on non-Linux platforms, where
86    /// `/proc/self/mountinfo` doesn't exist.
87    pub fs: Option<FsInfo>,
88    pub fsync: Option<FsyncStats>,
89    /// Set alongside `fsync: None`, to carry the `io::Error` into the WARN log line.
90    fsync_error: Option<String>,
91    /// `None` for the fs backend, which has no pragmas to read.
92    pub pragmas: Option<SqlitePragmas>,
93}
94
95// FsInfo and FsClass are only ever constructed on Linux (`unknown_fs_info`, `parse_mountinfo`);
96// on other platforms nothing builds them, which trips `dead_code` even though they are matched
97// on and read.
98#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
99#[derive(Clone, Debug)]
100pub struct FsInfo {
101    pub fs_type: String,
102    pub source: String,
103    pub mount_point: PathBuf,
104    pub class: FsClass,
105}
106
107/// How suitable a filesystem is for a SQLite database that consensus writes to on every view.
108#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum FsClass {
111    Local,
112    /// POSIX advisory locking is unreliable.
113    Network,
114    /// Contents do not survive a restart.
115    Volatile,
116    /// Container layer or union mount; writes land somewhere the operator did not choose.
117    Container,
118    /// Neither local nor one of the above; may also mean mountinfo couldn't be read.
119    Unknown,
120}
121
122/// `p50`/`max` time the whole write loop (`seek` + `write_all` + `sync_data`), so they measure
123/// durable-write latency, not `sync_data` alone.
124#[derive(Clone, Debug)]
125pub struct FsyncStats {
126    /// How many samples the budget allowed; a low count means each fsync was slow.
127    pub samples: usize,
128    pub p50: Duration,
129    pub max: Duration,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct SqlitePragmas {
134    pub journal_mode: String,
135    pub synchronous: &'static str,
136    /// SQLite honours `auto_vacuum = INCREMENTAL` only on an empty database, so a file created
137    /// before that option was set silently reports `none` and the pruner's `incremental_vacuum`
138    /// reclaims nothing.
139    pub auto_vacuum: &'static str,
140    pub page_size: u64,
141}
142
143/// Runs the blocking half on a dedicated thread under [`PROBE_TIMEOUT`], and returns an error if
144/// it expires. `canonicalize` is inside that half deliberately: `stat` on a hung hard mount blocks
145/// uninterruptibly, so the classification can hang before the fsync probe is even reached.
146///
147/// Logs the filesystem classification as soon as it is known, so a mount whose `fdatasync` hangs
148/// still says which filesystem it was.
149pub async fn probe(dir: &Path, pragmas: Option<SqlitePragmas>) -> anyhow::Result<StorageProbe> {
150    let page_size = pragmas.as_ref().map(|p| p.page_size);
151    let owned = dir.to_path_buf();
152
153    let blocking = tokio::task::spawn_blocking(move || {
154        let fs = resolve_fs_info(&owned);
155        log_fs_info(&owned, fs.as_ref());
156        (fs, fsync_probe(&owned, page_size))
157    });
158
159    // The timed-out thread stays parked in the kernel; `main` bounds teardown with
160    // `Runtime::shutdown_timeout` so it cannot hold up the exit.
161    let (fs, fsync) = match tokio::time::timeout(PROBE_TIMEOUT, blocking).await {
162        Ok(Ok(result)) => result,
163        Ok(Err(err)) => {
164            tracing::warn!(%err, "storage probe: probe task failed, skipping");
165            (None, Err(io::Error::other(err)))
166        },
167        Err(_) => anyhow::bail!(
168            "storage directory {} did not respond within {}s. The usual cause is a network mount \
169             whose server is unreachable: a hard-mounted NFS or CIFS export retries forever \
170             rather than returning an error. Check that the mount is alive. The node cannot \
171             persist consensus state on a filesystem that does not respond.",
172            dir.display(),
173            PROBE_TIMEOUT.as_secs(),
174        ),
175    };
176
177    let (fsync, fsync_error) = match fsync {
178        Ok(stats) => (Some(stats), None),
179        Err(err) => (None, Some(err.to_string())),
180    };
181
182    let probe = StorageProbe {
183        fs,
184        fsync,
185        fsync_error,
186        pragmas,
187    };
188    probe.log_fsync_and_pragmas();
189    Ok(probe)
190}
191
192fn log_fs_info(path: &Path, fs: Option<&FsInfo>) {
193    let Some(fs) = fs else {
194        tracing::debug!(
195            path = %path.display(),
196            "storage probe: filesystem was not probed on this platform"
197        );
198        return;
199    };
200
201    tracing::info!(
202        target: "announce",
203        fs = fs.fs_type.as_str(),
204        source = fs.source.as_str(),
205        mount = %fs.mount_point.display(),
206        class = fs_class_label(fs.class),
207        path = %path.display(),
208        "storage probe: filesystem"
209    );
210
211    match fs.class {
212        FsClass::Local => {},
213        FsClass::Network => tracing::error!(
214            "storage probe: ESPRESSO_NODE_STORAGE_PATH is on a network or host-shared filesystem. \
215             SQLite file locking does not work reliably on these and the database can be \
216             corrupted. Move it to a disk attached to this machine."
217        ),
218        FsClass::Volatile => tracing::warn!(
219            "storage probe: ESPRESSO_NODE_STORAGE_PATH is on a RAM-backed filesystem. Everything \
220             under it is erased when this machine reboots, and the node will have to resync from \
221             genesis. Point it at a real disk, for example /var/lib/espresso."
222        ),
223        FsClass::Container => tracing::warn!(
224            "storage probe: ESPRESSO_NODE_STORAGE_PATH is inside the container's own writable \
225             layer, not a mounted volume. The database is destroyed whenever the container is \
226             recreated, which includes every image update. Mount a Docker volume or a host \
227             directory and point ESPRESSO_NODE_STORAGE_PATH at it."
228        ),
229        FsClass::Unknown => tracing::warn!(
230            "storage probe: Could not classify the filesystem backing ESPRESSO_NODE_STORAGE_PATH. \
231             If this is a network mount or a RAM disk, move the database to a local persistent \
232             disk."
233        ),
234    }
235}
236
237impl StorageProbe {
238    fn log_fsync_and_pragmas(&self) {
239        let journal_mode = self
240            .pragmas
241            .as_ref()
242            .map_or("n/a", |p| p.journal_mode.as_str());
243        let synchronous = self.pragmas.as_ref().map_or("n/a", |p| p.synchronous);
244        let auto_vacuum = self.pragmas.as_ref().map_or("n/a", |p| p.auto_vacuum);
245        let page_size = self.pragmas.as_ref().map(|p| p.page_size);
246
247        // `Option<T: Value>` records nothing when `None`, so the fsync fields are simply absent
248        // when the probe could not run, rather than suppressing the pragmas along with them.
249        tracing::info!(
250            target: "announce",
251            journal_mode,
252            synchronous,
253            auto_vacuum,
254            page_size,
255            p50_us = self.fsync.as_ref().map(|f| f.p50.as_micros()),
256            max_us = self.fsync.as_ref().map(|f| f.max.as_micros()),
257            samples = self.fsync.as_ref().map(|f| f.samples),
258            "storage probe: fsync and pragmas"
259        );
260
261        let Some(fsync) = &self.fsync else {
262            let error = self.fsync_error.as_deref().unwrap_or("unknown error");
263            tracing::warn!(
264                error,
265                "storage probe: Could not write a test file into the storage directory. Check \
266                 that it exists and is writable by the user running the node."
267            );
268            return;
269        };
270
271        if fsync.p50 > FSYNC_ERROR {
272            tracing::error!(
273                "storage probe: Disk is extremely slow to commit writes, far beyond what \
274                 consensus can absorb per view. Use a local SSD or NVMe; network storage and \
275                 throttled cloud volumes (exhausted burst credits) are the usual cause."
276            );
277        }
278    }
279
280    fn backend_label(&self) -> &'static str {
281        if self.pragmas.is_some() {
282            "sqlite"
283        } else {
284            "fs"
285        }
286    }
287
288    /// Callers pass a subgroup, so the exported names carry that prefix, e.g.
289    /// `consensus_disk_info`. Microseconds because [`Gauge::set`] takes a `usize`; a histogram
290    /// would give seconds, but the probe never re-runs, so its buckets' rate is always zero.
291    pub fn register(&self, metrics: &dyn Metrics) {
292        let (fs_type, fs_class) = match &self.fs {
293            Some(fs) => (fs.fs_type.clone(), fs_class_label(fs.class).to_string()),
294            None => ("unknown".to_string(), "not_probed".to_string()),
295        };
296
297        // The mount source stays out of the label set: for a network mount it is an internal
298        // hostname, and nothing aggregates by device. It is in the log line instead.
299        metrics
300            .text_family(
301                "info".to_string(),
302                vec![
303                    "backend".to_string(),
304                    "fs".to_string(),
305                    "class".to_string(),
306                    "journal_mode".to_string(),
307                    "synchronous".to_string(),
308                    "auto_vacuum".to_string(),
309                ],
310            )
311            .create(vec![
312                self.backend_label().to_string(),
313                fs_type,
314                fs_class,
315                self.pragmas
316                    .as_ref()
317                    .map_or("n/a".to_string(), |p| p.journal_mode.clone()),
318                self.pragmas
319                    .as_ref()
320                    .map_or("n/a".to_string(), |p| p.synchronous.to_string()),
321                self.pragmas
322                    .as_ref()
323                    .map_or("n/a".to_string(), |p| p.auto_vacuum.to_string()),
324            ]);
325
326        let Some(fsync) = &self.fsync else {
327            return;
328        };
329
330        let fsync_micros =
331            metrics.gauge_family("fsync_micros".to_string(), vec!["stat".to_string()]);
332        for (stat, value) in [("p50", fsync.p50), ("max", fsync.max)] {
333            fsync_micros
334                .create(vec![stat.to_string()])
335                .set(value.as_micros() as usize);
336        }
337    }
338}
339
340fn fs_class_label(class: FsClass) -> &'static str {
341    match class {
342        FsClass::Local => "local",
343        FsClass::Network => "network",
344        FsClass::Volatile => "volatile",
345        FsClass::Container => "container",
346        FsClass::Unknown => "unknown",
347    }
348}
349
350#[cfg(target_os = "linux")]
351fn unknown_fs_info() -> FsInfo {
352    FsInfo {
353        fs_type: "unknown".to_string(),
354        source: "unknown".to_string(),
355        mount_point: PathBuf::from("unknown"),
356        class: FsClass::Unknown,
357    }
358}
359
360#[cfg(target_os = "linux")]
361fn resolve_fs_info(dir: &Path) -> Option<FsInfo> {
362    let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
363
364    let mountinfo = match std::fs::read_to_string(MOUNTINFO_PATH) {
365        Ok(content) => content,
366        Err(err) => {
367            // On a Linux node an unreadable mountinfo is a real anomaly, not an expected gap.
368            tracing::warn!(?err, "storage probe: failed to read {MOUNTINFO_PATH}");
369            return Some(unknown_fs_info());
370        },
371    };
372
373    Some(parse_mountinfo(&mountinfo, &canonical).unwrap_or_else(unknown_fs_info))
374}
375
376/// `/proc/self/mountinfo` is Linux-only; other platforms are not classified.
377#[cfg(not(target_os = "linux"))]
378fn resolve_fs_info(_dir: &Path) -> Option<FsInfo> {
379    None
380}
381
382/// Longest mount point that is a prefix of `path`. `path` is already canonicalized.
383///
384/// When two mounts share the same mount point (an overmount stacking a new mount on an existing
385/// path), the later line in `/proc/self/mountinfo` is the one currently visible there.
386/// `Iterator::max_by_key` returns the *last* maximal element on a tie, so preserving the file's
387/// line order here (rather than, say, sorting) is load-bearing for that tie-break.
388#[cfg(target_os = "linux")]
389fn parse_mountinfo(mountinfo: &str, path: &Path) -> Option<FsInfo> {
390    mountinfo
391        .lines()
392        .filter_map(parse_mountinfo_line)
393        .filter(|(mount_point, ..)| path.starts_with(mount_point))
394        .max_by_key(|(mount_point, ..)| mount_point.as_os_str().len())
395        .map(|(mount_point, fs_type, source)| FsInfo {
396            class: classify_fs(&fs_type),
397            fs_type,
398            source,
399            mount_point,
400        })
401}
402
403/// Parses one `/proc/self/mountinfo` line into `(mount_point, fs_type, source)`.
404///
405/// Format: `<fields...> - <fs_type> <source> <options>`, where fields before the `-` include the
406/// mount point as the 5th whitespace-separated field, followed by zero or more optional fields
407/// (`shared:N`, `master:N`, `propagate_from:N`, `unbindable`). The mount point's fixed position
408/// makes the optional field count irrelevant here. See `proc(5)`.
409#[cfg(target_os = "linux")]
410fn parse_mountinfo_line(line: &str) -> Option<(PathBuf, String, String)> {
411    let (fields, fs_fields) = line.split_once(" - ")?;
412
413    let mount_point = fields.split_whitespace().nth(4)?;
414    let mut fs_fields = fs_fields.split_whitespace();
415    let fs_type = fs_fields.next()?;
416    let source = fs_fields.next()?;
417
418    Some((
419        PathBuf::from(mount_point.replace("\\040", " ")),
420        fs_type.to_string(),
421        source.replace("\\040", " "),
422    ))
423}
424
425/// Unrecognized filesystem types are [`FsClass::Unknown`] rather than assumed local; asserting
426/// safety for a filesystem this probe has never seen would defeat the point of probing.
427#[cfg(target_os = "linux")]
428fn classify_fs(fs_type: &str) -> FsClass {
429    if NETWORK_FS.contains(&fs_type) {
430        FsClass::Network
431    } else if VOLATILE_FS.contains(&fs_type) {
432        FsClass::Volatile
433    } else if CONTAINER_FS.contains(&fs_type) {
434        FsClass::Container
435    } else if LOCAL_FS.contains(&fs_type) {
436        FsClass::Local
437    } else {
438        FsClass::Unknown
439    }
440}
441
442/// Writes and syncs a scratch file inside `dir`, sized to `page_size` (SQLite's own page size, or
443/// 4096 if unknown), and returns fsync latency stats. The file is written and synced once before
444/// sampling to preallocate its page; each sample then seeks back to the start and overwrites in
445/// place, mirroring SQLite's steady-state writes rather than growing the file every iteration, so
446/// that `sync_data` (`fdatasync` on Linux) need not flush changed metadata such as file size.
447fn fsync_probe(dir: &Path, page_size: Option<u64>) -> io::Result<FsyncStats> {
448    let page_size = page_size.unwrap_or(4096).clamp(512, 65536) as usize;
449    let mut buf = vec![0u8; page_size];
450    // Random, not zero-filled: a copy-on-write filesystem with compression enabled (ZFS, btrfs)
451    // collapses an all-zero write into a hole, measuring nothing about real fsync latency.
452    rand::thread_rng().fill(&mut buf[..]);
453
454    let mut file = Builder::new().tempfile_in(dir)?;
455    file.write_all(&buf)?;
456    file.as_file().sync_data()?;
457
458    let mut samples = Vec::with_capacity(MAX_SAMPLES);
459    let start = Instant::now();
460    loop {
461        let sample_start = Instant::now();
462        file.seek(io::SeekFrom::Start(0))?;
463        file.write_all(&buf)?;
464        file.as_file().sync_data()?;
465        samples.push(sample_start.elapsed());
466
467        if samples.len() >= MAX_SAMPLES || start.elapsed() >= BUDGET {
468            break;
469        }
470    }
471
472    samples.sort_unstable();
473    Ok(FsyncStats {
474        p50: samples[samples.len() / 2],
475        max: *samples.last().expect("loop takes at least one sample"),
476        samples: samples.len(),
477    })
478}
479
480#[cfg(test)]
481mod test {
482    use hotshot_query_service::metrics::PrometheusMetrics;
483    use tempfile::TempDir;
484
485    use super::*;
486
487    #[test]
488    fn fsync_probe_reports_bounded_samples() {
489        let dir = TempDir::new().unwrap();
490        let stats = fsync_probe(dir.path(), None).unwrap();
491
492        assert!((1..=MAX_SAMPLES).contains(&stats.samples));
493        assert!(stats.max >= stats.p50);
494    }
495
496    #[test]
497    fn register_exports_info_metric_without_fsync() {
498        let probe = StorageProbe {
499            fs: None,
500            fsync: None,
501            fsync_error: None,
502            pragmas: None,
503        };
504        let metrics = PrometheusMetrics::default();
505
506        probe.register(&metrics);
507
508        assert!(metrics.export().unwrap().contains("info"));
509    }
510
511    #[cfg(target_os = "linux")]
512    #[test]
513    fn fs_class_lists_are_disjoint_and_lowercase() {
514        let lists: [&[&str]; 4] = [NETWORK_FS, VOLATILE_FS, CONTAINER_FS, LOCAL_FS];
515
516        for list in lists {
517            for entry in list {
518                assert_eq!(*entry, entry.to_lowercase(), "{entry} is not lowercase");
519            }
520        }
521
522        for (i, a) in lists.iter().enumerate() {
523            for b in &lists[i + 1..] {
524                for entry in *a {
525                    assert!(!b.contains(entry), "{entry} appears in two fs-class lists");
526                }
527            }
528        }
529    }
530
531    #[cfg(target_os = "linux")]
532    const MOUNTINFO_FIXTURE: &str = concat!(
533        "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p2 rw\n",
534        "37 35 0:31 / /var/lib/espresso rw,relatime shared:2 - nfs4 nfsserver:/export rw\n",
535        "38 35 0:32 / /mnt/my\\040disk rw,relatime shared:3 - ext4 /dev/sdb1 rw\n",
536        "39 35 0:33 / /proc rw,nosuid,relatime - proc proc rw\n",
537        "40 35 0:34 / /run rw,nosuid shared:1 master:2 propagate_from:3 - tmpfs tmpfs rw\n",
538        "41 35 0:35 / /mnt/overmounted rw,relatime - ext4 /dev/sdc1 rw\n",
539        "42 35 0:36 / /mnt/overmounted rw,relatime - tmpfs tmpfs rw\n",
540    );
541
542    #[cfg(target_os = "linux")]
543    #[test]
544    fn classify_fs_representative_per_class() {
545        assert_eq!(classify_fs("ext4"), FsClass::Local);
546        assert_eq!(classify_fs("nfs4"), FsClass::Network);
547        assert_eq!(classify_fs("tmpfs"), FsClass::Volatile);
548        assert_eq!(classify_fs("overlay"), FsClass::Container);
549        assert_eq!(classify_fs("made_up_fs"), FsClass::Unknown);
550    }
551
552    #[cfg(target_os = "linux")]
553    #[test]
554    fn parse_mountinfo_picks_longest_matching_mount() {
555        let info = parse_mountinfo(
556            MOUNTINFO_FIXTURE,
557            Path::new("/var/lib/espresso/sqlite/database"),
558        )
559        .expect("mount found");
560
561        assert_eq!(info.mount_point, Path::new("/var/lib/espresso"));
562        assert_eq!(info.fs_type, "nfs4");
563        assert_eq!(info.source, "nfsserver:/export");
564        assert_eq!(info.class, FsClass::Network);
565    }
566
567    #[cfg(target_os = "linux")]
568    #[test]
569    fn parse_mountinfo_unescapes_mount_point() {
570        let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new("/mnt/my disk/database")).unwrap();
571
572        assert_eq!(info.mount_point, Path::new("/mnt/my disk"));
573        assert_eq!(info.source, "/dev/sdb1");
574    }
575
576    /// Two entries at the same mount point: the later one (the currently visible overmount) must
577    /// win. This is the tie-break documented on `parse_mountinfo`.
578    #[cfg(target_os = "linux")]
579    #[test]
580    fn parse_mountinfo_overmount_prefers_later_entry() {
581        let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new("/mnt/overmounted/database"))
582            .expect("mount found");
583
584        assert_eq!(info.fs_type, "tmpfs");
585        assert_eq!(info.class, FsClass::Volatile);
586    }
587
588    #[cfg(target_os = "linux")]
589    #[test]
590    fn parse_mountinfo_line_variants() {
591        let cases = [
592            // Falls back to the root mount.
593            ("/home/user/data", "/", "ext4", FsClass::Local),
594            // No optional fields before the `-`.
595            ("/proc/self", "/proc", "proc", FsClass::Unknown),
596            // Several optional fields (shared/master/propagate_from) before the `-`.
597            ("/run/lock", "/run", "tmpfs", FsClass::Volatile),
598        ];
599        for (path, mount, fs_type, class) in cases {
600            let info = parse_mountinfo(MOUNTINFO_FIXTURE, Path::new(path)).unwrap_or_else(|| {
601                panic!("expected a mount for {path}");
602            });
603            assert_eq!(info.mount_point, Path::new(mount), "{path}");
604            assert_eq!(info.fs_type, fs_type, "{path}");
605            assert_eq!(info.class, class, "{path}");
606        }
607
608        assert!(parse_mountinfo("", Path::new("/anything")).is_none());
609    }
610}