Skip to main content

videre_core/
pipeline_runs.rs

1//! Per-command pipeline run history and liveness, surfaced by `videre stats`
2//! and other dashboard-style callers.
3//! See docs/superpowers/specs/2026-07-31-dashboard-stats-pass-b-design.md
4//! for the full design, and in particular why `track()` below does not rely
5//! on Drop/RAII for the success/failure bookkeeping, only the lock's
6//! release does, and even that is backstopped by the OS releasing `flock` on
7//! any process death.
8
9use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12use std::fs::{File, OpenOptions};
13use std::path::{Path, PathBuf};
14
15/// The eight commands tracked so far. Extended with `locations` on
16/// 2026-08-01 (was seven after `prune`'s addition earlier the same day).
17/// It's a clean fit for the same one-shot start/finish model the others
18/// use: `videre locations` is a full-recompute batch pass, not an
19/// interactive per-query command. `report`, `search`, `mcp`, and `config`
20/// remain deliberately excluded: `report --faces`/`--show-faces` and `mcp`
21/// are long-running servers with no natural "finished" moment (the same
22/// reason `videre watch` itself is excluded. See below), `search` is an
23/// interactive per-query command rather than a library-processing pipeline
24/// stage (true even for its new `--location` mode, which is a single query
25/// like any other `search` invocation, not a batch job), and `config` is a
26/// trivial instant read/write with nothing meaningful to time. Revisit only
27/// if a real driver for tracking one of those emerges. See TECH_DEBT.md.
28///
29/// `videre watch` itself is deliberately not in this list, it has no
30/// "finished" moment during normal operation, so it gets its own liveness
31/// lock (see `watch_lock_path`) but no `pipeline_runs` row.
32pub const TRACKED_COMMANDS: [&str; 8] = [
33    "scan",
34    "faces",
35    "embed",
36    "classify",
37    "dedupe",
38    "fix-dates",
39    "prune",
40    "locations",
41];
42
43pub fn ensure_pipeline_runs_table(conn: &Connection) -> rusqlite::Result<()> {
44    conn.execute_batch(
45        "CREATE TABLE IF NOT EXISTS pipeline_runs (
46            command      TEXT PRIMARY KEY,
47            started_at   TEXT NOT NULL,
48            finished_at  TEXT,
49            status       TEXT NOT NULL,
50            duration_ms  INTEGER,
51            summary      TEXT
52        );",
53    )
54}
55
56pub fn start_run(conn: &Connection, command: &str) -> rusqlite::Result<()> {
57    conn.execute(
58        "INSERT INTO pipeline_runs (command, started_at, status)
59         VALUES (?1, datetime('now'), 'running')
60         ON CONFLICT(command) DO UPDATE SET
61             started_at = excluded.started_at,
62             status = 'running',
63             finished_at = NULL,
64             duration_ms = NULL,
65             summary = NULL",
66        params![command],
67    )?;
68    Ok(())
69}
70
71pub fn finish_run(
72    conn: &Connection,
73    command: &str,
74    status: &str,
75    duration_ms: i64,
76    summary: Option<&str>,
77) -> rusqlite::Result<()> {
78    conn.execute(
79        "UPDATE pipeline_runs SET
80             finished_at = datetime('now'),
81             status = ?2,
82             duration_ms = ?3,
83             summary = ?4
84         WHERE command = ?1",
85        params![command, status, duration_ms, summary],
86    )?;
87    Ok(())
88}
89
90/// Holds an open, flock'd file for as long as it's alive. Dropping it closes
91/// the file, which releases the flock, the OS does the same thing
92/// automatically if the process dies without ever dropping this (SIGKILL,
93/// power loss), so there is no correctness dependency on Drop actually
94/// running; it's just the tidy path.
95pub struct LockGuard(#[allow(dead_code)] File);
96
97/// Lock file for one (database, command) pair, under `<videre home>/locks/`.
98///
99/// The name is `<db stem>-<hash of the canonical db path>.<command>.lock`. The
100/// hash is what makes this correct rather than merely tidy: two libraries can
101/// both be named `photos.db` in different directories, and keying on the
102/// basename alone would make them share a lock, silently serializing unrelated
103/// libraries, and making `videre stats` report one as running because the other
104/// is. The readable stem is kept purely so a human listing the directory can
105/// tell which database a lock belongs to.
106///
107/// The canonicalize call also means two paths to the same database (a symlink,
108/// `./photos.db` vs an absolute path) resolve to one lock, which is the
109/// property the old sidecar scheme got for free by living next to the file.
110fn lock_path_for(db_path: &Path, command: &str) -> Result<PathBuf> {
111    use std::hash::{Hash, Hasher};
112    let canonical = db_path
113        .canonicalize()
114        .with_context(|| format!("canonicalize {}", db_path.display()))?;
115    let mut hasher = std::collections::hash_map::DefaultHasher::new();
116    canonical.hash(&mut hasher);
117    let stem = canonical
118        .file_stem()
119        .map(|s| s.to_string_lossy().to_string())
120        .unwrap_or_else(|| "db".to_string());
121    Ok(crate::home::locks_dir()?.join(format!("{stem}-{:016x}.{command}.lock", hasher.finish())))
122}
123
124/// Deletes every pre-`locks/` sidecar lock (`<db path>.<command>.lock`) left
125/// behind by older versions, so upgrading actually clears the clutter from
126/// `~/.videre` instead of leaving a permanent litter of zero-byte files.
127///
128/// Only removes it when an exclusive `flock` succeeds, which proves no live
129/// process is holding it. An older binary running concurrently would still
130/// hold its sidecar, and we leave that alone, deleting a held lock file
131/// wouldn't release the lock anyway (the `flock` lives on the inode), it would
132/// just let the next process create a fresh file and a second, independent
133/// lock. Entirely best-effort: any failure here is ignored, since this is
134/// housekeeping and must never be able to fail a real run.
135fn remove_legacy_sidecar_locks(db_path: &Path) {
136    use fs2::FileExt;
137    let Ok(canonical) = db_path.canonicalize() else {
138        return;
139    };
140    // Sweep every command's sidecar, not just the one being acquired: cleaning
141    // only the current command would leave the rest sitting in the user's
142    // directory until each of those commands happened to run, and something
143    // like `watch` may not run for weeks. One command now clears the lot.
144    for command in TRACKED_COMMANDS.iter().copied().chain(["watch"]) {
145        let legacy = PathBuf::from(format!("{}.{command}.lock", canonical.display()));
146        if !legacy.exists() {
147            continue;
148        }
149        let Ok(file) = OpenOptions::new().write(true).open(&legacy) else {
150            continue;
151        };
152        if file.try_lock_exclusive().is_ok() {
153            let _ = fs2::FileExt::unlock(&file);
154            drop(file);
155            let _ = std::fs::remove_file(&legacy);
156        }
157    }
158}
159
160/// Acquires an exclusive, non-blocking advisory lock scoped to this exact
161/// database file and command. Fails immediately (refusing the run, per the
162/// concurrency decision in the design doc) if another live process already
163/// holds it, never blocks waiting for it to free up.
164pub fn acquire_lock(db_path: &Path, command: &str) -> Result<LockGuard> {
165    use fs2::FileExt;
166    let lock_path = lock_path_for(db_path, command)?;
167    if let Some(dir) = lock_path.parent() {
168        std::fs::create_dir_all(dir)
169            .with_context(|| format!("create lock directory {}", dir.display()))?;
170    }
171    remove_legacy_sidecar_locks(db_path);
172    let file = OpenOptions::new()
173        .create(true)
174        .write(true)
175        .open(&lock_path)
176        .with_context(|| format!("open lock file {}", lock_path.display()))?;
177    file.try_lock_exclusive().map_err(|_| {
178        anyhow::anyhow!("{command} is already running against {}", db_path.display())
179    })?;
180    Ok(LockGuard(file))
181}
182
183/// True if another live process currently holds `command`'s lock for
184/// `db_path`. Never blocks: probes with a non-blocking try-lock and releases
185/// immediately if it succeeds, so this is safe to call from a read path.
186pub fn is_locked(db_path: &Path, command: &str) -> Result<bool> {
187    use fs2::FileExt;
188    let lock_path = lock_path_for(db_path, command)?;
189    if !lock_path.exists() {
190        return Ok(false);
191    }
192    let file = OpenOptions::new()
193        .write(true)
194        .open(&lock_path)
195        .with_context(|| format!("open lock file {}", lock_path.display()))?;
196    match file.try_lock_exclusive() {
197        Ok(()) => {
198            FileExt::unlock(&file).ok();
199            Ok(false)
200        }
201        Err(_) => Ok(true),
202    }
203}
204
205/// Wraps `f` with pipeline-run bookkeeping: refuses to start if `command` is
206/// already running against `db_path`, records a `running` row before calling
207/// `f`, then records `success`/`failed` (with `f`'s error message, if any)
208/// once `f` returns, all before this function itself returns. Every
209/// `std::process::exit` call site this design touches happens strictly after
210/// its wrapped operation already returned a `Result` (see the design doc's
211/// "key design insight"), so this finalization is never skipped by an exit
212/// call, only an actual crash mid-`f()` skips it, which is exactly what the
213/// lock-based `crashed` detection in `read_all` is for.
214pub fn track<T>(
215    conn: &Connection,
216    db_path: &Path,
217    command: &str,
218    f: impl FnOnce() -> Result<T>,
219) -> Result<T> {
220    ensure_pipeline_runs_table(conn)?;
221    let _lock = acquire_lock(db_path, command)?;
222    start_run(conn, command)?;
223    let started = std::time::Instant::now();
224    let result = f();
225    let duration_ms = started.elapsed().as_millis() as i64;
226    match &result {
227        Ok(_) => finish_run(conn, command, "success", duration_ms, None)?,
228        Err(e) => finish_run(conn, command, "failed", duration_ms, Some(&e.to_string()))?,
229    }
230    result
231}
232
233/// Installs a SIGINT handler that marks `command`'s row `interrupted` (using
234/// its already-recorded `started_at` to compute duration) and exits 130, the
235/// standard SIGINT exit code. Call this once, after `track()`'s `start_run`
236/// has already written the `running` row for `command`, the handler opens
237/// its own fresh connection since the main thread's `Connection` isn't
238/// safely shareable across the handler boundary. Best-effort: any error
239/// inside the handler is swallowed (there's no useful way to report it once
240/// the process is already exiting on a signal).
241pub fn install_sigint_handler(db_path: &Path, command: &'static str) -> Result<()> {
242    let db_path = db_path.to_path_buf();
243    ctrlc::set_handler(move || {
244        if let Ok(conn) = Connection::open(&db_path) {
245            let started_at: Option<String> = conn
246                .query_row(
247                    "SELECT started_at FROM pipeline_runs WHERE command = ?1",
248                    params![command],
249                    |r| r.get(0),
250                )
251                .optional()
252                .ok()
253                .flatten();
254            let duration_ms = started_at
255                .and_then(|s| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok())
256                .map(|started| {
257                    (chrono::Utc::now().naive_utc() - started)
258                        .num_milliseconds()
259                        .max(0)
260                })
261                .unwrap_or(0);
262            let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
263        }
264        std::process::exit(130);
265    })
266    .context("installing SIGINT handler")
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
270pub struct PipelineRunStatus {
271    pub command: String,
272    pub last_run_at: Option<String>,
273    /// "running" | "success" | "failed" | "interrupted" | "crashed" | None if never run.
274    /// "crashed" is computed here, never a stored value. See the design doc.
275    pub status: Option<String>,
276    pub duration_ms: Option<i64>,
277    pub currently_running: bool,
278}
279
280pub fn read_all(conn: &Connection, db_path: &Path) -> Result<Vec<PipelineRunStatus>> {
281    ensure_pipeline_runs_table(conn)?;
282    let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
283    for command in TRACKED_COMMANDS {
284        let row: Option<(String, Option<i64>, String)> = conn
285            .query_row(
286                "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
287                params![command],
288                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
289            )
290            .optional()?;
291
292        let currently_running = is_locked(db_path, command)?;
293
294        let (last_run_at, status, duration_ms) = match row {
295            None => (None, None, None),
296            Some((started_at, duration_ms, stored_status)) => {
297                let status = if stored_status == "running" && !currently_running {
298                    "crashed".to_string()
299                } else {
300                    stored_status
301                };
302                (Some(started_at), Some(status), duration_ms)
303            }
304        };
305
306        out.push(PipelineRunStatus {
307            command: command.to_string(),
308            last_run_at,
309            status,
310            duration_ms,
311            currently_running,
312        });
313    }
314    Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    /// Points `VIDERE_HOME` at one throwaway directory for the whole test
322    /// binary, so lock files land there instead of the developer's real
323    /// `~/.videre/locks`. Necessary since locks moved out of the database's own
324    /// directory: before that, a temp-file database put its locks in a temp dir
325    /// that cleaned itself up, but now every test would write into the real
326    /// home and leave litter behind forever (test databases have random names,
327    /// so the files would accumulate, never being reused or overwritten).
328    ///
329    /// The `set_var` happens inside `get_or_init` so it runs exactly once even
330    /// though tests share a process and run in parallel, calling `set_var`
331    /// from several threads at once would otherwise be a data race against
332    /// every concurrent `getenv`.
333    fn isolated_home() {
334        static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
335        HOME.get_or_init(|| {
336            let dir = std::env::temp_dir().join(format!("videre-test-home-{}", std::process::id()));
337            std::fs::create_dir_all(&dir).expect("create isolated test home");
338            std::env::set_var("VIDERE_HOME", &dir);
339            dir
340        });
341    }
342
343    fn test_db() -> Connection {
344        let conn = Connection::open_in_memory().unwrap();
345        ensure_pipeline_runs_table(&conn).unwrap();
346        conn
347    }
348
349    #[test]
350    fn lock_lives_under_the_videre_home_locks_dir_not_beside_the_database() {
351        isolated_home();
352        let db = tempfile::NamedTempFile::new().unwrap();
353        let path = lock_path_for(db.path(), "scan").unwrap();
354
355        assert_eq!(path.parent().unwrap(), crate::home::locks_dir().unwrap());
356        assert!(
357            path.file_name()
358                .unwrap()
359                .to_string_lossy()
360                .ends_with(".scan.lock"),
361            "unexpected lock file name: {}",
362            path.display()
363        );
364        assert_ne!(
365            path.parent().unwrap(),
366            db.path().parent().unwrap(),
367            "lock must not be a sidecar in the database's own directory anymore"
368        );
369    }
370
371    #[test]
372    fn same_basename_in_different_directories_gets_distinct_locks() {
373        // The reason the lock name carries a hash of the full path rather than
374        // just the stem: two libraries can each be called photos.db. Sharing a
375        // lock between them would serialize unrelated work and make `videre
376        // stats` report one as running because the other is.
377        isolated_home();
378        let a_dir = tempfile::tempdir().unwrap();
379        let b_dir = tempfile::tempdir().unwrap();
380        let a = a_dir.path().join("photos.db");
381        let b = b_dir.path().join("photos.db");
382        std::fs::write(&a, b"").unwrap();
383        std::fs::write(&b, b"").unwrap();
384
385        let a_lock = lock_path_for(&a, "scan").unwrap();
386        let b_lock = lock_path_for(&b, "scan").unwrap();
387        assert_ne!(
388            a_lock, b_lock,
389            "identically-named databases must not share a lock"
390        );
391
392        // ...and both still land in the one locks directory.
393        assert_eq!(a_lock.parent(), b_lock.parent());
394    }
395
396    #[test]
397    fn acquiring_a_lock_removes_the_legacy_sidecar_left_by_older_versions() {
398        isolated_home();
399        let db = tempfile::NamedTempFile::new().unwrap();
400        let legacy = PathBuf::from(format!(
401            "{}.scan.lock",
402            db.path().canonicalize().unwrap().display()
403        ));
404        std::fs::write(&legacy, b"").unwrap();
405        assert!(legacy.exists());
406
407        let _guard = acquire_lock(db.path(), "scan").unwrap();
408        assert!(
409            !legacy.exists(),
410            "stale sidecar lock should have been cleaned up"
411        );
412    }
413
414    #[test]
415    fn ensure_pipeline_runs_table_is_idempotent() {
416        let conn = test_db();
417        ensure_pipeline_runs_table(&conn).unwrap();
418    }
419
420    #[test]
421    fn start_run_then_finish_run_records_success() {
422        let conn = test_db();
423        start_run(&conn, "embed").unwrap();
424
425        let status: String = conn
426            .query_row(
427                "SELECT status FROM pipeline_runs WHERE command = 'embed'",
428                [],
429                |r| r.get(0),
430            )
431            .unwrap();
432        assert_eq!(status, "running");
433
434        finish_run(&conn, "embed", "success", 1234, None).unwrap();
435
436        let (status, duration_ms, summary): (String, i64, Option<String>) = conn
437            .query_row(
438                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
439                [],
440                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
441            )
442            .unwrap();
443        assert_eq!(status, "success");
444        assert_eq!(duration_ms, 1234);
445        assert_eq!(summary, None);
446    }
447
448    #[test]
449    fn start_run_upserts_resetting_prior_finish_fields() {
450        let conn = test_db();
451        start_run(&conn, "embed").unwrap();
452        finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
453
454        start_run(&conn, "embed").unwrap(); // second run begins
455
456        let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
457            .query_row(
458                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
459                [],
460                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
461            )
462            .unwrap();
463        assert_eq!(status, "running");
464        assert_eq!(duration_ms, None);
465        assert_eq!(summary, None);
466
467        let count: i64 = conn
468            .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
469            .unwrap();
470        assert_eq!(count, 1, "upsert, not a second row");
471    }
472
473    #[test]
474    fn acquire_lock_refuses_a_second_concurrent_acquisition() {
475        isolated_home();
476        let db_file = tempfile::NamedTempFile::new().unwrap();
477        let db_path = db_file.path();
478
479        let _first = acquire_lock(db_path, "faces").unwrap();
480        let second = acquire_lock(db_path, "faces");
481        assert!(
482            second.is_err(),
483            "a second concurrent lock on the same command must be refused"
484        );
485    }
486
487    #[test]
488    fn acquire_lock_allows_different_commands_concurrently() {
489        isolated_home();
490        let db_file = tempfile::NamedTempFile::new().unwrap();
491        let db_path = db_file.path();
492
493        let _faces_lock = acquire_lock(db_path, "faces").unwrap();
494        let embed_lock = acquire_lock(db_path, "embed");
495        assert!(
496            embed_lock.is_ok(),
497            "different commands must not contend for the same lock"
498        );
499    }
500
501    #[test]
502    fn acquire_lock_is_available_again_after_release() {
503        isolated_home();
504        let db_file = tempfile::NamedTempFile::new().unwrap();
505        let db_path = db_file.path();
506
507        {
508            let _lock = acquire_lock(db_path, "scan").unwrap();
509        } // dropped here, releasing the flock
510
511        let second = acquire_lock(db_path, "scan");
512        assert!(
513            second.is_ok(),
514            "lock must be available again once the guard is dropped"
515        );
516    }
517
518    #[test]
519    fn track_records_success_and_returns_the_value() {
520        isolated_home();
521        let conn = test_db();
522        let db_file = tempfile::NamedTempFile::new().unwrap();
523
524        let result = track(&conn, db_file.path(), "embed", || Ok(42)).unwrap();
525        assert_eq!(result, 42);
526
527        let status: String = conn
528            .query_row(
529                "SELECT status FROM pipeline_runs WHERE command = 'embed'",
530                [],
531                |r| r.get(0),
532            )
533            .unwrap();
534        assert_eq!(status, "success");
535    }
536
537    #[test]
538    fn track_records_failure_with_the_error_message() {
539        isolated_home();
540        let conn = test_db();
541        let db_file = tempfile::NamedTempFile::new().unwrap();
542
543        let result: Result<()> = track(&conn, db_file.path(), "classify", || {
544            Err(anyhow::anyhow!("something broke"))
545        });
546        assert!(result.is_err());
547
548        let (status, summary): (String, Option<String>) = conn
549            .query_row(
550                "SELECT status, summary FROM pipeline_runs WHERE command = 'classify'",
551                [],
552                |r| Ok((r.get(0)?, r.get(1)?)),
553            )
554            .unwrap();
555        assert_eq!(status, "failed");
556        assert_eq!(summary.as_deref(), Some("something broke"));
557    }
558
559    #[test]
560    fn track_refuses_when_already_locked() {
561        isolated_home();
562        let conn = test_db();
563        let db_file = tempfile::NamedTempFile::new().unwrap();
564
565        let _held = acquire_lock(db_file.path(), "scan").unwrap();
566        let result: Result<()> = track(&conn, db_file.path(), "scan", || Ok(()));
567        assert!(
568            result.is_err(),
569            "track must refuse to run while the lock is already held"
570        );
571
572        let count: i64 = conn
573            .query_row(
574                "SELECT COUNT(*) FROM pipeline_runs WHERE command = 'scan'",
575                [],
576                |r| r.get(0),
577            )
578            .unwrap();
579        assert_eq!(count, 0);
580    }
581
582    #[test]
583    fn read_all_reports_none_for_a_never_run_command() {
584        isolated_home();
585        let conn = test_db();
586        let db_file = tempfile::NamedTempFile::new().unwrap();
587
588        let statuses = read_all(&conn, db_file.path()).unwrap();
589        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
590        assert_eq!(embed.last_run_at, None);
591        assert_eq!(embed.status, None);
592        assert!(!embed.currently_running);
593    }
594
595    #[test]
596    fn read_all_reports_success_after_a_completed_run() {
597        isolated_home();
598        let conn = test_db();
599        let db_file = tempfile::NamedTempFile::new().unwrap();
600
601        track(&conn, db_file.path(), "embed", || Ok(())).unwrap();
602
603        let statuses = read_all(&conn, db_file.path()).unwrap();
604        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
605        assert_eq!(embed.status.as_deref(), Some("success"));
606        assert!(embed.last_run_at.is_some());
607        assert!(!embed.currently_running);
608    }
609
610    #[test]
611    fn read_all_reports_currently_running_while_locked() {
612        isolated_home();
613        let conn = test_db();
614        let db_file = tempfile::NamedTempFile::new().unwrap();
615
616        start_run(&conn, "faces").unwrap();
617        let _held = acquire_lock(db_file.path(), "faces").unwrap();
618
619        let statuses = read_all(&conn, db_file.path()).unwrap();
620        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
621        assert_eq!(faces.status.as_deref(), Some("running"));
622        assert!(faces.currently_running);
623    }
624
625    #[test]
626    fn read_all_reports_crashed_when_running_but_not_locked() {
627        isolated_home();
628        let conn = test_db();
629        let db_file = tempfile::NamedTempFile::new().unwrap();
630
631        start_run(&conn, "faces").unwrap();
632
633        let statuses = read_all(&conn, db_file.path()).unwrap();
634        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
635        assert_eq!(faces.status.as_deref(), Some("crashed"));
636        assert!(!faces.currently_running);
637
638        let stored_status: String = conn
639            .query_row(
640                "SELECT status FROM pipeline_runs WHERE command = 'faces'",
641                [],
642                |r| r.get(0),
643            )
644            .unwrap();
645        assert_eq!(
646            stored_status, "running",
647            "read_all must not write back the crashed label"
648        );
649    }
650
651    #[test]
652    fn install_sigint_handler_does_not_error_when_called_once() {
653        let db_file = tempfile::NamedTempFile::new().unwrap();
654        // Only one handler can be installed per process for the life of the
655        // test binary; this just confirms the call itself succeeds.
656        // (ctrlc::set_handler errors if called twice in the same process,
657        // so this is deliberately the only test that calls it in this suite.)
658        let result = install_sigint_handler(db_file.path(), "scan");
659        assert!(result.is_ok());
660    }
661}