first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -29,6 +29,9 @@ wasm-bindgen.workspace = true
|
||||
web-sys.workspace = true
|
||||
galaxy_web_event_bus.workspace = true
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[features]
|
||||
crash_reporting = ["dep:sentry", "dep:sentry-log", "galaxy_core/crash_reporting"]
|
||||
agent_mode_evals = []
|
||||
|
||||
@@ -8,23 +8,32 @@ pub enum LogDestination {
|
||||
}
|
||||
|
||||
/// Configuration for initializing the logger.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LogConfig {
|
||||
/// Whether the caller is the CLI. When true, logs are written to a separate subdirectory
|
||||
/// with a higher rotation limit so that CLI invocations don't evict GUI application logs.
|
||||
pub is_cli: bool,
|
||||
/// The destination for log output. If `None`, the destination is inferred from the environment.
|
||||
pub log_destination: Option<LogDestination>,
|
||||
/// Optional in-session size threshold for `warp.log`. When `Some(n)` and the active
|
||||
/// file accumulates more than `n` bytes during a single execution, it is rotated to
|
||||
/// `warp.log.in_session.0` and a fresh active file is opened. Older `.in_session.N`
|
||||
/// files shift up and the oldest is discarded, matching the per-startup
|
||||
/// `rotate_log_files` behavior. `None` preserves the existing unbounded-within-session
|
||||
/// growth (warpdotdev/warp#10879).
|
||||
pub max_file_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
|
||||
mod imp;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod rotation;
|
||||
|
||||
pub use imp::init;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use imp::{create_log_bundle_zip, log_directory, log_file_path, rotate_log_files};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use imp::{
|
||||
init_for_crash_recovery_process, init_logging_for_unit_tests, on_crash_recovery_process_killed,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{IsTerminal, Write, copy};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
env,
|
||||
fs::{self, File},
|
||||
io::{IsTerminal, Write, copy},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use log::LevelFilter;
|
||||
use std::sync::OnceLock;
|
||||
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
use crate::{LogConfig, LogDestination};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
|
||||
const MAX_FILES_IN_GUI_ROTATION: usize = 5;
|
||||
const MAX_FILES_IN_CLI_ROTATION: usize = 10;
|
||||
@@ -159,19 +158,37 @@ pub async fn rotate_files(channel_file_name: &str, max_rotation: usize) -> Resul
|
||||
}
|
||||
};
|
||||
|
||||
// Delete the oldest log file.
|
||||
// Delete the oldest log file (and any nested .in_session.M chunks that
|
||||
// belonged to that oldest startup-rotation slot).
|
||||
let largest_log_file_suffix = max_rotation.saturating_sub(1);
|
||||
let _ = fs::remove_file(
|
||||
log_directory.join(format!("{channel_file_name}.old.{largest_log_file_suffix}")),
|
||||
);
|
||||
remove_old_session_in_session_chunks(
|
||||
&log_directory,
|
||||
channel_file_name,
|
||||
largest_log_file_suffix,
|
||||
);
|
||||
|
||||
// Rotate the log files.
|
||||
// Rotate the .old.N startup-rotation slots, and along with each one any
|
||||
// nested `<name>.log.old.{N}.in_session.M` chunks left by the session
|
||||
// that produced the .old.N slot. Nested chunks shift with their parent
|
||||
// so they stay associated with the same logical session.
|
||||
for file_no in (0..largest_log_file_suffix).rev() {
|
||||
let old_file_path = log_directory.join(format!("{channel_file_name}.old.{file_no}"));
|
||||
let new_file_path = log_directory.join(format!("{channel_file_name}.old.{}", file_no + 1));
|
||||
let _ = fs::rename(old_file_path, new_file_path);
|
||||
|
||||
shift_old_session_in_session_chunks(&log_directory, channel_file_name, file_no);
|
||||
}
|
||||
|
||||
// Migrate the previous session's `<name>.log.in_session.M` files into
|
||||
// the `<name>.log.old.0.in_session.M` namespace, so the next session
|
||||
// opens with a clean `.in_session.*` window. The active log it produced
|
||||
// is renamed below from `.log.old.temp` to `.log.old.0`, so this naming
|
||||
// co-locates each old session's final state with its mid-session chunks.
|
||||
migrate_previous_session_in_session_chunks(&log_directory, channel_file_name);
|
||||
|
||||
// Rename `warp.log.old.temp` (the temporary file) to `warp.log.old.0`.
|
||||
let temp_file_path = temp_log_file_path(&log_directory);
|
||||
|
||||
@@ -183,12 +200,89 @@ pub async fn rotate_files(channel_file_name: &str, max_rotation: usize) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every `<channel_file_name>.old.{slot_index}.in_session.M` file.
|
||||
/// Called when an entire `.old.{slot_index}` slot is being discarded so its
|
||||
/// nested mid-session chunks are discarded alongside it.
|
||||
fn remove_old_session_in_session_chunks(
|
||||
log_directory: &Path,
|
||||
channel_file_name: &str,
|
||||
slot_index: usize,
|
||||
) {
|
||||
let prefix = format!("{channel_file_name}.old.{slot_index}.in_session.");
|
||||
let Ok(read_dir) = fs::read_dir(log_directory) else {
|
||||
return;
|
||||
};
|
||||
for entry in read_dir.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(rest) = name.strip_prefix(&prefix)
|
||||
&& rest.parse::<usize>().is_ok()
|
||||
{
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `<channel_file_name>.old.{from}.in_session.M` files to
|
||||
/// `<channel_file_name>.old.{from+1}.in_session.M`, shifting a previous
|
||||
/// session's nested chunks one slot older alongside their parent `.old.N`.
|
||||
fn shift_old_session_in_session_chunks(log_directory: &Path, channel_file_name: &str, from: usize) {
|
||||
let prefix = format!("{channel_file_name}.old.{from}.in_session.");
|
||||
let Ok(read_dir) = fs::read_dir(log_directory) else {
|
||||
return;
|
||||
};
|
||||
for entry in read_dir.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(rest) = name.strip_prefix(&prefix)
|
||||
&& let Ok(chunk_index) = rest.parse::<usize>()
|
||||
{
|
||||
let new_path = log_directory.join(format!(
|
||||
"{channel_file_name}.old.{}.in_session.{chunk_index}",
|
||||
from + 1
|
||||
));
|
||||
let _ = fs::rename(path, new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the previous session's `<channel_file_name>.in_session.M` files
|
||||
/// into `<channel_file_name>.old.0.in_session.M`. Co-locates each old
|
||||
/// session's mid-session chunks with the `.old.0` slot that holds its
|
||||
/// final-state log, and frees the `.in_session.*` namespace for the new
|
||||
/// session that just started.
|
||||
fn migrate_previous_session_in_session_chunks(log_directory: &Path, channel_file_name: &str) {
|
||||
let prefix = format!("{channel_file_name}.in_session.");
|
||||
let Ok(read_dir) = fs::read_dir(log_directory) else {
|
||||
return;
|
||||
};
|
||||
for entry in read_dir.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(rest) = name.strip_prefix(&prefix)
|
||||
&& let Ok(chunk_index) = rest.parse::<usize>()
|
||||
{
|
||||
let new_path = log_directory.join(format!(
|
||||
"{channel_file_name}.old.0.in_session.{chunk_index}"
|
||||
));
|
||||
let _ = fs::rename(path, new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the logger for the crash recovery process.
|
||||
pub fn init_for_crash_recovery_process() -> Result<()> {
|
||||
init_internal(
|
||||
true, /* is_from_crash_recovery_process */
|
||||
false, /* is_cli */
|
||||
None, /* log_destination */
|
||||
None, /* max_file_size_bytes — crash recovery uses its own short-lived log */
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,6 +295,7 @@ pub fn init(config: LogConfig) -> Result<()> {
|
||||
false, /* is_from_crash_recovery_process */
|
||||
config.is_cli,
|
||||
config.log_destination,
|
||||
config.max_file_size_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,42 +323,106 @@ pub fn log_file_path() -> Result<PathBuf> {
|
||||
Ok(main_process_log_file_path(&dir))
|
||||
}
|
||||
|
||||
/// Collects a list of the paths to both the current warp instance's log file,
|
||||
/// and any older log files (we keep up to 6 log files around at any time,
|
||||
/// all of which are potentially useful for debugging).
|
||||
/// Collects paths to the current warp instance's log file and any older
|
||||
/// log files (up to 6 retained, all potentially useful for debugging).
|
||||
///
|
||||
/// Returned ordering is newest-first, grouped by session:
|
||||
///
|
||||
/// - The active `<name>.log` (current session's most recent writes).
|
||||
/// - `<name>.log.in_session.N` files produced by mid-session size rotation
|
||||
/// of the current session, sorted by index (`.in_session.0` is the most
|
||||
/// recent rotation).
|
||||
/// - For each previous-startup slot `K = 0..max_rotation`, in order:
|
||||
/// `<name>.log.old.K` (that session's final-state log) immediately
|
||||
/// followed by its `<name>.log.old.K.in_session.N` chunks, sorted by N.
|
||||
fn current_and_rotated_log_paths() -> Result<Vec<PathBuf>> {
|
||||
let log_directory = log_directory()?;
|
||||
let current_log_path = main_process_log_file_path(&log_directory);
|
||||
let logfile_name = ChannelState::logfile_name();
|
||||
collect_log_paths_in(&log_directory, &logfile_name)
|
||||
}
|
||||
|
||||
let mut rotated_logs: Vec<(usize, PathBuf)> = fs::read_dir(&log_directory)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter_map(|path| {
|
||||
let file_name = path.file_name()?.to_str()?;
|
||||
let suffix =
|
||||
file_name.strip_prefix(&format!("{}.old.", ChannelState::logfile_name()))?;
|
||||
let index = suffix.parse::<usize>().ok()?;
|
||||
Some((index, path))
|
||||
})
|
||||
.collect();
|
||||
rotated_logs.sort_by_key(|(index, _)| *index);
|
||||
/// Directory-scanning core of [`current_and_rotated_log_paths`], parameterized
|
||||
/// for testability. See the parent docs for ordering semantics.
|
||||
fn collect_log_paths_in(log_directory: &Path, logfile_name: &str) -> Result<Vec<PathBuf>> {
|
||||
let current_log_path = log_directory.join(logfile_name);
|
||||
let in_session_prefix = format!("{logfile_name}.in_session.");
|
||||
let old_prefix = format!("{logfile_name}.old.");
|
||||
|
||||
// Current session's mid-session rotation slots: <name>.log.in_session.N.
|
||||
let mut current_in_session: Vec<(usize, PathBuf)> = Vec::new();
|
||||
// Previous-startup final logs: <name>.log.old.K.
|
||||
let mut old_logs: Vec<(usize, PathBuf)> = Vec::new();
|
||||
// Previous sessions' nested mid-session chunks: <name>.log.old.K.in_session.M.
|
||||
// Keyed by (K, M) so each K's chunks group together with their .old.K parent.
|
||||
let mut old_nested: Vec<(usize, usize, PathBuf)> = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(log_directory)?.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(suffix) = file_name.strip_prefix(&in_session_prefix) {
|
||||
if let Ok(index) = suffix.parse::<usize>() {
|
||||
current_in_session.push((index, path));
|
||||
}
|
||||
} else if let Some(suffix) = file_name.strip_prefix(&old_prefix) {
|
||||
// suffix can be either `K` (an old log) or `K.in_session.M`
|
||||
// (a previous session's nested mid-rotation chunk).
|
||||
if let Ok(index) = suffix.parse::<usize>() {
|
||||
old_logs.push((index, path));
|
||||
} else if let Some((slot_str, chunk_str)) = suffix.split_once(".in_session.")
|
||||
&& let (Ok(slot), Ok(chunk)) =
|
||||
(slot_str.parse::<usize>(), chunk_str.parse::<usize>())
|
||||
{
|
||||
old_nested.push((slot, chunk, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
current_in_session.sort_by_key(|(index, _)| *index);
|
||||
old_logs.sort_by_key(|(index, _)| *index);
|
||||
old_nested.sort_by_key(|(slot, chunk, _)| (*slot, *chunk));
|
||||
|
||||
let mut files = Vec::new();
|
||||
if current_log_path.is_file() {
|
||||
files.push(current_log_path);
|
||||
}
|
||||
|
||||
files.extend(
|
||||
rotated_logs
|
||||
current_in_session
|
||||
.into_iter()
|
||||
.map(|(_, path)| path)
|
||||
.filter(|path| path.is_file()),
|
||||
);
|
||||
|
||||
// Interleave each .old.K with its nested .old.K.in_session.M chunks so
|
||||
// a session's final state is immediately followed by that session's
|
||||
// mid-session chunks before the next-older session begins.
|
||||
let mut nested_iter = old_nested.into_iter().peekable();
|
||||
for (slot, old_path) in old_logs {
|
||||
if old_path.is_file() {
|
||||
files.push(old_path);
|
||||
}
|
||||
while let Some((nslot, _, _)) = nested_iter.peek() {
|
||||
if *nslot != slot {
|
||||
break;
|
||||
}
|
||||
let (_, _, npath) = nested_iter.next().expect("peek matched");
|
||||
if npath.is_file() {
|
||||
files.push(npath);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any nested chunks whose parent .old.K is missing on disk still get
|
||||
// included after their slot has been skipped above — they show up here
|
||||
// grouped by (slot, chunk) ordering since they were never paired.
|
||||
for (_, _, npath) in nested_iter {
|
||||
if npath.is_file() {
|
||||
files.push(npath);
|
||||
}
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"No warp logs were found for {}",
|
||||
ChannelState::logfile_name()
|
||||
"No warp logs were found for {logfile_name}"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -332,7 +491,7 @@ fn sentry_log_filter(md: &log::Metadata) -> sentry_log::LogFilter {
|
||||
}
|
||||
|
||||
// Filter out the "redraw_frame" logging from breadcrumbs.
|
||||
"galaxyui::core::redraw_frame" => sentry_log::LogFilter::Ignore,
|
||||
"galaxyui_core::core::redraw_frame" => sentry_log::LogFilter::Ignore,
|
||||
|
||||
// Filter out logs from the crash-reporting implementation, in case it logs
|
||||
// anything in the process of forwarding logs to Sentry.
|
||||
@@ -346,6 +505,7 @@ fn init_internal(
|
||||
is_from_crash_recovery_process: bool,
|
||||
is_cli: bool,
|
||||
log_destination: Option<LogDestination>,
|
||||
max_file_size_bytes: Option<u64>,
|
||||
) -> Result<()> {
|
||||
/// Returns an empty file named `warp.log` to log the current execution, and
|
||||
/// renames the previous execution's log to a temporary name.
|
||||
@@ -421,9 +581,24 @@ fn init_internal(
|
||||
log_directory = log_directory.join(CLI_LOG_SUBDIRECTORY);
|
||||
}
|
||||
if use_logfile {
|
||||
base_logger.target(env_logger::Target::Pipe(Box::new(
|
||||
setup_log_files_for_current_execution(&log_directory, is_from_crash_recovery_process)?,
|
||||
)));
|
||||
let file =
|
||||
setup_log_files_for_current_execution(&log_directory, is_from_crash_recovery_process)?;
|
||||
// Crash-recovery logs are short-lived (the file is renamed into place
|
||||
// by the parent on crash, and otherwise deleted on clean exit), so
|
||||
// skip in-session rotation for them — `max_file_size_bytes` only
|
||||
// applies to the main process's `warp.log`.
|
||||
let target: Box<dyn std::io::Write + Send + 'static> = if is_from_crash_recovery_process {
|
||||
Box::new(file)
|
||||
} else {
|
||||
crate::rotation::wrap_for_rotation(
|
||||
file,
|
||||
&log_directory,
|
||||
&ChannelState::logfile_name(),
|
||||
max_file_size_bytes,
|
||||
max_rotation,
|
||||
)?
|
||||
};
|
||||
base_logger.target(env_logger::Target::Pipe(target));
|
||||
base_logger.format(format_for_file_output);
|
||||
} else {
|
||||
// Agent mode eval outputs are written to stdout but redirected to a file, so we don't want terminal styling.
|
||||
@@ -480,7 +655,7 @@ fn init_log_directory() -> Result<std::path::PathBuf> {
|
||||
anyhow::anyhow!("could not locate home directory in order to create a log file")
|
||||
})?
|
||||
.join("Library/Logs/"))
|
||||
} else if #[cfg(target_os = "linux")] {
|
||||
} else if #[cfg(any(target_os = "linux", target_os = "freebsd"))] {
|
||||
Ok(galaxy_core::paths::state_dir())
|
||||
} else if #[cfg(windows)] {
|
||||
Ok(galaxy_core::paths::state_dir().join(galaxy_core::paths::WARP_LOGS_DIR))
|
||||
@@ -504,3 +679,7 @@ pub fn init_logging_for_unit_tests() {
|
||||
.format(format_for_terminal_output)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "native_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use super::*;
|
||||
|
||||
fn touch(dir: &Path, name: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
File::create(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_active_in_session_and_old_logs_in_expected_order() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let active = touch(tmp.path(), "warp.log");
|
||||
let in_session_0 = touch(tmp.path(), "warp.log.in_session.0");
|
||||
let in_session_1 = touch(tmp.path(), "warp.log.in_session.1");
|
||||
let in_session_2 = touch(tmp.path(), "warp.log.in_session.2");
|
||||
let old_0 = touch(tmp.path(), "warp.log.old.0");
|
||||
let old_1 = touch(tmp.path(), "warp.log.old.1");
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp.log").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
active,
|
||||
in_session_0,
|
||||
in_session_1,
|
||||
in_session_2,
|
||||
old_0,
|
||||
old_1
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_in_session_logs_even_when_no_active_or_old_logs_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let in_session_0 = touch(tmp.path(), "warp.log.in_session.0");
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp.log").unwrap();
|
||||
|
||||
assert_eq!(paths, vec![in_session_0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_files_and_malformed_suffixes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let active = touch(tmp.path(), "warp.log");
|
||||
touch(tmp.path(), "warp.log.in_session.abc"); // not a number
|
||||
touch(tmp.path(), "warp.log.in_session."); // empty suffix
|
||||
touch(tmp.path(), "warp.log.old.xyz"); // not a number
|
||||
touch(tmp.path(), "other.log"); // unrelated
|
||||
touch(tmp.path(), "warp.log.old.temp"); // matches old. prefix but non-numeric
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp.log").unwrap();
|
||||
|
||||
assert_eq!(paths, vec![active]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_directory_is_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let err = collect_log_paths_in(tmp.path(), "warp.log").unwrap_err();
|
||||
assert!(err.to_string().contains("No warp logs were found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_channel_specific_logfile_name() {
|
||||
// Beta/preview channels use a different base name; make sure scanning
|
||||
// is gated on that name and doesn't pick up the wrong channel's files.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let active = touch(tmp.path(), "warp_preview.log");
|
||||
let in_session_0 = touch(tmp.path(), "warp_preview.log.in_session.0");
|
||||
touch(tmp.path(), "warp.log"); // different channel — must be ignored
|
||||
touch(tmp.path(), "warp.log.in_session.0");
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp_preview.log").unwrap();
|
||||
|
||||
assert_eq!(paths, vec![active, in_session_0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaves_old_logs_with_their_nested_in_session_chunks() {
|
||||
// Previous sessions' mid-rotation chunks are nested under their
|
||||
// parent .old.K slot; collection should output each .old.K
|
||||
// immediately followed by its .old.K.in_session.M chunks before
|
||||
// moving on to .old.{K+1}.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let active = touch(tmp.path(), "warp.log");
|
||||
let cur_in_session_0 = touch(tmp.path(), "warp.log.in_session.0");
|
||||
let old_0 = touch(tmp.path(), "warp.log.old.0");
|
||||
let old_0_chunk_0 = touch(tmp.path(), "warp.log.old.0.in_session.0");
|
||||
let old_0_chunk_1 = touch(tmp.path(), "warp.log.old.0.in_session.1");
|
||||
let old_1 = touch(tmp.path(), "warp.log.old.1");
|
||||
let old_1_chunk_0 = touch(tmp.path(), "warp.log.old.1.in_session.0");
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp.log").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
active,
|
||||
cur_in_session_0,
|
||||
old_0,
|
||||
old_0_chunk_0,
|
||||
old_0_chunk_1,
|
||||
old_1,
|
||||
old_1_chunk_0,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_chunks_surface_even_when_parent_old_slot_is_missing() {
|
||||
// If a .old.K slot is missing from disk but its nested chunks
|
||||
// remain (e.g. truncated by manual cleanup), the chunks should
|
||||
// still be bundled rather than silently dropped.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let active = touch(tmp.path(), "warp.log");
|
||||
let orphan_chunk = touch(tmp.path(), "warp.log.old.3.in_session.0");
|
||||
|
||||
let paths = collect_log_paths_in(tmp.path(), "warp.log").unwrap();
|
||||
|
||||
assert_eq!(paths, vec![active, orphan_chunk]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_previous_session_renames_in_session_to_old_0_in_session() {
|
||||
// Mid-session chunks from the previous session belong with the
|
||||
// .old.0 slot that holds that session's final-state log.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
touch(tmp.path(), "warp.log.in_session.0");
|
||||
touch(tmp.path(), "warp.log.in_session.1");
|
||||
touch(tmp.path(), "warp.log.in_session.2");
|
||||
|
||||
migrate_previous_session_in_session_chunks(tmp.path(), "warp.log");
|
||||
|
||||
assert!(tmp.path().join("warp.log.old.0.in_session.0").is_file());
|
||||
assert!(tmp.path().join("warp.log.old.0.in_session.1").is_file());
|
||||
assert!(tmp.path().join("warp.log.old.0.in_session.2").is_file());
|
||||
// Bare .in_session.* slots are free for the new session.
|
||||
assert!(!tmp.path().join("warp.log.in_session.0").exists());
|
||||
assert!(!tmp.path().join("warp.log.in_session.1").exists());
|
||||
assert!(!tmp.path().join("warp.log.in_session.2").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_previous_session_is_a_noop_when_no_in_session_chunks_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let unrelated = touch(tmp.path(), "warp.log");
|
||||
|
||||
migrate_previous_session_in_session_chunks(tmp.path(), "warp.log");
|
||||
|
||||
// Active log untouched; no spurious .old.0.in_session.* files.
|
||||
assert!(unrelated.is_file());
|
||||
let any_nested = fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.any(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("warp.log.old.0.in_session.")
|
||||
});
|
||||
assert!(!any_nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_previous_session_ignores_malformed_in_session_filenames() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let real = touch(tmp.path(), "warp.log.in_session.0");
|
||||
let bogus_a = touch(tmp.path(), "warp.log.in_session.abc");
|
||||
let bogus_b = touch(tmp.path(), "warp.log.in_session.");
|
||||
let unrelated = touch(tmp.path(), "warp.log.in_session.0.weird"); // not a usize
|
||||
|
||||
migrate_previous_session_in_session_chunks(tmp.path(), "warp.log");
|
||||
|
||||
assert!(!real.exists()); // moved
|
||||
assert!(tmp.path().join("warp.log.old.0.in_session.0").is_file());
|
||||
// Malformed entries are left where they are.
|
||||
assert!(bogus_a.is_file());
|
||||
assert!(bogus_b.is_file());
|
||||
assert!(unrelated.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_nested_chunks_renames_old_n_in_session_to_old_n_plus_1() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
touch(tmp.path(), "warp.log.old.0.in_session.0");
|
||||
touch(tmp.path(), "warp.log.old.0.in_session.1");
|
||||
|
||||
shift_old_session_in_session_chunks(tmp.path(), "warp.log", 0);
|
||||
|
||||
assert!(tmp.path().join("warp.log.old.1.in_session.0").is_file());
|
||||
assert!(tmp.path().join("warp.log.old.1.in_session.1").is_file());
|
||||
assert!(!tmp.path().join("warp.log.old.0.in_session.0").exists());
|
||||
assert!(!tmp.path().join("warp.log.old.0.in_session.1").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_nested_chunks_only_touches_the_requested_slot() {
|
||||
// Shifting slot 0 must not disturb slot 1's nested chunks.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
touch(tmp.path(), "warp.log.old.0.in_session.0");
|
||||
let slot1_chunk = touch(tmp.path(), "warp.log.old.1.in_session.0");
|
||||
|
||||
shift_old_session_in_session_chunks(tmp.path(), "warp.log", 0);
|
||||
|
||||
assert!(tmp.path().join("warp.log.old.1.in_session.0").is_file());
|
||||
assert_eq!(tmp.path().join("warp.log.old.1.in_session.0"), slot1_chunk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nested_chunks_deletes_every_chunk_of_the_target_slot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
touch(tmp.path(), "warp.log.old.4.in_session.0");
|
||||
touch(tmp.path(), "warp.log.old.4.in_session.1");
|
||||
let survivor = touch(tmp.path(), "warp.log.old.3.in_session.0");
|
||||
|
||||
remove_old_session_in_session_chunks(tmp.path(), "warp.log", 4);
|
||||
|
||||
assert!(!tmp.path().join("warp.log.old.4.in_session.0").exists());
|
||||
assert!(!tmp.path().join("warp.log.old.4.in_session.1").exists());
|
||||
// Other slots' chunks are untouched.
|
||||
assert!(survivor.is_file());
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! In-session size-based rotation for `warp.log`.
|
||||
//!
|
||||
//! The existing startup rotation (`rotate_log_files`) handles the
|
||||
//! per-execution boundary: each launch's `warp.log` becomes `warp.log.old.N`
|
||||
//! at the next launch, with older files shifting up and the oldest dropping
|
||||
//! off. That model bounds disk usage *per restart* but the active session's
|
||||
//! log itself grows unboundedly.
|
||||
//!
|
||||
//! This module adds the orthogonal in-session bound: a `Write` wrapper that
|
||||
//! rotates the active file once its byte count crosses a configured
|
||||
//! threshold. Rotated copies land at `warp.log.in_session.N` (distinct from
|
||||
//! the startup `.old.N` slots, which log-bundle uploads and other UX depend
|
||||
//! on). When the configured number of `.in_session.N` slots is full, the
|
||||
//! oldest is discarded — matching `rotate_log_files`'s overflow semantics.
|
||||
//!
|
||||
//! See warpdotdev/warp#10879.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `Write` implementation that rotates its backing file once `max_bytes` of
|
||||
/// writes have accumulated. On rotation, the active path is renamed to
|
||||
/// `<base>.in_session.0`, existing `<base>.in_session.N` files shift up,
|
||||
/// and the oldest beyond `max_rotation` is deleted. A fresh empty active
|
||||
/// file is then opened.
|
||||
pub(crate) struct RotatingFileWriter {
|
||||
log_directory: PathBuf,
|
||||
base_file_name: String,
|
||||
max_bytes: u64,
|
||||
max_rotation: usize,
|
||||
bytes_written: u64,
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl RotatingFileWriter {
|
||||
/// Opens (or truncates) `<log_directory>/<base_file_name>` and starts
|
||||
/// tracking byte counts toward `max_bytes`. `max_rotation` is the number
|
||||
/// of `.in_session.N` slots to retain.
|
||||
pub(crate) fn open(
|
||||
log_directory: impl Into<PathBuf>,
|
||||
base_file_name: impl Into<String>,
|
||||
max_bytes: u64,
|
||||
max_rotation: usize,
|
||||
) -> io::Result<Self> {
|
||||
let log_directory = log_directory.into();
|
||||
let base_file_name = base_file_name.into();
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(log_directory.join(&base_file_name))?;
|
||||
Ok(Self {
|
||||
log_directory,
|
||||
base_file_name,
|
||||
max_bytes,
|
||||
max_rotation,
|
||||
bytes_written: 0,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
fn in_session_path(&self, index: usize) -> PathBuf {
|
||||
self.log_directory
|
||||
.join(format!("{}.in_session.{index}", self.base_file_name))
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.log_directory.join(&self.base_file_name)
|
||||
}
|
||||
|
||||
/// Rotates the active file. Drops the oldest `.in_session.N`, shifts
|
||||
/// the remaining slots up by one, renames the active file into slot 0,
|
||||
/// and opens a fresh active file.
|
||||
fn rotate(&mut self) -> io::Result<()> {
|
||||
if self.max_rotation == 0 {
|
||||
// Caller asked for zero retained rotations — just truncate and
|
||||
// continue without producing a sidecar file.
|
||||
self.file.flush()?;
|
||||
self.file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(self.active_path())?;
|
||||
self.bytes_written = 0;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.file.flush()?;
|
||||
|
||||
// Delete the oldest slot.
|
||||
let oldest = self.in_session_path(self.max_rotation - 1);
|
||||
if oldest.exists() {
|
||||
fs::remove_file(&oldest)?;
|
||||
}
|
||||
|
||||
// Shift remaining slots up: N-2 -> N-1, ..., 0 -> 1.
|
||||
for n in (0..self.max_rotation - 1).rev() {
|
||||
let src = self.in_session_path(n);
|
||||
if src.exists() {
|
||||
fs::rename(src, self.in_session_path(n + 1))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Move the active file to slot 0 and open a fresh active file.
|
||||
fs::rename(self.active_path(), self.in_session_path(0))?;
|
||||
self.file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(self.active_path())?;
|
||||
self.bytes_written = 0;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for RotatingFileWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
// Rotate only when the *current* active file already holds content;
|
||||
// otherwise an oversized first write would promote an empty file to
|
||||
// `.in_session.0` and burn a retention slot before any useful log
|
||||
// data exists. The oversized payload still lands in the active file,
|
||||
// and is preserved on the next real rotation.
|
||||
if !buf.is_empty()
|
||||
&& self.max_bytes > 0
|
||||
&& self.bytes_written > 0
|
||||
&& self.bytes_written.saturating_add(buf.len() as u64) > self.max_bytes
|
||||
{
|
||||
self.rotate()?;
|
||||
}
|
||||
let n = self.file.write(buf)?;
|
||||
self.bytes_written = self.bytes_written.saturating_add(n as u64);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps `file` in a [`RotatingFileWriter`] when `max_file_size_bytes` is
|
||||
/// `Some(_)` and non-zero. Otherwise returns the raw file boxed as a
|
||||
/// `Write` so callers can use a uniform target type.
|
||||
///
|
||||
/// The `file` argument is the already-opened active file at
|
||||
/// `<log_directory>/<base_file_name>`. When rotation is enabled we discard
|
||||
/// it and reopen via `RotatingFileWriter::open` so the rotation state
|
||||
/// owns the file descriptor.
|
||||
pub(crate) fn wrap_for_rotation(
|
||||
file: File,
|
||||
log_directory: &Path,
|
||||
base_file_name: &str,
|
||||
max_file_size_bytes: Option<u64>,
|
||||
max_rotation: usize,
|
||||
) -> io::Result<Box<dyn Write + Send + 'static>> {
|
||||
match max_file_size_bytes {
|
||||
Some(max_bytes) if max_bytes > 0 => {
|
||||
// The file passed in was opened with truncate=true by the caller;
|
||||
// we'll reopen via RotatingFileWriter::open which has the same
|
||||
// semantics. Drop the existing handle first to keep file
|
||||
// descriptors symmetric.
|
||||
drop(file);
|
||||
let writer = RotatingFileWriter::open(
|
||||
log_directory.to_path_buf(),
|
||||
base_file_name.to_string(),
|
||||
max_bytes,
|
||||
max_rotation,
|
||||
)?;
|
||||
Ok(Box::new(writer))
|
||||
}
|
||||
_ => Ok(Box::new(file)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rotation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::io::Read;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
let mut s = String::new();
|
||||
File::open(path).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_below_threshold_do_not_rotate() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 1024, 3).unwrap();
|
||||
w.write_all(b"hello world\n").unwrap();
|
||||
w.flush().unwrap();
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "hello world\n");
|
||||
assert!(!tmp.path().join("warp.log.in_session.0").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_threshold_rotates_to_in_session_zero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 16, 3).unwrap();
|
||||
w.write_all(b"first batch ").unwrap(); // 12 bytes
|
||||
w.write_all(b"more content").unwrap(); // crosses 16 → rotate before write
|
||||
w.flush().unwrap();
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.0")),
|
||||
"first batch "
|
||||
);
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "more content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_rotations_shift_slots_up() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 8, 3).unwrap();
|
||||
// Each write of ~10 bytes crosses the 8-byte threshold and triggers
|
||||
// a rotation before the write lands. So the *previous* batch becomes
|
||||
// .in_session.0 each time, shifting older slots up.
|
||||
w.write_all(b"aaaaaaaaa\n").unwrap(); // first write — no prior content, becomes active
|
||||
w.write_all(b"bbbbbbbbb\n").unwrap(); // rotates "aaa..." -> .0
|
||||
w.write_all(b"ccccccccc\n").unwrap(); // rotates "bbb..." -> .0, "aaa..." -> .1
|
||||
w.write_all(b"ddddddddd\n").unwrap(); // rotates "ccc..." -> .0, "bbb..." -> .1, "aaa..." -> .2
|
||||
w.flush().unwrap();
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "ddddddddd\n");
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.0")),
|
||||
"ccccccccc\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.1")),
|
||||
"bbbbbbbbb\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.2")),
|
||||
"aaaaaaaaa\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_drops_the_oldest_slot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 8, 2).unwrap();
|
||||
w.write_all(b"aaaaaaaaa\n").unwrap();
|
||||
w.write_all(b"bbbbbbbbb\n").unwrap(); // rotates -> .0 = aaa
|
||||
w.write_all(b"ccccccccc\n").unwrap(); // rotates -> .0 = bbb, .1 = aaa
|
||||
w.write_all(b"ddddddddd\n").unwrap(); // rotates -> .0 = ccc, .1 = bbb, aaa dropped
|
||||
w.flush().unwrap();
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "ddddddddd\n");
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.0")),
|
||||
"ccccccccc\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.1")),
|
||||
"bbbbbbbbb\n"
|
||||
);
|
||||
assert!(!tmp.path().join("warp.log.in_session.2").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_first_write_does_not_promote_empty_file_to_in_session_zero() {
|
||||
// Regression for the Oz nit on #11000: when the very first write
|
||||
// exceeds `max_bytes`, the rotator must NOT rename an empty active
|
||||
// file into `.in_session.0` — that would burn a retention slot
|
||||
// before any useful data exists. The oversized payload stays in the
|
||||
// active file and is promoted on the next real rotation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 8, 3).unwrap();
|
||||
w.write_all(b"oversized first payload\n").unwrap(); // 24 bytes, > 8
|
||||
w.flush().unwrap();
|
||||
assert!(!tmp.path().join("warp.log.in_session.0").exists());
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log")),
|
||||
"oversized first payload\n"
|
||||
);
|
||||
|
||||
// On the next write the (now-populated) active file rotates
|
||||
// normally and the oversized payload becomes `.in_session.0`.
|
||||
w.write_all(b"next\n").unwrap();
|
||||
w.flush().unwrap();
|
||||
assert_eq!(
|
||||
read(&tmp.path().join("warp.log.in_session.0")),
|
||||
"oversized first payload\n"
|
||||
);
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "next\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_rotation_truncates_in_place_without_sidecar() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 8, 0).unwrap();
|
||||
// First write skips rotation since the active file is still empty;
|
||||
// both batches land sequentially, and the second crosses the
|
||||
// threshold so the truncate-in-place branch fires.
|
||||
w.write_all(b"first batch\n").unwrap();
|
||||
w.write_all(b"second batch\n").unwrap();
|
||||
w.flush().unwrap();
|
||||
// With max_rotation=0, no .in_session.N file should ever exist.
|
||||
assert!(!tmp.path().join("warp.log.in_session.0").exists());
|
||||
// The active file holds only the most recent batch (older content
|
||||
// truncated since slot 0 is not retained).
|
||||
assert_eq!(read(&tmp.path().join("warp.log")), "second batch\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_bytes_disables_rotation_entirely() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut w = RotatingFileWriter::open(tmp.path(), "warp.log", 0, 3).unwrap();
|
||||
for _ in 0..100 {
|
||||
w.write_all(b"line\n").unwrap();
|
||||
}
|
||||
w.flush().unwrap();
|
||||
assert!(!tmp.path().join("warp.log.in_session.0").exists());
|
||||
assert_eq!(read(&tmp.path().join("warp.log")).len(), 500);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
// Vendored wasm-logger.
|
||||
// MIT License: Copyright (c) 2018 Limira
|
||||
use crate::LogConfig;
|
||||
use anyhow::Result;
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::console;
|
||||
|
||||
use crate::LogConfig;
|
||||
|
||||
/// Initializes the global logger for the application.
|
||||
/// Note: On WASM, `config` is ignored since we always log to the browser console.
|
||||
pub fn init(_config: LogConfig) -> Result<()> {
|
||||
@@ -188,7 +189,7 @@ impl Log for WasmLogger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Initialize the logger which the given config. If failed, it will log a message to the the browser console.
|
||||
/// Initialize the logger with the given config. If initialization fails, it will log a message to the browser console.
|
||||
fn init_logger(config: Config) {
|
||||
let max_level = config.level;
|
||||
let wl = WasmLogger {
|
||||
|
||||
Reference in New Issue
Block a user