Skip to main content

opencode_ctl/
logging.rs

1//! File logging (spec "Logging"): `<home>/logs/serve-<pid>.log` and `pair-<pair-id>.log`, level
2//! `info` (`debug` on request), a 5 MiB cap that rolls once to `.1`, and retention. Nothing here
3//! ever writes to stdout: stdout is the MCP channel.
4
5use std::collections::BTreeMap;
6use std::fs::{File, OpenOptions};
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use tracing::subscriber::DefaultGuard;
11use tracing_appender::non_blocking::WorkerGuard;
12
13use crate::liveness::ProcessProbe;
14use crate::pairing::valid_pair_id;
15
16/// The size at which a log file rolls to `.1`.
17pub const LOG_CAP_BYTES: u64 = 5 * 1024 * 1024;
18/// Retention: files beyond the newest 20 of their kind ...
19pub const KEEP_NEWEST: usize = 20;
20/// ... AND older than this.
21pub const RETAIN_MAX_AGE_MS: i64 = 14 * 24 * 60 * 60 * 1000;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum LogScope {
25    /// The process-wide subscriber, installed once per process (`serve`, the launcher subcommands).
26    Global,
27    /// A thread-local default subscriber, uninstalled when the handle drops (tests).
28    Scoped,
29}
30
31/// Keeps the log alive: dropping it flushes the worker and (Scoped) uninstalls the subscriber.
32pub struct LogHandle {
33    worker: WorkerGuard,
34    default: Option<DefaultGuard>,
35}
36
37impl LogHandle {
38    pub fn is_scoped(&self) -> bool {
39        self.default.is_some()
40    }
41
42    pub fn worker(&self) -> &WorkerGuard {
43        &self.worker
44    }
45}
46
47/// An append-only file that moves itself to `<name>.1` (replacing any earlier one) before a write
48/// that would take it past the cap. Whole writes stay whole; there is never a `.2`.
49pub struct CappedWriter {
50    path: PathBuf,
51    cap: u64,
52    file: Option<File>,
53    size: u64,
54}
55
56fn open_append(path: &Path) -> io::Result<File> {
57    let mut o = OpenOptions::new();
58    o.create(true).append(true);
59    #[cfg(unix)]
60    {
61        use std::os::unix::fs::OpenOptionsExt;
62        o.mode(0o600);
63    }
64    o.open(path)
65}
66
67fn create_logs_dir(dir: &Path) -> io::Result<()> {
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::DirBuilderExt;
71        std::fs::DirBuilder::new()
72            .recursive(true)
73            .mode(0o700)
74            .create(dir)
75    }
76    #[cfg(not(unix))]
77    std::fs::create_dir_all(dir)
78}
79
80fn rolled_name(path: &Path) -> PathBuf {
81    let mut name = path.file_name().unwrap_or_default().to_os_string();
82    name.push(".1");
83    path.with_file_name(name)
84}
85
86impl CappedWriter {
87    pub fn open(path: &Path, cap: u64) -> io::Result<Self> {
88        if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
89            create_logs_dir(dir)?;
90        }
91        let file = open_append(path)?;
92        let size = file.metadata()?.len();
93        Ok(Self {
94            path: path.to_path_buf(),
95            cap,
96            file: Some(file),
97            size,
98        })
99    }
100
101    fn roll(&mut self) -> io::Result<()> {
102        if let Some(mut f) = self.file.take() {
103            let _ = f.flush();
104        }
105        // A failed rename keeps appending to the same file rather than losing lines.
106        let _ = std::fs::rename(&self.path, rolled_name(&self.path));
107        let file = open_append(&self.path)?;
108        self.size = file.metadata()?.len();
109        self.file = Some(file);
110        Ok(())
111    }
112}
113
114impl Write for CappedWriter {
115    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
116        if self.size > 0 && self.size + buf.len() as u64 > self.cap {
117            self.roll()?;
118        }
119        let f = self
120            .file
121            .as_mut()
122            .ok_or_else(|| io::Error::other("log file closed"))?;
123        let n = f.write(buf)?;
124        self.size += n as u64;
125        Ok(n)
126    }
127
128    fn flush(&mut self) -> io::Result<()> {
129        match self.file.as_mut() {
130            Some(f) => f.flush(),
131            None => Ok(()),
132        }
133    }
134}
135
136/// Open `path` as a non-blocking, ANSI-free file log. `Global` installs the process subscriber
137/// with `try_init` (a second call returns a handle and leaves the first subscriber in place);
138/// `Scoped` installs a thread-local default whose guard lives in the handle.
139pub fn init_file_logging(
140    path: &Path,
141    debug: bool,
142    scope: LogScope,
143) -> Result<LogHandle, io::Error> {
144    let writer = CappedWriter::open(path, LOG_CAP_BYTES)?;
145    let (non_blocking, worker) = tracing_appender::non_blocking(writer);
146    let level = if debug {
147        tracing::Level::DEBUG
148    } else {
149        tracing::Level::INFO
150    };
151    let builder = tracing_subscriber::fmt()
152        .with_writer(non_blocking)
153        .with_ansi(false)
154        .with_max_level(level);
155    let default = match scope {
156        LogScope::Global => {
157            let _ = builder.try_init();
158            None
159        }
160        LogScope::Scoped => Some(tracing::subscriber::set_default(builder.finish())),
161    };
162    Ok(LogHandle { worker, default })
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
166enum Kind {
167    Serve,
168    Pair,
169}
170
171/// `serve-<pid>.log[.1]` / `pair-<12 hex>.log[.1]` -> (kind, id, is_rolled).
172fn classify(name: &str) -> Option<(Kind, &str, bool)> {
173    let (stem, rolled) = match name.strip_suffix(".log.1") {
174        Some(s) => (s, true),
175        None => (name.strip_suffix(".log")?, false),
176    };
177    if let Some(pid) = stem.strip_prefix("serve-") {
178        if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
179            return Some((Kind::Serve, pid, rolled));
180        }
181    }
182    if let Some(id) = stem.strip_prefix("pair-") {
183        if valid_pair_id(id) {
184            return Some((Kind::Pair, id, rolled));
185        }
186    }
187    None
188}
189
190fn mtime_ms(path: &Path) -> Option<i64> {
191    let t = std::fs::metadata(path).ok()?.modified().ok()?;
192    let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
193    Some(d.as_millis() as i64)
194}
195
196struct Group {
197    id: String,
198    files: Vec<PathBuf>,
199    newest_ms: i64,
200}
201
202/// Log retention (spec Logging). A base log and its `.1` form one group of their kind (`serve-*`
203/// and `pair-*` are counted separately); a group is deleted only when it is beyond the newest 20
204/// of its kind AND its newest file is older than 14 days AND its owner is gone: for `serve-<pid>`
205/// the pid is dead, for `pair-<id>` `pair_is_dead(id)`. Decisions key on the FILE NAME's id.
206/// Returns the files removed.
207pub fn retain(
208    logs_dir: &Path,
209    now_ms: i64,
210    probe: &dyn ProcessProbe,
211    pair_is_dead: &dyn Fn(&str) -> bool,
212) -> Vec<PathBuf> {
213    let Ok(entries) = std::fs::read_dir(logs_dir) else {
214        return Vec::new();
215    };
216    let mut groups: BTreeMap<(Kind, String), Group> = BTreeMap::new();
217    for entry in entries.flatten() {
218        let name = entry.file_name();
219        let Some((kind, id, _)) = name.to_str().and_then(classify) else {
220            continue;
221        };
222        let path = entry.path();
223        if !path.is_file() {
224            continue;
225        }
226        let m = mtime_ms(&path).unwrap_or(now_ms);
227        let g = groups
228            .entry((kind, id.to_string()))
229            .or_insert_with(|| Group {
230                id: id.to_string(),
231                files: Vec::new(),
232                newest_ms: i64::MIN,
233            });
234        g.files.push(path);
235        g.newest_ms = g.newest_ms.max(m);
236    }
237    let mut removed = Vec::new();
238    for kind in [Kind::Serve, Kind::Pair] {
239        let mut of_kind: Vec<&Group> = groups
240            .iter()
241            .filter(|((k, _), _)| *k == kind)
242            .map(|(_, g)| g)
243            .collect();
244        of_kind.sort_by(|a, b| b.newest_ms.cmp(&a.newest_ms).then_with(|| b.id.cmp(&a.id)));
245        for g in of_kind.into_iter().skip(KEEP_NEWEST) {
246            if now_ms - g.newest_ms <= RETAIN_MAX_AGE_MS {
247                continue;
248            }
249            let gone = match kind {
250                Kind::Serve => g.id.parse::<u32>().is_ok_and(|pid| !probe.is_alive(pid)),
251                Kind::Pair => pair_is_dead(&g.id),
252            };
253            if !gone {
254                continue;
255            }
256            for f in &g.files {
257                if std::fs::remove_file(f).is_ok() {
258                    removed.push(f.clone());
259                }
260            }
261        }
262    }
263    removed
264}