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
+3
View File
@@ -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 = []
+11 -2
View File
@@ -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,
+214 -35
View File
@@ -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;
+224
View File
@@ -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());
}
+177
View 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;
+138
View File
@@ -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);
}
+3 -2
View File
@@ -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 {