1use 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
16pub const LOG_CAP_BYTES: u64 = 5 * 1024 * 1024;
18pub const KEEP_NEWEST: usize = 20;
20pub const RETAIN_MAX_AGE_MS: i64 = 14 * 24 * 60 * 60 * 1000;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum LogScope {
25 Global,
27 Scoped,
29}
30
31pub 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
47pub 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 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
136pub 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
171fn 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
202pub 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}