first pass of merging in warp (doesn't build)
This commit is contained in:
+217
-24
@@ -1,15 +1,63 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use async_channel::Sender;
|
||||
use async_fs::OpenOptions;
|
||||
use futures::AsyncWriteExt as _;
|
||||
use galaxyui::r#async::executor::{Background, BackgroundTask};
|
||||
use galaxyui_core::r#async::executor::{Background, BackgroundTask};
|
||||
|
||||
pub mod manager;
|
||||
|
||||
/// Configuration for size-based log file rotation.
|
||||
///
|
||||
/// When a [`SimpleLogger`] is created with `Some(RotationConfig)`, it tracks the
|
||||
/// number of bytes written to the active log file. After any write that brings
|
||||
/// the cumulative byte count to `max_file_size_bytes` or above, the writer
|
||||
/// closes the active file, rotates it to a `.1` suffix (shifting older `.N`
|
||||
/// files up by one and discarding the file at `.{max_rotation}` before the
|
||||
/// shift), and reopens a fresh active file.
|
||||
///
|
||||
/// The file may briefly exceed `max_file_size_bytes` by one log line — the
|
||||
/// rotation happens *after* the write that crosses the threshold so log lines
|
||||
/// are never split across files.
|
||||
///
|
||||
/// A `SimpleLogger` constructed with `rotation = None` retains the original
|
||||
/// behavior: one file per logger lifetime, unbounded growth, truncate-on-create.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RotationConfig {
|
||||
max_file_size_bytes: u64,
|
||||
max_rotation: usize,
|
||||
}
|
||||
|
||||
impl RotationConfig {
|
||||
/// Build a [`RotationConfig`].
|
||||
///
|
||||
/// Both parameters must be non-zero; passing zero for either is treated as
|
||||
/// "rotation disabled" and yields `None`. Callers that want unconditional
|
||||
/// disabling should pass `None` directly to [`SimpleLogger::new`] rather
|
||||
/// than calling this with zero — but accepting zero here keeps the
|
||||
/// `Option<RotationConfig>` API safe to thread through call sites that
|
||||
/// derive values from configuration.
|
||||
pub fn new(max_file_size_bytes: u64, max_rotation: usize) -> Option<Self> {
|
||||
if max_file_size_bytes == 0 || max_rotation == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Self {
|
||||
max_file_size_bytes,
|
||||
max_rotation,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_file_size_bytes(&self) -> u64 {
|
||||
self.max_file_size_bytes
|
||||
}
|
||||
|
||||
pub fn max_rotation(&self) -> usize {
|
||||
self.max_rotation
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for a [`SimpleLogger`].
|
||||
///
|
||||
/// When all [`SimpleLogger`] clones are dropped, this is dropped too, which closes
|
||||
@@ -43,8 +91,16 @@ pub struct SimpleLogger {
|
||||
|
||||
impl SimpleLogger {
|
||||
/// Creates a new logger that writes to the specified file path.
|
||||
/// The file is truncated on creation.
|
||||
pub(crate) fn new(log_path: PathBuf, executor: Arc<Background>) -> Self {
|
||||
///
|
||||
/// If `rotation` is `Some`, the active file is rotated whenever its written
|
||||
/// byte count reaches the configured threshold. If `None`, the file is
|
||||
/// truncated on creation and grows without bound for the logger's lifetime
|
||||
/// (the original behavior).
|
||||
pub(crate) fn new(
|
||||
log_path: PathBuf,
|
||||
executor: Arc<Background>,
|
||||
rotation: Option<RotationConfig>,
|
||||
) -> Self {
|
||||
let (log_tx, log_rx) = async_channel::unbounded::<String>();
|
||||
|
||||
if let Some(directory) = log_path.parent() {
|
||||
@@ -52,34 +108,85 @@ impl SimpleLogger {
|
||||
}
|
||||
|
||||
let logging_task = executor.spawn(async move {
|
||||
let mut log_file = match OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)
|
||||
.await
|
||||
{
|
||||
let mut log_file = match open_truncated(&log_path).await {
|
||||
Ok(log_file) => log_file,
|
||||
Err(e) => {
|
||||
log::warn!("Could not open file for logging: {:?}. {:?}", &log_path, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut written_bytes: u64 = 0;
|
||||
loop {
|
||||
match log_rx.recv().await {
|
||||
Ok(log_line) => {
|
||||
let _ = log_file
|
||||
.write_all(
|
||||
format!(
|
||||
"{} | {}\n",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
|
||||
log_line
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let formatted = format!(
|
||||
"{} | {}\n",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
|
||||
log_line
|
||||
);
|
||||
let bytes = formatted.as_bytes();
|
||||
let _ = log_file.write_all(bytes).await;
|
||||
// Flush after each line to ensure logs are visible immediately
|
||||
let _ = log_file.flush().await;
|
||||
written_bytes = written_bytes.saturating_add(bytes.len() as u64);
|
||||
|
||||
if let Some(config) = rotation {
|
||||
if written_bytes >= config.max_file_size_bytes {
|
||||
// Drop the active file handle before renaming so platforms
|
||||
// that disallow renaming an open file (notably Windows)
|
||||
// succeed, and so the subsequent reopen receives a fresh
|
||||
// inode.
|
||||
drop(log_file);
|
||||
let rotation_failed =
|
||||
match perform_rotation(&log_path, config.max_rotation).await {
|
||||
Ok(()) => false,
|
||||
Err(e) => {
|
||||
// The only path through `perform_rotation` that
|
||||
// surfaces an error is step 3's rename of the
|
||||
// active file. That rename only runs when the
|
||||
// active file exists, so on Err the original
|
||||
// content is still on disk at `log_path`. Open
|
||||
// in append mode rather than truncating so we
|
||||
// don't destroy the log data rotation was meant
|
||||
// to preserve.
|
||||
log::warn!(
|
||||
"SimpleLogger: rotation failed for {:?}: {e}; \
|
||||
preserving existing log content and continuing \
|
||||
in append mode",
|
||||
&log_path,
|
||||
);
|
||||
true
|
||||
}
|
||||
};
|
||||
let reopen = if rotation_failed {
|
||||
open_append(&log_path).await
|
||||
} else {
|
||||
open_truncated(&log_path).await
|
||||
};
|
||||
log_file = match reopen {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"SimpleLogger: failed to reopen {:?} after \
|
||||
rotation: {e}",
|
||||
&log_path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Seed the counter from the file's current size so the
|
||||
// next rotation threshold check stays meaningful even
|
||||
// after the preserve-on-failure path.
|
||||
written_bytes = if rotation_failed {
|
||||
async_fs::metadata(&log_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("SimpleLogger: channel closed: {e}");
|
||||
@@ -125,3 +232,89 @@ impl SimpleLogger {
|
||||
Arc::downgrade(&self.writer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `path` for writing with truncation, ensuring the parent directory exists.
|
||||
async fn open_truncated(path: &std::path::Path) -> std::io::Result<async_fs::File> {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open `path` for appending. Used by the rotation-failure recovery path so
|
||||
/// existing log data is preserved when `perform_rotation` could not move the
|
||||
/// active file to its rotated slot.
|
||||
async fn open_append(path: &std::path::Path) -> std::io::Result<async_fs::File> {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rotate `base_path` and its existing `.1` … `.{max_rotation}` siblings.
|
||||
///
|
||||
/// After the call:
|
||||
/// - the file previously at `.{max_rotation}` is gone
|
||||
/// - each remaining `.N` has been renamed to `.{N+1}`
|
||||
/// - the previous active file at `base_path` is now at `.1`
|
||||
/// - `base_path` itself no longer exists (the caller is expected to reopen it
|
||||
/// truncated)
|
||||
///
|
||||
/// Rename failures for intermediate `.N` files are tolerated (the file may not
|
||||
/// exist yet if fewer than `max_rotation` rotations have occurred). A failure to
|
||||
/// rename the current active file is reported back to the caller.
|
||||
pub(crate) async fn perform_rotation(
|
||||
base_path: &std::path::Path,
|
||||
max_rotation: usize,
|
||||
) -> std::io::Result<()> {
|
||||
// Step 1 — drop the file that would otherwise become `.{max_rotation + 1}`.
|
||||
// Tolerate ENOENT silently: it just means we haven't accumulated enough
|
||||
// rotations yet.
|
||||
let oldest = path_with_suffix(base_path, max_rotation);
|
||||
if let Err(e) = async_fs::remove_file(&oldest).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::debug!(
|
||||
"SimpleLogger: could not remove oldest rotation {:?}: {e}",
|
||||
oldest
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — shift every existing `.N` up by one, going from oldest to
|
||||
// youngest so we never overwrite a file we haven't moved yet.
|
||||
for n in (1..max_rotation).rev() {
|
||||
let src = path_with_suffix(base_path, n);
|
||||
let dst = path_with_suffix(base_path, n + 1);
|
||||
if let Err(e) = async_fs::rename(&src, &dst).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::debug!("SimpleLogger: could not rotate {:?} -> {:?}: {e}", src, dst,);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — promote the current active file to `.1`. This is the rename
|
||||
// that matters; surface its error so the caller can decide to keep going
|
||||
// (it will reopen truncated regardless) or report it.
|
||||
if base_path.exists() {
|
||||
async_fs::rename(base_path, path_with_suffix(base_path, 1)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the rotated-suffix path for `base_path`. e.g. `mcp/srv.log` with `n=2`
|
||||
/// becomes `mcp/srv.log.2`. Operating on the raw `OsString` rather than via
|
||||
/// `set_extension` is intentional — we append a suffix, we don't replace one,
|
||||
/// and `set_extension("log.2")` would strip a legitimate trailing `.log`.
|
||||
pub(crate) fn path_with_suffix(base: &std::path::Path, n: usize) -> PathBuf {
|
||||
let mut s = base.as_os_str().to_owned();
|
||||
s.push(format!(".{n}"));
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Tests for the size-based rotation helpers in `lib.rs`.
|
||||
//!
|
||||
//! The high-level `SimpleLogger::new` path runs in a background executor and
|
||||
//! interleaves async file I/O with channel reads; integration coverage at that
|
||||
//! layer is provided by [`crate::manager`]'s tests. The cases here exercise the
|
||||
//! pure file-shuffling helpers (`perform_rotation`, `path_with_suffix`) and the
|
||||
//! `RotationConfig` constructor, all of which are deterministic and don't
|
||||
//! require an executor — keeping the unit tests fast and avoiding the
|
||||
//! flakiness that comes with background-task synchronization.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::{path_with_suffix, perform_rotation, RotationConfig};
|
||||
|
||||
/// Unique temp directory per test, so parallel cargo nextest runs don't
|
||||
/// collide on shared state.
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"simple-logger-rotation-{name}-{}-{id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("failed to create test temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &[u8]) {
|
||||
fs::write(path, contents).expect("failed to write test fixture file");
|
||||
}
|
||||
|
||||
fn read_to_string(path: &Path) -> String {
|
||||
fs::read_to_string(path).expect("failed to read file")
|
||||
}
|
||||
|
||||
// ---------- RotationConfig ----------
|
||||
|
||||
#[test]
|
||||
fn rotation_config_zero_max_size_disables() {
|
||||
assert!(RotationConfig::new(0, 5).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_config_zero_max_rotation_disables() {
|
||||
assert!(RotationConfig::new(10 * 1024 * 1024, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_config_both_zero_disables() {
|
||||
assert!(RotationConfig::new(0, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_config_positive_values_construct() {
|
||||
let c = RotationConfig::new(1024, 5).expect("positive values should construct");
|
||||
assert_eq!(c.max_file_size_bytes(), 1024);
|
||||
assert_eq!(c.max_rotation(), 5);
|
||||
}
|
||||
|
||||
// ---------- path_with_suffix ----------
|
||||
|
||||
#[test]
|
||||
fn path_with_suffix_appends_dot_n_without_replacing_extension() {
|
||||
let base = Path::new("/tmp/foo/bar.log");
|
||||
let p = path_with_suffix(base, 3);
|
||||
assert_eq!(p, PathBuf::from("/tmp/foo/bar.log.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_with_suffix_preserves_compound_extensions() {
|
||||
let base = Path::new("/tmp/foo/server.stderr.log");
|
||||
let p = path_with_suffix(base, 1);
|
||||
// Crucially this must be `server.stderr.log.1` — not `server.stderr.1`
|
||||
// (which is what `set_extension` would produce).
|
||||
assert_eq!(p, PathBuf::from("/tmp/foo/server.stderr.log.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_with_suffix_handles_no_extension() {
|
||||
let base = Path::new("/tmp/foo/logfile");
|
||||
let p = path_with_suffix(base, 7);
|
||||
assert_eq!(p, PathBuf::from("/tmp/foo/logfile.7"));
|
||||
}
|
||||
|
||||
// ---------- perform_rotation: file-level behavior ----------
|
||||
|
||||
/// The base case: there's only an active file at `base_path`. After rotation,
|
||||
/// the active file should be gone (renamed to `.1`) and no other files should
|
||||
/// exist.
|
||||
#[tokio::test]
|
||||
async fn rotate_promotes_active_file_to_dot_one_when_no_prior_rotations() {
|
||||
let dir = temp_dir("first-rotation");
|
||||
let base = dir.join("server.log");
|
||||
write_file(&base, b"hello\n");
|
||||
|
||||
perform_rotation(&base, 5)
|
||||
.await
|
||||
.expect("rotation should succeed");
|
||||
|
||||
assert!(!base.exists(), "active file must be gone after rotation");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "hello\n");
|
||||
assert!(!path_with_suffix(&base, 2).exists());
|
||||
}
|
||||
|
||||
/// With one prior rotated file, the active becomes `.1` and the existing `.1`
|
||||
/// shifts to `.2`. Verifies the iteration goes from oldest to youngest (so we
|
||||
/// don't clobber an unmoved file).
|
||||
#[tokio::test]
|
||||
async fn rotate_shifts_prior_rotations_up_by_one() {
|
||||
let dir = temp_dir("shift");
|
||||
let base = dir.join("srv.log");
|
||||
write_file(&base, b"current\n");
|
||||
write_file(&path_with_suffix(&base, 1), b"previous\n");
|
||||
|
||||
perform_rotation(&base, 5)
|
||||
.await
|
||||
.expect("rotation should succeed");
|
||||
|
||||
assert!(!base.exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "previous\n");
|
||||
assert!(!path_with_suffix(&base, 3).exists());
|
||||
}
|
||||
|
||||
/// At the rotation cap, the oldest rotated file (`.max_rotation`) is deleted
|
||||
/// before the shift, so no file ages past `.max_rotation`. This is the
|
||||
/// "automatic cleanup" property the bug-fix is meant to provide.
|
||||
#[tokio::test]
|
||||
async fn rotate_discards_oldest_file_when_at_cap() {
|
||||
let dir = temp_dir("cap");
|
||||
let base = dir.join("server.log");
|
||||
write_file(&base, b"current\n");
|
||||
for n in 1..=3 {
|
||||
write_file(
|
||||
&path_with_suffix(&base, n),
|
||||
format!("rotated-{n}\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
perform_rotation(&base, 3)
|
||||
.await
|
||||
.expect("rotation should succeed");
|
||||
|
||||
// `.3` was the oldest and is gone (its contents — "rotated-3" — are not
|
||||
// preserved anywhere). `.1` became `.2`, `.2` became `.3`, and the
|
||||
// original active file is at `.1`.
|
||||
assert!(!base.exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "rotated-1\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "rotated-2\n");
|
||||
assert!(!path_with_suffix(&base, 4).exists());
|
||||
}
|
||||
|
||||
/// With `max_rotation = 1`, the only rotated slot is `.1`. Each rotation
|
||||
/// overwrites `.1` with the latest active contents and discards the prior
|
||||
/// `.1`. Verifies the minimum-rotation edge case doesn't off-by-one.
|
||||
#[tokio::test]
|
||||
async fn rotate_with_max_rotation_one_overwrites_dot_one() {
|
||||
let dir = temp_dir("max-one");
|
||||
let base = dir.join("server.log");
|
||||
write_file(&base, b"second\n");
|
||||
write_file(&path_with_suffix(&base, 1), b"first\n");
|
||||
|
||||
perform_rotation(&base, 1)
|
||||
.await
|
||||
.expect("rotation should succeed");
|
||||
|
||||
assert!(!base.exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "second\n");
|
||||
// The previous `.1` ("first") has been displaced and discarded; the cap is
|
||||
// `max_rotation = 1`, so there's no `.2`.
|
||||
assert!(!path_with_suffix(&base, 2).exists());
|
||||
}
|
||||
|
||||
/// Missing intermediate rotated files (e.g. the user hasn't accumulated enough
|
||||
/// rotations to fill every slot) must not cause the rotation to error. Only the
|
||||
/// rename of the active file is fatal.
|
||||
#[tokio::test]
|
||||
async fn rotate_tolerates_missing_intermediate_files() {
|
||||
let dir = temp_dir("sparse");
|
||||
let base = dir.join("server.log");
|
||||
write_file(&base, b"current\n");
|
||||
// Skip `.1`, `.2`, `.3`; only `.4` exists.
|
||||
write_file(&path_with_suffix(&base, 4), b"old\n");
|
||||
|
||||
perform_rotation(&base, 5)
|
||||
.await
|
||||
.expect("rotation should succeed despite gaps");
|
||||
|
||||
assert!(!base.exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "current\n");
|
||||
// `.4` shifts to `.5`. `.2`, `.3`, `.4` remain empty/missing.
|
||||
assert!(!path_with_suffix(&base, 2).exists());
|
||||
assert!(!path_with_suffix(&base, 3).exists());
|
||||
assert!(!path_with_suffix(&base, 4).exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 5)), "old\n");
|
||||
}
|
||||
|
||||
/// Rotation when the active file doesn't exist (e.g. caller invoked rotation
|
||||
/// preemptively before any writes) should not error — there's simply nothing
|
||||
/// to promote. Existing rotated files still shift.
|
||||
#[tokio::test]
|
||||
async fn rotate_no_op_when_active_file_missing_but_still_shifts_rotated() {
|
||||
let dir = temp_dir("no-active");
|
||||
let base = dir.join("server.log");
|
||||
// No active file, but two rotated files exist.
|
||||
write_file(&path_with_suffix(&base, 1), b"one\n");
|
||||
write_file(&path_with_suffix(&base, 2), b"two\n");
|
||||
|
||||
perform_rotation(&base, 5)
|
||||
.await
|
||||
.expect("rotation should succeed with no active file");
|
||||
|
||||
assert!(!base.exists());
|
||||
// Without an active file, `.1` is empty (the shift moved `.1` -> `.2` and
|
||||
// `.2` -> `.3`; nothing populated `.1`).
|
||||
assert!(!path_with_suffix(&base, 1).exists());
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "one\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "two\n");
|
||||
}
|
||||
|
||||
/// After three consecutive rotations, the in-use file's contents propagate to
|
||||
/// `.3` and the eldest contents from the first rotation are gone. This
|
||||
/// exercises the iteration-direction property: a left-to-right loop would
|
||||
/// clobber files mid-shift.
|
||||
#[tokio::test]
|
||||
async fn three_consecutive_rotations_preserve_order_and_discard_oldest() {
|
||||
let dir = temp_dir("three-rotations");
|
||||
let base = dir.join("server.log");
|
||||
|
||||
write_file(&base, b"v1\n");
|
||||
perform_rotation(&base, 3).await.unwrap();
|
||||
// After rotation 1: .1 = v1
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v1\n");
|
||||
|
||||
write_file(&base, b"v2\n");
|
||||
perform_rotation(&base, 3).await.unwrap();
|
||||
// After rotation 2: .1 = v2, .2 = v1
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v2\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v1\n");
|
||||
|
||||
write_file(&base, b"v3\n");
|
||||
perform_rotation(&base, 3).await.unwrap();
|
||||
// After rotation 3: .1 = v3, .2 = v2, .3 = v1
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v3\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v2\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "v1\n");
|
||||
|
||||
write_file(&base, b"v4\n");
|
||||
perform_rotation(&base, 3).await.unwrap();
|
||||
// After rotation 4: v1 is discarded; .1=v4, .2=v3, .3=v2
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 1)), "v4\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 2)), "v3\n");
|
||||
assert_eq!(read_to_string(&path_with_suffix(&base, 3)), "v2\n");
|
||||
assert!(!path_with_suffix(&base, 4).exists());
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::{LogFileWriter, SimpleLogger};
|
||||
use galaxyui::r#async::executor::Background;
|
||||
use galaxyui::{Entity, SingletonEntity};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use thiserror::Error;
|
||||
use galaxyui_core::r#async::executor::Background;
|
||||
use galaxyui_core::{Entity, SingletonEntity};
|
||||
|
||||
use crate::{LogFileWriter, SimpleLogger};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LogManagerError {
|
||||
@@ -113,6 +115,27 @@ impl LogManager {
|
||||
namespace: &str,
|
||||
relative_path: impl AsRef<Path>,
|
||||
executor: Arc<Background>,
|
||||
) -> Result<SimpleLogger, LogManagerError> {
|
||||
self.register_with_rotation(namespace, relative_path, executor, None)
|
||||
}
|
||||
|
||||
/// Registers a logger with optional size-based rotation.
|
||||
///
|
||||
/// Identical to [`register`](Self::register) when `rotation` is `None`. When
|
||||
/// `Some(config)`, the resulting logger rotates the active log file once
|
||||
/// it accumulates `config.max_file_size_bytes` of writes, keeping up to
|
||||
/// `config.max_rotation` rotated copies on disk and discarding older ones.
|
||||
///
|
||||
/// This is the entry point used by callers that produce high-volume logs
|
||||
/// over long-lived sessions — primarily MCP server stderr/stdout capture,
|
||||
/// where a single chatty server could otherwise grow its log file
|
||||
/// unboundedly across a multi-day session (warpdotdev/warp#7723).
|
||||
pub fn register_with_rotation(
|
||||
&mut self,
|
||||
namespace: &str,
|
||||
relative_path: impl AsRef<Path>,
|
||||
executor: Arc<Background>,
|
||||
rotation: Option<crate::RotationConfig>,
|
||||
) -> Result<SimpleLogger, LogManagerError> {
|
||||
if !self.namespaces.contains(namespace) {
|
||||
return Err(LogManagerError::UnknownNamespace {
|
||||
@@ -120,13 +143,14 @@ impl LogManager {
|
||||
});
|
||||
}
|
||||
let path = resolve_log_path(namespace, relative_path);
|
||||
self.register_resolved_path(path, executor)
|
||||
self.register_resolved_path(path, executor, rotation)
|
||||
}
|
||||
|
||||
fn register_resolved_path(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
executor: Arc<Background>,
|
||||
rotation: Option<crate::RotationConfig>,
|
||||
) -> Result<SimpleLogger, LogManagerError> {
|
||||
if let Some(existing) = self.loggers.get(&path) {
|
||||
if let Some(writer) = existing.upgrade() {
|
||||
@@ -141,7 +165,7 @@ impl LogManager {
|
||||
|
||||
// In the absence of an active logger at this path, initialize and return a new logger,
|
||||
// which truncates any existing log file on creation.
|
||||
let logger = SimpleLogger::new(path.clone(), executor);
|
||||
let logger = SimpleLogger::new(path.clone(), executor, rotation);
|
||||
self.loggers.insert(path, logger.downgrade());
|
||||
Ok(logger)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxyui::r#async::executor::Background;
|
||||
use galaxyui_core::r#async::executor::Background;
|
||||
|
||||
use super::LogManager;
|
||||
use crate::{path_with_suffix, RotationConfig};
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -32,13 +29,13 @@ fn register_resolved_path_reuses_stale_entries_after_drop() {
|
||||
let log_path = temp_path("re-register").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), None)
|
||||
.expect("initial registration should succeed");
|
||||
|
||||
drop(logger);
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.register_resolved_path(log_path.clone(), executor, None)
|
||||
.expect("stale entry should be reclaimed after the logger is dropped");
|
||||
drop(logger);
|
||||
cleanup_log_path(&log_path);
|
||||
@@ -51,11 +48,11 @@ fn register_resolved_path_rejects_duplicate_active_loggers() {
|
||||
let log_path = temp_path("collision").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), None)
|
||||
.expect("initial registration should succeed");
|
||||
assert!(
|
||||
manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.register_resolved_path(log_path.clone(), executor, None)
|
||||
.is_err(),
|
||||
"live logger should block duplicate registration"
|
||||
);
|
||||
@@ -71,17 +68,226 @@ fn register_reclaims_closed_logger() {
|
||||
let log_path = temp_path("close-reclaim").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone())
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), None)
|
||||
.expect("initial registration should succeed");
|
||||
|
||||
// Close the channel without dropping the logger — the Arc<LogFileWriter> is still alive.
|
||||
logger.close();
|
||||
|
||||
let new_logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor)
|
||||
.register_resolved_path(log_path.clone(), executor, None)
|
||||
.expect("closed logger should be reclaimed even when Arc is still alive");
|
||||
|
||||
drop(logger);
|
||||
drop(new_logger);
|
||||
cleanup_log_path(&log_path);
|
||||
}
|
||||
|
||||
// ---------- end-to-end rotation through SimpleLogger ----------
|
||||
//
|
||||
// These tests drive a live `SimpleLogger` running on a real background
|
||||
// executor and assert that, when configured with rotation, sufficient writes
|
||||
// cause the active log file to roll over. They cover the integrated path
|
||||
// (channel send → async write → byte counter → rotation), which the file-level
|
||||
// unit tests in `lib_tests.rs` deliberately don't touch.
|
||||
|
||||
/// Wait until `predicate` returns `Some(value)`, polling every 10 ms. Returns
|
||||
/// the value, or panics with `label` if the deadline elapses without the
|
||||
/// predicate succeeding. Used to synchronize on async file writes without
|
||||
/// hard-coding sleeps that pad every run with dead time.
|
||||
fn wait_for<T>(deadline_ms: u64, label: &str, mut predicate: impl FnMut() -> Option<T>) -> T {
|
||||
// `instant::Instant` is the cross-target (incl. wasm) drop-in for
|
||||
// `std::time::Instant`; the rest of the workspace standardizes on it via
|
||||
// the `disallowed_types` clippy lint.
|
||||
let start = instant::Instant::now();
|
||||
let deadline = std::time::Duration::from_millis(deadline_ms);
|
||||
loop {
|
||||
if let Some(value) = predicate() {
|
||||
return value;
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
panic!(
|
||||
"wait_for({label}) timed out after {} ms",
|
||||
deadline.as_millis()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Repeatedly logging a moderately sized message must eventually cause the
|
||||
/// active log file to be rotated to `.1` and a fresh active file to replace it.
|
||||
/// This is the headline behavior #7723 asks for.
|
||||
#[test]
|
||||
fn simple_logger_with_rotation_rolls_active_file_over_when_threshold_exceeded() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let log_path = temp_path("rotation-rolls-over").join("server.log");
|
||||
|
||||
// Each log line is timestamped + the message + a newline; comfortably more
|
||||
// than 64 bytes per line. With a 256-byte threshold, five lines is enough
|
||||
// to guarantee at least one rotation.
|
||||
let rotation = RotationConfig::new(256, 3).expect("config should construct");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), Some(rotation))
|
||||
.expect("registration should succeed");
|
||||
|
||||
for i in 0..10 {
|
||||
logger.log(format!(
|
||||
"log line {i} padded out so each entry is comfortably over fifty bytes"
|
||||
));
|
||||
}
|
||||
// Closing the channel without dropping the Arc lets the background task
|
||||
// finish flushing pending writes deterministically.
|
||||
logger.close();
|
||||
drop(logger);
|
||||
|
||||
// After draining, the active file should be reopened-truncated (or
|
||||
// contain just the tail end of writes), and at least one `.1` rotation
|
||||
// should exist with the rolled-over contents.
|
||||
let rotated = path_with_suffix(&log_path, 1);
|
||||
wait_for(2000, "rotated `.1` to appear", || {
|
||||
if rotated.exists() {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
assert!(
|
||||
rotated.exists(),
|
||||
"rotation should have produced {:?}",
|
||||
rotated
|
||||
);
|
||||
let rotated_contents = std::fs::read_to_string(&rotated).expect("read rotated file");
|
||||
assert!(
|
||||
rotated_contents.contains("log line"),
|
||||
"rotated file should contain log lines, got: {:?}",
|
||||
rotated_contents
|
||||
);
|
||||
|
||||
// Cleanup: remove the whole rotation set if it exists.
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
for n in 1..=3 {
|
||||
let _ = std::fs::remove_file(path_with_suffix(&log_path, n));
|
||||
}
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::remove_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a rotation config, the same write volume that rotates a configured
|
||||
/// logger must NOT rotate an unconfigured one. Pins the backward-compatibility
|
||||
/// guarantee: existing callers (everything other than the MCP path) see
|
||||
/// unchanged truncate-on-create behavior.
|
||||
#[test]
|
||||
fn simple_logger_without_rotation_does_not_rotate_even_at_high_volume() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let log_path = temp_path("no-rotation").join("server.log");
|
||||
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), None)
|
||||
.expect("registration should succeed");
|
||||
|
||||
for i in 0..50 {
|
||||
logger.log(format!(
|
||||
"log line {i} padded out so each entry is comfortably over fifty bytes"
|
||||
));
|
||||
}
|
||||
logger.close();
|
||||
drop(logger);
|
||||
|
||||
// Give the background task time to drain pending writes before we assert.
|
||||
wait_for(2000, "active file to be non-empty", || {
|
||||
if log_path.metadata().map(|m| m.len() > 0).unwrap_or(false) {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// No `.1` file ever gets created when rotation is disabled, no matter
|
||||
// how much we wrote.
|
||||
assert!(
|
||||
!path_with_suffix(&log_path, 1).exists(),
|
||||
"no rotation should occur when config is None"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::remove_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// When `perform_rotation` fails (e.g. the rename of the active file to its
|
||||
/// `.1` slot fails because that path is occupied by a non-empty directory),
|
||||
/// the active log content must survive. Previously the callback fell through
|
||||
/// to `open_truncated`, which destroyed the data rotation was meant to
|
||||
/// preserve. The fix opens append-mode on failure and seeds the byte counter
|
||||
/// from the existing file size.
|
||||
#[test]
|
||||
fn simple_logger_rotation_failure_preserves_active_log_content() {
|
||||
let mut manager = LogManager::new();
|
||||
let executor = Arc::new(Background::default());
|
||||
let dir = temp_path("rotation-failure-preserves");
|
||||
let log_path = dir.join("server.log");
|
||||
|
||||
// Pre-stage `server.log.1` as a non-empty directory so the rename in
|
||||
// `perform_rotation` (step 3) will fail with a real I/O error rather than
|
||||
// succeed. This is the only error path the rotation callback observes.
|
||||
//
|
||||
// Use `max_rotation = 1` so step 2's "shift older slots up" loop is empty
|
||||
// (`1..1` is empty) — otherwise step 2 would silently rename the blocking
|
||||
// directory out of the way before step 3 ran.
|
||||
let blocked_target = path_with_suffix(&log_path, 1);
|
||||
std::fs::create_dir_all(&blocked_target).expect("create blocking dir");
|
||||
std::fs::write(blocked_target.join("placeholder"), b"sentinel").expect("populate blocking dir");
|
||||
|
||||
let rotation = RotationConfig::new(256, 1).expect("config should construct");
|
||||
let logger = manager
|
||||
.register_resolved_path(log_path.clone(), executor.clone(), Some(rotation))
|
||||
.expect("registration should succeed");
|
||||
|
||||
// Write enough to push past the 256-byte threshold and trigger rotation.
|
||||
for i in 0..10 {
|
||||
logger.log(format!(
|
||||
"log line {i} padded out so each entry is comfortably over fifty bytes"
|
||||
));
|
||||
}
|
||||
logger.close();
|
||||
drop(logger);
|
||||
|
||||
// After draining, the active log must still exist and contain the
|
||||
// pre-rotation content (because rotation could not move it aside). The
|
||||
// blocking directory must also still be intact — rotation didn't somehow
|
||||
// clobber it.
|
||||
wait_for(2000, "active log to flush", || {
|
||||
std::fs::metadata(&log_path)
|
||||
.ok()
|
||||
.filter(|m| m.len() > 0)
|
||||
.map(|_| ())
|
||||
});
|
||||
|
||||
let preserved = std::fs::read_to_string(&log_path).expect("read active log");
|
||||
assert!(
|
||||
preserved.contains("log line"),
|
||||
"active log content must be preserved when rotation fails; got: {preserved:?}",
|
||||
);
|
||||
|
||||
assert!(
|
||||
blocked_target.is_dir(),
|
||||
"blocking directory must remain intact after failed rotation",
|
||||
);
|
||||
assert!(
|
||||
blocked_target.join("placeholder").is_file(),
|
||||
"blocking directory's contents must be unchanged",
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
let _ = std::fs::remove_dir_all(&blocked_target);
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::remove_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user