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;
|
||||
|
||||
Reference in New Issue
Block a user