Skip to main content

agentmux_launcher/
logging.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4/// Rotation cap for `agentmux-launcher.log` (SPEC_WIN10_PAGEFILE_OOM_CRASH_
5/// 2026_06_29 P1). This file mirrors the srv subprocess's entire stderr
6/// stream verbatim, forever, with no rotation of its own — left unbounded
7/// it grew to 406 MB across 69 days on a real machine, directly tightening
8/// the page-file ceiling the pagefile-OOM spec is about. 50 MB per file ×
9/// 3 rotated files + the live file ≈ 200 MB worst case, versus unbounded.
10const MAX_LOG_BYTES: u64 = 50 * 1024 * 1024;
11const MAX_ROTATED_FILES: u32 = 3;
12
13/// Append a timestamped line to ~/.agentmux/logs/agentmux-launcher.log.
14/// Best-effort — silently no-ops if the log dir doesn't exist yet.
15pub(crate) fn log(msg: &str) {
16    let log_dir = dirs_fallback_home().join(".agentmux").join("logs");
17    let _ = std::fs::create_dir_all(&log_dir);
18    let path = log_dir.join("agentmux-launcher.log");
19    rotate_if_oversized(&path);
20    if let Ok(mut f) = std::fs::OpenOptions::new()
21        .create(true)
22        .append(true)
23        .open(&path)
24    {
25        use std::io::Write;
26        let secs = agentmux_common::time::now_secs_u64();
27        let _ = writeln!(f, "[{}] v{} {}", secs, env!("CARGO_PKG_VERSION"), msg);
28    }
29}
30
31fn rotated_path(path: &std::path::Path, n: u32) -> std::path::PathBuf {
32    path.with_extension(format!("log.{n}"))
33}
34
35/// Size-based rotation: `agentmux-launcher.log` -> `.log.1` -> `.log.2` ->
36/// `.log.3` (oldest dropped), run once per `log()` call before appending.
37/// A `metadata()` stat per call is cheap relative to the disk write that
38/// follows; simplicity here matters more than shaving a syscall. Best-effort
39/// like `log()` itself — any failure (permissions, concurrent access) just
40/// leaves the current file growing past the cap rather than losing log data
41/// or panicking.
42fn rotate_if_oversized(path: &std::path::Path) {
43    let Ok(meta) = std::fs::metadata(path) else {
44        return;
45    };
46    if meta.len() < MAX_LOG_BYTES {
47        return;
48    }
49    let _ = std::fs::remove_file(rotated_path(path, MAX_ROTATED_FILES));
50    for i in (1..MAX_ROTATED_FILES).rev() {
51        let _ = std::fs::rename(rotated_path(path, i), rotated_path(path, i + 1));
52    }
53    let _ = std::fs::rename(path, rotated_path(path, 1));
54}
55
56/// Home dir without depending on `dirs` for THIS specific lookup.
57/// Kept to avoid a dirs dep cycle from log() — log() is called from
58/// data_dir::resolve_paths via failure paths, and we want it to work
59/// even if `dirs` itself is mid-failure.
60pub(crate) fn dirs_fallback_home() -> std::path::PathBuf {
61    std::env::var("USERPROFILE")
62        .or_else(|_| std::env::var("HOME"))
63        .map(std::path::PathBuf::from)
64        .unwrap_or_else(|_| std::path::PathBuf::from("."))
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use tempfile::tempdir;
71
72    #[test]
73    fn rotate_if_oversized_is_a_noop_below_the_cap() {
74        let tmp = tempdir().unwrap();
75        let path = tmp.path().join("agentmux-launcher.log");
76        std::fs::write(&path, vec![b'x'; 1024]).unwrap();
77
78        rotate_if_oversized(&path);
79
80        assert!(path.exists());
81        assert_eq!(std::fs::metadata(&path).unwrap().len(), 1024);
82        assert!(!rotated_path(&path, 1).exists());
83    }
84
85    #[test]
86    fn rotate_if_oversized_rotates_the_live_file_to_dot_1() {
87        let tmp = tempdir().unwrap();
88        let path = tmp.path().join("agentmux-launcher.log");
89        std::fs::write(&path, vec![b'x'; MAX_LOG_BYTES as usize]).unwrap();
90
91        rotate_if_oversized(&path);
92
93        assert!(!path.exists(), "oversized live file should be rotated away");
94        assert!(rotated_path(&path, 1).exists());
95        assert_eq!(
96            std::fs::metadata(rotated_path(&path, 1)).unwrap().len(),
97            MAX_LOG_BYTES
98        );
99    }
100
101    #[test]
102    fn rotate_if_oversized_shifts_the_whole_chain_and_drops_the_oldest() {
103        let tmp = tempdir().unwrap();
104        let path = tmp.path().join("agentmux-launcher.log");
105        std::fs::write(&path, vec![b'x'; MAX_LOG_BYTES as usize]).unwrap();
106        std::fs::write(rotated_path(&path, 1), b"gen1").unwrap();
107        std::fs::write(rotated_path(&path, 2), b"gen2").unwrap();
108        std::fs::write(rotated_path(&path, 3), b"gen3-oldest-should-be-dropped").unwrap();
109
110        rotate_if_oversized(&path);
111
112        assert!(!path.exists());
113        assert_eq!(std::fs::read(rotated_path(&path, 1)).unwrap(), vec![b'x'; MAX_LOG_BYTES as usize]);
114        assert_eq!(std::fs::read(rotated_path(&path, 2)).unwrap(), b"gen1");
115        assert_eq!(std::fs::read(rotated_path(&path, 3)).unwrap(), b"gen2");
116    }
117}