From 880c98ecfb56ebc192388a1ee4f9d4f4c6ffd8e9 Mon Sep 17 00:00:00 2001 From: khyperia <953151+khyperia@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:44:34 +0100 Subject: [PATCH] Add clean-logs-older-than option --- book/src/configuration/misc.md | 26 +++++ book/src/troubleshooting.md | 3 + jay-config/src/_private/client.rs | 6 +- jay-config/src/_private/ipc.rs | 5 +- jay-config/src/logging.rs | 15 ++- src/compositor.rs | 2 + src/config/handler.rs | 7 +- src/logger.rs | 105 +++++++++++++++++- src/state.rs | 11 +- toml-config/src/config.rs | 1 + toml-config/src/config/parsers.rs | 1 + .../config/parsers/clean_logs_older_than.rs | 57 ++++++++++ toml-config/src/config/parsers/config.rs | 15 +++ toml-config/src/lib.rs | 9 +- toml-spec/spec/spec.generated.json | 21 ++++ toml-spec/spec/spec.generated.md | 48 ++++++++ toml-spec/spec/spec.yaml | 38 +++++++ 17 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 toml-config/src/config/parsers/clean_logs_older_than.rs diff --git a/book/src/configuration/misc.md b/book/src/configuration/misc.md index 1e90feb3..9974d3f7 100644 --- a/book/src/configuration/misc.md +++ b/book/src/configuration/misc.md @@ -111,6 +111,32 @@ instead: ~$ jay set-log-level debug ``` +## Log File Cleanup + +Jay creates a new log file each time it starts. Over time, old log files can +accumulate. To automatically delete old log files on startup, use the +`clean-logs-older-than` option: + +```toml +clean-logs-older-than.days = 7 +``` + +The table accepts `weeks` and `days` fields. At least one must be specified. +They can be combined and accept fractional values: + +```toml +[clean-logs-older-than] +weeks = 2 +days = 3 +``` + +Log files belonging to other running Jay instances (e.g. on another VT) are +never deleted, even if they are older than the specified age. + +> [!NOTE] +> This setting only takes effect at compositor startup. It cannot be triggered +> by reloading the configuration. + ## Focus Follows Mouse When enabled, moving the pointer over a window automatically gives it keyboard diff --git a/book/src/troubleshooting.md b/book/src/troubleshooting.md index ea498a72..3f973bb5 100644 --- a/book/src/troubleshooting.md +++ b/book/src/troubleshooting.md @@ -266,6 +266,9 @@ log-level = "debug" > The `log-level` config setting is read at startup and cannot be changed by > reloading the configuration. Use `jay set-log-level` for runtime changes. +To automatically clean up old log files, see +[Log File Cleanup](configuration/misc.md#log-file-cleanup). + ## Performance issues If you experience dropped frames, stuttering, or high latency, try the diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 8c434cb1..61dfbf2f 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -60,7 +60,7 @@ use { atomic::{AtomicBool, Ordering::Relaxed}, }, task::{Context, Poll, Waker}, - time::Duration, + time::{Duration, SystemTime}, }, }; @@ -776,6 +776,10 @@ impl ConfigClient { self.send(&ClientMessage::SetLogLevel { level }) } + pub fn clean_logs_older_than(&self, time: SystemTime) { + self.send(&ClientMessage::CleanLogsOlderThan { time }) + } + pub fn unset_env(&self, key: &str) { self.send(&ClientMessage::UnsetEnv { key }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 3487333b..618332f6 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -21,7 +21,7 @@ use { xwayland::XScalingMode, }, serde::{Deserialize, Serialize}, - std::time::Duration, + std::time::{Duration, SystemTime}, }; #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -861,6 +861,9 @@ pub enum ClientMessage<'a> { SeatWarpMouseToFocus { seat: Seat, }, + CleanLogsOlderThan { + time: SystemTime, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/logging.rs b/jay-config/src/logging.rs index 1f37aa46..ab7f8c3e 100644 --- a/jay-config/src/logging.rs +++ b/jay-config/src/logging.rs @@ -3,7 +3,10 @@ //! Note that you can use the `log` crate for logging. All invocations of `log::info` etc. //! automatically log into the compositors log. -use serde::{Deserialize, Serialize}; +use { + serde::{Deserialize, Serialize}, + std::time::SystemTime, +}; /// The log level of the compositor or a log message. #[derive(Serialize, Deserialize, Copy, Clone, Debug)] @@ -19,3 +22,13 @@ pub enum LogLevel { pub fn set_log_level(level: LogLevel) { get!().set_log_level(level); } + +/// If this function is called during startup, Jay's log files before `time` are deleted. +/// +/// The current log file is never deleted, nor are any other logfiles of active Jay instances (e.g. +/// on another VT), even if `time` is in the future. +/// +/// Calling this function after startup has no effect. +pub fn clean_logs_older_than(time: SystemTime) { + get!().clean_logs_older_than(time); +} diff --git a/src/compositor.rs b/src/compositor.rs index c1bc089d..c5345459 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -397,6 +397,7 @@ fn start_compositor2( egg_state: Default::default(), control_centers: Default::default(), virtual_outputs: Default::default(), + clean_logs_older_than: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); @@ -457,6 +458,7 @@ async fn start_compositor3(state: Rc, test_future: Option) { if state.create_default_seat.get() && state.globals.seats.is_empty() { state.create_seat(DEFAULT_SEAT_NAME); } + state.perform_clean_logs_older_than(); state.update_ei_acceptor(); let _geh = start_global_event_handlers(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 3eadc619..668176da 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -86,7 +86,7 @@ use { hash::Hash, ops::Deref, rc::{Rc, Weak}, - time::Duration, + time::{Duration, SystemTime}, }, thiserror::Error, uapi::{OwnedFd, c, fcntl_dupfd_cloexec}, @@ -1856,6 +1856,10 @@ impl ConfigProxyHandler { self.state.set_log_level(level.into()); } + fn handle_clean_logs_older_than(&self, time: SystemTime) { + self.state.clean_logs_older_than.set(Some(time)); + } + fn handle_grab(&self, kb: InputDevice, grab: bool) -> Result<(), CphError> { let kb = self.get_kb(kb)?; kb.grab(grab); @@ -3376,6 +3380,7 @@ impl ConfigProxyHandler { ClientMessage::GetConnectorByName { name } => self.handle_get_connector_by_name(name), ClientMessage::CreateVirtualOutput { name } => self.handle_create_virtual_output(name), ClientMessage::RemoveVirtualOutput { name } => self.handle_remove_virtual_output(name), + ClientMessage::CleanLogsOlderThan { time } => self.handle_clean_logs_older_than(time), } Ok(()) } diff --git a/src/logger.rs b/src/logger.rs index a344abbd..1c2aeddc 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -4,7 +4,7 @@ use { utils::{atomic_enum::AtomicEnum, errorfmt::ErrorFmt, oserror::OsError}, }, backtrace::Backtrace, - bstr::BString, + bstr::{BStr, BString, ByteSlice}, log::{LevelFilter, Log, Metadata, Record}, parking_lot::Mutex, std::{ @@ -17,9 +17,11 @@ use { Arc, atomic::{AtomicI32, AtomicU32, Ordering::Relaxed}, }, + thread, time::SystemTime, }, - uapi::{Errno, Fd, OwnedFd, Ustring, c, format_ustr}, + thiserror::Error, + uapi::{AsUstr, Dirent, Errno, Fd, OwnedFd, Ustring, c, format_ustr}, }; thread_local! { @@ -80,6 +82,17 @@ impl Logger { log::set_max_level(filter); } + pub fn clean_logs_older_than(&self, time: SystemTime) { + let time_formatted = humantime::format_rfc3339_millis(time); + log::info!("Cleaning unused log files older than {}", time_formatted); + let path = self.path(); + thread::spawn(move || { + if let Err(e) = clean_logs_older_than(path.as_bstr(), time) { + log::error!("Could not clean log files: {}", ErrorFmt(e)); + } + }); + } + pub fn level(&self) -> LogLevel { self.level.load(Relaxed) } @@ -105,6 +118,7 @@ impl Logger { pub fn open_log_file(ty: &str) -> (Ustring, OwnedFd) { let log_dir = create_log_dir(ty); + let mut flock_fail_count = 0; for i in 0.. { let file_name = format_ustr!( "{}/{ty}-{}-{}.txt", @@ -117,7 +131,22 @@ pub fn open_log_file(ty: &str) -> (Ustring, OwnedFd) { c::O_CREAT | c::O_EXCL | c::O_CLOEXEC | c::O_WRONLY, 0o644, ) { - Ok(f) => return (file_name, f), + Ok(f) => { + if let Err(e) = uapi::flock(f.raw(), c::LOCK_EX | c::LOCK_NB) { + log::warn!("Unable to flock just-opened logfile: {}", ErrorFmt(e)); + flock_fail_count += 1; + if flock_fail_count > 10 { + log::error!(concat!( + "Failed to flock just-opened logfile more than 10 times in a row. ", + "Not flocking the logfile, if the cleanup routine later succeeds to ", + "flock this logfile, it will be deleted even if it is still in use." + )); + } else { + continue; + } + } + return (file_name, f); + } Err(Errno(c::EEXIST)) => {} Err(e) => { let e: OsError = e.into(); @@ -209,3 +238,73 @@ impl Log for LogWrapper { // nothing } } + +#[derive(Debug, Error)] +enum CleanLogsError { + #[error("Log path has no parent")] + NoParent, + #[error("Could not open the log directory")] + OpenDir(#[source] OsError), + #[error("Could not enumerate directory entry")] + ReadDir(#[source] OsError), + #[error("Could not open the log file")] + OpenFile(#[source] OsError), + #[error("Could not stat the log file")] + Stat(#[source] OsError), + #[error("Could not unlink the log file")] + Unlink(#[source] OsError), +} + +fn clean_logs_older_than(current_log_path: &BStr, time: SystemTime) -> Result<(), CleanLogsError> { + let current_log_path = current_log_path.to_path_lossy(); + let parent = current_log_path.parent().ok_or(CleanLogsError::NoParent)?; + let mut dir = uapi::opendir(parent) + .map_err(Into::into) + .map_err(CleanLogsError::OpenDir)?; + let parent = uapi::open(parent, c::O_PATH | c::O_CLOEXEC | c::O_DIRECTORY, 0) + .map_err(Into::into) + .map_err(CleanLogsError::OpenDir)?; + let time = time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as c::time_t; + while let Some(entry) = uapi::readdir(&mut dir) { + let entry = entry.map_err(Into::into).map_err(CleanLogsError::ReadDir)?; + if let Err(err) = process_entry(parent.raw(), &entry, time) { + log::error!( + "Could not clean log file {}: {}", + entry.name().as_ustr().display(), + ErrorFmt(err), + ); + } + } + fn process_entry( + parent: c::c_int, + entry: &Dirent, + time: c::time_t, + ) -> Result<(), CleanLogsError> { + if entry.d_type != c::DT_REG { + return Ok(()); + } + let name = entry.name(); + let file = uapi::openat(parent, name, c::O_RDONLY | c::O_CLOEXEC, 0) + .map_err(Into::into) + .map_err(CleanLogsError::OpenFile)?; + let stat = uapi::fstat(*file) + .map_err(Into::into) + .map_err(CleanLogsError::Stat)?; + if stat.st_mtime >= time { + return Ok(()); + } + if uapi::flock(file.raw(), c::LOCK_EX | c::LOCK_NB).is_err() { + log::info!("Preserving file still in use: {}", name.as_ustr().display()); + return Ok(()); + } + uapi::unlinkat(parent, name, 0) + .map_err(Into::into) + .map_err(CleanLogsError::Unlink)?; + log::info!("Deleted {}", name.as_ustr().display()); + Ok(()) + } + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index 849097b8..25aceac0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -150,7 +150,7 @@ use { ops::{Deref, DerefMut}, rc::{Rc, Weak}, sync::Arc, - time::Duration, + time::{Duration, SystemTime}, }, thiserror::Error, uapi::{OwnedFd, c}, @@ -305,6 +305,7 @@ pub struct State { pub egg_state: EggState, pub control_centers: ControlCenters, pub virtual_outputs: VirtualOutputs, + pub clean_logs_older_than: Cell>, } // impl Drop for State { @@ -1760,6 +1761,14 @@ impl State { } } + pub fn perform_clean_logs_older_than(&self) { + if let Some(time) = self.clean_logs_older_than.get() + && let Some(logger) = &self.logger + { + logger.clean_logs_older_than(time); + } + } + fn colors_changed(&self) { struct V; impl NodeVisitorBase for V { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 1caa117b..6dabbed5 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -523,6 +523,7 @@ pub struct Config { pub keymaps: Vec, pub auto_reload: Option, pub log_level: Option, + pub clean_logs_older_than: Option, pub theme: Theme, pub egui: Egui, pub gfx_api: Option, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 27a4ea8a..448c26b4 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -9,6 +9,7 @@ use { pub mod action; mod actions; mod capabilities; +mod clean_logs_older_than; mod client_match; mod client_rule; mod color; diff --git a/toml-config/src/config/parsers/clean_logs_older_than.rs b/toml-config/src/config/parsers/clean_logs_older_than.rs new file mode 100644 index 00000000..0c3bb4a7 --- /dev/null +++ b/toml-config/src/config/parsers/clean_logs_older_than.rs @@ -0,0 +1,57 @@ +use { + crate::{ + config::{ + context::Context, + extractor::{Extractor, ExtractorError, fltorint, opt}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned, SpannedExt}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + std::time::{Duration, TryFromFloatSecsError}, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum CleanLogsOlderThanParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error("At least one of the `weeks` or `days` fields must be specified")] + WeeksOrDays, + #[error("Duration is invalid")] + InvalidDuration(#[source] TryFromFloatSecsError), +} + +pub struct CleanLogsOlderThanParser<'a>(pub &'a Context<'a>); + +impl Parser for CleanLogsOlderThanParser<'_> { + type Value = Duration; + type Error = CleanLogsOlderThanParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (weeks, days) = ext.extract((opt(fltorint("weeks")), opt(fltorint("days"))))?; + if weeks.is_none() && days.is_none() { + return Err(CleanLogsOlderThanParserError::WeeksOrDays.spanned(span)); + } + const SECONDS_IN_WEEK: f64 = 604800.0; + const SECONDS_IN_DAY: f64 = 86400.0; + let duration = Duration::try_from_secs_f64( + weeks.despan().unwrap_or_default() * SECONDS_IN_WEEK + + days.despan().unwrap_or_default() * SECONDS_IN_DAY, + ) + .map_err(CleanLogsOlderThanParserError::InvalidDuration) + .map_err(|e| e.spanned(span))?; + Ok(duration) + } +} diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 1f0651b2..c8dd76dd 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -8,6 +8,7 @@ use { parsers::{ action::ActionParser, actions::ActionsParser, + clean_logs_older_than::CleanLogsOlderThanParser, client_rule::ClientRulesParser, color_management::ColorManagementParser, connector::ConnectorsParser, @@ -152,6 +153,7 @@ impl Parser for ConfigParser<'_> { show_titles, fallback_output_mode_val, egui_val, + clean_logs_older_than_val, ), ) = ext.extract(( ( @@ -211,6 +213,7 @@ impl Parser for ConfigParser<'_> { recover(opt(bol("show-titles"))), opt(val("fallback-output-mode")), opt(val("egui")), + opt(val("clean-logs-older-than")), ), ))?; let mut keymap = None; @@ -307,6 +310,17 @@ impl Parser for ConfigParser<'_> { } } } + let mut clean_logs_older_than = None; + if let Some(value) = clean_logs_older_than_val { + match value.parse(&mut CleanLogsOlderThanParser(self.0)) { + Ok(v) => { + clean_logs_older_than = Some(v); + } + Err(e) => { + log::warn!("Could not parse clean-logs-older-than: {}", self.0.error(e)); + } + } + } let mut theme = Theme::default(); if let Some(value) = theme_val { match value.parse(&mut ThemeParser(self.0)) { @@ -567,6 +581,7 @@ impl Parser for ConfigParser<'_> { keymaps, auto_reload: auto_reload.despan(), log_level, + clean_logs_older_than, theme, egui, gfx_api, diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 87fe1689..11a3e2f8 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -35,7 +35,7 @@ use { io::Async, is_reload, keyboard::Keymap, - logging::set_log_level, + logging::{clean_logs_older_than, set_log_level}, on_devices_enumerated, on_idle, on_unload, open_control_center, quit, reload, set_color_management_enabled, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_idle, set_idle_grace_period, @@ -67,7 +67,7 @@ use { os::{fd::AsRawFd, unix::ffi::OsStrExt}, path::{Path, PathBuf}, rc::Rc, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }, uapi::{ Errno, @@ -1494,6 +1494,11 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc +### `CleanLogsOlderThan` + +The definition of how old logfiles need to be for them to be automatically deleted. + +Omitted values are set to 0. At least one of `weeks` or `days` must be specified. + +- Example: + + ```toml + clean-logs-older-than.weeks = 2 + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `weeks` (optional): + + The number of weeks. + + The value of this field should be a number. + + The numbers should be greater than or equal to 0. + +- `days` (optional): + + The number of days. + + The value of this field should be a number. + + The numbers should be greater than or equal to 0. + + ### `ClickMethod` @@ -1836,6 +1870,20 @@ The table has the following fields: The value of this field should be a [LogLevel](#types-LogLevel). +- `clean-logs-older-than` (optional): + + If specified on startup, deletes Jay's log files older than the specified time period. + + Possible keys in the table are `days` and `weeks`. + + - Example: + + ```toml + clean-logs-older-than.days = 7 + ``` + + The value of this field should be a [CleanLogsOlderThan](#types-CleanLogsOlderThan). + - `theme` (optional): Sets the theme of the compositor. diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 8faedec1..b31b9a4d 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2680,6 +2680,19 @@ Config: ```toml log-level = "debug" ``` + clean-logs-older-than: + ref: CleanLogsOlderThan + required: false + description: | + If specified on startup, deletes Jay's log files older than the specified time period. + + Possible keys in the table are `days` and `weeks`. + + - Example: + + ```toml + clean-logs-older-than.days = 7 + ``` theme: ref: Theme required: false @@ -3218,6 +3231,31 @@ GracePeriod: required: false +CleanLogsOlderThan: + kind: table + description: | + The definition of how old logfiles need to be for them to be automatically deleted. + + Omitted values are set to 0. At least one of `weeks` or `days` must be specified. + + - Example: + + ```toml + clean-logs-older-than.weeks = 2 + ``` + fields: + weeks: + description: The number of weeks. + kind: number + minimum: 0 + required: false + days: + description: The number of days. + kind: number + minimum: 0 + required: false + + RepeatRate: kind: table description: |