Skip to main content

mati_core/mcp/
daemon_log.rs

1//! Daemon log — where the daemon's own `tracing` output can actually be read.
2//!
3//! The daemon is a detached background process, so its stderr goes wherever the
4//! spawner pointed it: `~/.mati/daemon_start.log` when `ensure_daemon` started
5//! it, and `/dev/null` otherwise. That file is shared by every store on the
6//! machine, unbounded, and — because the process-wide filter defaults to `warn`
7//! — carries no INFO. The lines that answer "did this actually run?"
8//! (`staleness analysis complete scanned=… updated=…`, `gotcha candidates
9//! auto-promoted`) were therefore unreadable in normal operation.
10//!
11//! [`install`] redirects the subscriber to `<root>/daemon.log`: per store,
12//! next to `lifecycle.log`, and size-bounded. Until it is called, and in every
13//! non-daemon process, writes go to stderr exactly as before.
14//!
15//! Local file only. No network, and no store records — the durability split in
16//! `store::durability` does not apply.
17
18use std::fs::{File, OpenOptions};
19use std::io::{self, Write};
20use std::path::{Path, PathBuf};
21use std::sync::{Mutex, OnceLock};
22
23use tracing_subscriber::fmt::MakeWriter;
24
25/// Log filename under a store root.
26pub const DAEMON_LOG_FILENAME: &str = "daemon.log";
27
28/// Rotation threshold. One previous generation is kept, so a store's daemon
29/// logs cost at most twice this on disk however long the daemon runs.
30const MAX_BYTES: u64 = 1 << 20;
31
32/// Tracing directives used when the process is the daemon.
33///
34/// Scoped to mati's own targets: a bare `info` would also admit surrealkv,
35/// tantivy, and tokio internals, which is volume without answers.
36pub const DAEMON_DIRECTIVES: &str = "warn,mati=info,mati_core=info";
37
38struct Sink {
39    path: PathBuf,
40    file: File,
41    written: u64,
42}
43
44impl Sink {
45    fn write_line(&mut self, buf: &[u8]) -> io::Result<usize> {
46        if self.written + buf.len() as u64 > MAX_BYTES {
47            self.rotate()?;
48        }
49        let n = self.file.write(buf)?;
50        self.written += n as u64;
51        Ok(n)
52    }
53
54    fn rotate(&mut self) -> io::Result<()> {
55        let previous = self.path.with_extension("log.1");
56        std::fs::rename(&self.path, &previous)?;
57        self.file = open_append(&self.path)?;
58        self.written = 0;
59        Ok(())
60    }
61}
62
63static SINK: OnceLock<Mutex<Sink>> = OnceLock::new();
64
65fn open_append(path: &Path) -> io::Result<File> {
66    OpenOptions::new().create(true).append(true).open(path)
67}
68
69/// Path of the daemon log for a store root.
70pub fn log_path(root: &Path) -> PathBuf {
71    root.join(DAEMON_LOG_FILENAME)
72}
73
74/// Point subsequent tracing output at `<root>/daemon.log`.
75///
76/// Returns `false` if the file cannot be opened (read-only home, missing
77/// runtime dir) or if a sink is already installed — in both cases output keeps
78/// going to stderr rather than being lost. Call once, from the daemon process.
79pub fn install(root: &Path) -> bool {
80    let path = log_path(root);
81    let Ok(file) = open_append(&path) else {
82        return false;
83    };
84    let written = file.metadata().map(|m| m.len()).unwrap_or(0);
85    SINK.set(Mutex::new(Sink {
86        path,
87        file,
88        written,
89    }))
90    .is_ok()
91}
92
93/// Writer factory for `tracing_subscriber::fmt().with_writer(..)`.
94///
95/// Resolves per event, so the daemon's pre-[`install`] startup lines still
96/// reach stderr and everything after lands in the file.
97#[derive(Clone, Copy, Default)]
98pub struct DaemonLogWriter;
99
100/// Where one event's bytes go.
101pub enum Target {
102    Rotating,
103    Stderr(io::Stderr),
104}
105
106impl Write for Target {
107    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
108        match self {
109            // A poisoned lock must not take the daemon down with it: drop the
110            // line and report it written. Logging is diagnostics, not data.
111            Self::Rotating => match SINK.get().and_then(|s| s.lock().ok()) {
112                Some(mut sink) => sink.write_line(buf),
113                None => Ok(buf.len()),
114            },
115            Self::Stderr(w) => w.write(buf),
116        }
117    }
118
119    fn flush(&mut self) -> io::Result<()> {
120        match self {
121            // `append` writes are unbuffered in userspace; nothing to push.
122            Self::Rotating => Ok(()),
123            Self::Stderr(w) => w.flush(),
124        }
125    }
126}
127
128impl<'a> MakeWriter<'a> for DaemonLogWriter {
129    type Writer = Target;
130
131    fn make_writer(&'a self) -> Self::Writer {
132        if SINK.get().is_some() {
133            Target::Rotating
134        } else {
135            Target::Stderr(io::stderr())
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// The sink is a process-wide `OnceLock`, so rotation is exercised on a
145    /// `Sink` built directly rather than through `install`.
146    fn sink_at(path: &Path) -> Sink {
147        Sink {
148            path: path.to_path_buf(),
149            file: open_append(path).unwrap(),
150            written: 0,
151        }
152    }
153
154    #[test]
155    fn growth_is_bounded_by_two_generations() {
156        let dir = tempfile::TempDir::new().unwrap();
157        let path = dir.path().join(DAEMON_LOG_FILENAME);
158        let mut sink = sink_at(&path);
159
160        let line = vec![b'x'; 64 * 1024];
161        for _ in 0..64 {
162            sink.write_line(&line).unwrap();
163        }
164
165        let total: u64 = std::fs::read_dir(dir.path())
166            .unwrap()
167            .flatten()
168            .map(|e| e.metadata().unwrap().len())
169            .sum();
170        assert!(
171            total <= 2 * MAX_BYTES,
172            "daemon log grew past two generations: {total} bytes"
173        );
174        assert!(
175            path.with_extension("log.1").exists(),
176            "rotation must keep one previous generation"
177        );
178    }
179
180    #[test]
181    fn rotation_preserves_the_most_recent_lines() {
182        let dir = tempfile::TempDir::new().unwrap();
183        let path = dir.path().join(DAEMON_LOG_FILENAME);
184        let mut sink = sink_at(&path);
185
186        sink.write_line(&vec![b'o'; MAX_BYTES as usize]).unwrap();
187        sink.write_line(b"newest\n").unwrap();
188
189        assert_eq!(std::fs::read_to_string(&path).unwrap(), "newest\n");
190    }
191
192    /// Without an installed sink the writer must behave exactly as before —
193    /// stderr, so a daemon that fails before `install` is still diagnosable
194    /// through `daemon_start.log`.
195    #[test]
196    fn writer_falls_back_to_stderr_until_installed() {
197        if SINK.get().is_some() {
198            return;
199        }
200        assert!(matches!(DaemonLogWriter.make_writer(), Target::Stderr(_)));
201    }
202}