first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+217 -24
View File
@@ -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;
+258
View File
@@ -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());
}
+29 -5
View File
@@ -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)
}
+220 -14
View File
@@ -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);
}
}