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}