Skip to main content

bee_tui/
bee_log_writer.rs

1//! Size-bounded rotating writer for the supervised Bee process's
2//! captured stdout + stderr.
3//!
4//! The supervisor used to redirect Bee's log streams straight to a
5//! `$TMPDIR/bee-tui-spawned-{ts}.log` file that grew without bound —
6//! a long-running node could fill `$TMPDIR` overnight. This writer
7//! caps the active file and rolls older content to numbered siblings
8//! (`<base>.1`, `<base>.2`, …, `<base>.{keep_files}`); everything
9//! beyond `keep_files` is unlinked.
10//!
11//! ## Why line-based, not byte-based
12//!
13//! Bee's logfmt entries are parsed one line at a time by
14//! [`crate::bee_log::parse_line`]. Splitting an entry across the
15//! rotation boundary would silently drop it (the old file's tail
16//! would be a half-line, the new file's head another). We accept
17//! a small byte over-shoot past `rotate_size_bytes` instead.
18//!
19//! ## Atomicity
20//!
21//! Rotation uses `std::fs::rename`, which is atomic on the same
22//! filesystem (`$TMPDIR` and the rotation siblings always share a
23//! filesystem). Concurrent writers aren't supported — the supervisor
24//! spawns exactly one writer task per Bee child, so contention is
25//! a non-concern here.
26
27use std::fs::{File, OpenOptions};
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30
31/// Append-with-rollover writer. Owns the active file fd; rotation
32/// closes it, renames the path, and opens a fresh one.
33pub struct BeeLogWriter {
34    base_path: PathBuf,
35    rotate_size_bytes: u64,
36    keep_files: u32,
37    current: File,
38    current_size: u64,
39}
40
41impl BeeLogWriter {
42    /// Open `base_path` for append, creating it if needed. If the
43    /// file already exists (cockpit restart while Bee kept running)
44    /// the existing size is taken into account — the next write past
45    /// the cap will rotate it as expected.
46    pub fn open(base_path: PathBuf, rotate_size_mb: u64, keep_files: u32) -> io::Result<Self> {
47        let current = OpenOptions::new()
48            .create(true)
49            .append(true)
50            .open(&base_path)?;
51        let current_size = current.metadata()?.len();
52        Ok(Self {
53            base_path,
54            rotate_size_bytes: rotate_size_mb.saturating_mul(1024 * 1024).max(1),
55            keep_files: keep_files.max(1),
56            current,
57            current_size,
58        })
59    }
60
61    pub fn path(&self) -> &Path {
62        &self.base_path
63    }
64
65    /// Append a single line (without trailing newline) to the active
66    /// file, rotating first if it would push the file past the cap.
67    /// The newline is added by the writer so callers don't need to
68    /// remember it.
69    pub fn write_line(&mut self, line: &[u8]) -> io::Result<()> {
70        let needed = line.len() as u64 + 1;
71        // Rotate when the next line would push us over the cap, but
72        // never on an empty active file — otherwise an oversized
73        // first line would loop forever (rotate → empty → still
74        // doesn't fit → rotate). Accept a small overshoot in that
75        // pathological case.
76        if self.current_size > 0 && self.current_size + needed > self.rotate_size_bytes {
77            self.rotate()?;
78        }
79        self.current.write_all(line)?;
80        self.current.write_all(b"\n")?;
81        self.current_size += needed;
82        Ok(())
83    }
84
85    /// Rotate `<base>` → `<base>.1`, `<base>.1` → `<base>.2`, …,
86    /// dropping the oldest. Re-opens a fresh active file at
87    /// `<base>` afterwards. Public for tests; production callers
88    /// should rely on `write_line` triggering this automatically.
89    pub fn rotate(&mut self) -> io::Result<()> {
90        self.current.flush()?;
91        // Discard the slot beyond keep_files (`<base>.{keep_files+1}`
92        // would never be re-read). On Linux this is also a no-op when
93        // the file doesn't exist.
94        let oldest = path_with_suffix(&self.base_path, self.keep_files);
95        let _ = std::fs::remove_file(&oldest);
96        // Cascade .{N-1} → .{N}, …, .1 → .2.
97        for i in (1..self.keep_files).rev() {
98            let from = path_with_suffix(&self.base_path, i);
99            let to = path_with_suffix(&self.base_path, i + 1);
100            if from.exists() {
101                std::fs::rename(&from, &to)?;
102            }
103        }
104        // Active → .1.
105        if self.base_path.exists() {
106            let first = path_with_suffix(&self.base_path, 1);
107            std::fs::rename(&self.base_path, &first)?;
108        }
109        // Open a fresh, truncated active file. `truncate(true)` is
110        // belt-and-braces — the rename above already removed the
111        // path's old content from the directory.
112        self.current = OpenOptions::new()
113            .create(true)
114            .truncate(true)
115            .write(true)
116            .open(&self.base_path)?;
117        self.current_size = 0;
118        Ok(())
119    }
120}
121
122/// Build `<base>.{n}` without losing the base extension. We can't
123/// use `set_extension` because it would replace `.log` rather than
124/// appending `.1` after it.
125fn path_with_suffix(base: &Path, n: u32) -> PathBuf {
126    let mut s = base.as_os_str().to_owned();
127    s.push(format!(".{n}"));
128    PathBuf::from(s)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn unique_path() -> PathBuf {
136        std::env::temp_dir().join(format!(
137            "bee-log-writer-test-{}.log",
138            std::time::SystemTime::now()
139                .duration_since(std::time::UNIX_EPOCH)
140                .unwrap()
141                .as_nanos()
142        ))
143    }
144
145    fn cleanup(base: &Path, keep_files: u32) {
146        let _ = std::fs::remove_file(base);
147        for i in 1..=keep_files + 1 {
148            let _ = std::fs::remove_file(path_with_suffix(base, i));
149        }
150    }
151
152    #[test]
153    fn writes_lines_with_newlines_appended() {
154        let path = unique_path();
155        let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
156        w.write_line(b"hello").unwrap();
157        w.write_line(b"world").unwrap();
158        let contents = std::fs::read_to_string(&path).unwrap();
159        assert_eq!(contents, "hello\nworld\n");
160        cleanup(&path, 2);
161    }
162
163    #[test]
164    fn rotates_when_size_cap_exceeded() {
165        // Cap = 1 MiB. Write 12 lines of ~100 KiB each so the second
166        // batch forces a rotation. Verify .1 has the early lines and
167        // active has the late ones.
168        let path = unique_path();
169        let mut w = BeeLogWriter::open(path.clone(), 1, 3).unwrap();
170        let chunk: Vec<u8> = vec![b'x'; 100 * 1024]; // 100 KiB
171        for _ in 0..15 {
172            w.write_line(&chunk).unwrap();
173        }
174        // Active file should be smaller than cap (rotation happened
175        // mid-loop). .1 must exist with the rolled-over content.
176        let active_len = std::fs::metadata(&path).unwrap().len();
177        assert!(
178            active_len < 1024 * 1024,
179            "active file should be under cap, got {active_len}"
180        );
181        let rotated_1 = path_with_suffix(&path, 1);
182        assert!(rotated_1.exists(), ".1 rotation file must exist");
183        cleanup(&path, 3);
184    }
185
186    #[test]
187    fn rotation_drops_oldest_beyond_keep_count() {
188        // keep_files=2 → only .1 and .2 ever exist; .3 should never
189        // appear after multiple rotations.
190        let path = unique_path();
191        let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
192        let chunk: Vec<u8> = vec![b'y'; 600 * 1024]; // 600 KiB
193        for _ in 0..10 {
194            w.write_line(&chunk).unwrap();
195        }
196        assert!(path_with_suffix(&path, 1).exists(), ".1 must exist");
197        assert!(path_with_suffix(&path, 2).exists(), ".2 must exist");
198        assert!(
199            !path_with_suffix(&path, 3).exists(),
200            ".3 must NOT exist (keep_files=2)"
201        );
202        cleanup(&path, 3);
203    }
204
205    #[test]
206    fn reopen_picks_up_existing_size() {
207        // Operator restarts bee-tui while Bee keeps running — when
208        // the writer re-opens an existing file it must factor the
209        // existing size into the rotation cap so it doesn't suddenly
210        // overshoot by another full cap's worth.
211        let path = unique_path();
212        std::fs::write(&path, "x".repeat(900_000)).unwrap();
213        let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
214        // Just under the 1 MiB cap with this initial size — writing
215        // 200 KiB should rotate.
216        w.write_line(&vec![b'z'; 200 * 1024]).unwrap();
217        assert!(
218            path_with_suffix(&path, 1).exists(),
219            "rotation should fire on the first write past the cap"
220        );
221        cleanup(&path, 2);
222    }
223
224    #[test]
225    fn oversized_line_rotates_after_writing() {
226        // A line bigger than the cap can't be split — write it as-is.
227        // First call to `write_line` lands on an empty file (no rotate
228        // yet); next call sees an over-cap file and rotates.
229        let path = unique_path();
230        let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
231        let huge: Vec<u8> = vec![b'h'; 2 * 1024 * 1024]; // 2 MiB
232        w.write_line(&huge).unwrap();
233        // Active file is over cap but no rotation yet (empty rule).
234        assert!(std::fs::metadata(&path).unwrap().len() > 1024 * 1024);
235        // Next small line triggers rotate.
236        w.write_line(b"normal").unwrap();
237        assert!(path_with_suffix(&path, 1).exists());
238        cleanup(&path, 2);
239    }
240}