From 151590422d31b28e7dda8994be59a8315a1210be Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Fri, 14 Aug 2026 18:43:54 +0200 Subject: [PATCH] v0.1.2-pre.004 --- Cargo.toml | 5 +- crates/ksp-logging-lib/Cargo.toml | 3 +- crates/ksp-logging-lib/src/error.rs | 4 +- crates/ksp-logging-lib/src/lib.rs | 13 +- crates/ksp-logging-lib/src/runtime.rs | 209 ++++++++++-- crates/ksp-logging-lib/src/writer.rs | 105 ++++++ crates/ksp-logging-lib/tests/public_api.rs | 7 +- crates/ksp-logging-lib/tests/runtime.rs | 94 +++++- crates/ksp-logging-lib/unit_tests/runtime.rs | 57 ++-- crates/ksp-logging-lib/unit_tests/writer.rs | 30 ++ deltas/0.1.2/pre.004.md | 301 ++++++++++++++++++ .../004-V0_1_2_LOGGING_FOUNDATION_PLAN.md | 66 ++-- 12 files changed, 803 insertions(+), 91 deletions(-) create mode 100644 crates/ksp-logging-lib/src/writer.rs create mode 100644 crates/ksp-logging-lib/unit_tests/writer.rs create mode 100644 deltas/0.1.2/pre.004.md diff --git a/Cargo.toml b/Cargo.toml index 6e97005..07f620a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 30 +# version: 31 [workspace] resolver = "3" members = ["crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.2-pre.3.fix.1" +version = "0.1.2-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -17,6 +17,7 @@ publish = false solana-pubkey = { version = "^4.3", default-features = false } tracing = { version = "^0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] } +tracing-appender = { version = "^0.2", default-features = false } [workspace.lints.rust] missing_docs = "warn" diff --git a/crates/ksp-logging-lib/Cargo.toml b/crates/ksp-logging-lib/Cargo.toml index a395e65..211fce2 100644 --- a/crates/ksp-logging-lib/Cargo.toml +++ b/crates/ksp-logging-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-logging-lib/Cargo.toml -# version: 2 +# version: 3 [package] name = "ksp-logging-lib" @@ -11,6 +11,7 @@ repository.workspace = true ksp-core-lib = { path = "../ksp-core-lib" } tracing.workspace = true tracing-subscriber.workspace = true +tracing-appender.workspace = true [lints] workspace = true diff --git a/crates/ksp-logging-lib/src/error.rs b/crates/ksp-logging-lib/src/error.rs index 781b999..c440309 100644 --- a/crates/ksp-logging-lib/src/error.rs +++ b/crates/ksp-logging-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/src/error.rs -// version: 2 +// version: 3 /// Error code used when runtime logging settings are invalid. pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "invalid_settings"); @@ -7,3 +7,5 @@ pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::E pub const ERROR_CODE_ALREADY_INITIALIZED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "already_initialized"); /// Error code used when a hot reload cannot replace the active runtime layers. pub const ERROR_CODE_RELOAD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "reload_failed"); +/// Error code used when the rolling file output cannot be initialized. +pub const ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("logging", "file_output_initialization_failed"); diff --git a/crates/ksp-logging-lib/src/lib.rs b/crates/ksp-logging-lib/src/lib.rs index dd42616..2d0a25e 100644 --- a/crates/ksp-logging-lib/src/lib.rs +++ b/crates/ksp-logging-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/src/lib.rs -// version: 2 +// version: 3 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -7,22 +7,27 @@ //! KSP-owned logging and tracing facade. //! //! This crate owns the KSP runtime logging contract. Behavioral KSP crates emit events and spans through this facade rather than depending directly on the -//! `tracing` stack. `0.1.2-pre.003` installs the single global subscriber, applies KSP takeover filtering, provides the initial console layer and establishes -//! hot reload. Non-blocking writers, file output, ANSI stripping and writer guards are completed by the following prerelease. +//! `tracing` stack. `0.1.2-pre.004` owns the single global subscriber, KSP takeover filtering, hot reload, non-blocking console/file outputs, rolling file +//! appenders, ANSI stripping, dropped-line counters and the worker guards required to flush active queues. mod error; mod macros; mod runtime; mod settings; mod span; +mod writer; /// Error code used when a global logging subscriber is already installed. pub use self::error::ERROR_CODE_ALREADY_INITIALIZED; +/// Error code used when the rolling file output cannot be initialized. +pub use self::error::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED; /// Error code used when runtime logging settings are invalid. pub use self::error::ERROR_CODE_INVALID_SETTINGS; /// Error code used when a hot reload cannot replace the active runtime layers. pub use self::error::ERROR_CODE_RELOAD_FAILED; -/// Guard owning the mutable runtime state of the installed KSP logging subscriber. +/// Cumulative number of log lines dropped by non-blocking KSP outputs. +pub use self::runtime::DroppedLines; +/// Guard owning the mutable runtime state and non-blocking writers of the installed KSP logging subscriber. pub use self::runtime::LoggingGuard; /// Installs the global KSP tracing subscriber. pub use self::runtime::initialize; diff --git a/crates/ksp-logging-lib/src/runtime.rs b/crates/ksp-logging-lib/src/runtime.rs index 3e84caa..beb140d 100644 --- a/crates/ksp-logging-lib/src/runtime.rs +++ b/crates/ksp-logging-lib/src/runtime.rs @@ -1,13 +1,52 @@ // file: crates/ksp-logging-lib/src/runtime.rs -// version: 2 +// version: 3 use tracing_subscriber::Layer; // rust-rules: trait-import use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import -/// Guard owning the mutable runtime state of the installed KSP logging subscriber. +/// Cumulative number of log lines dropped by non-blocking KSP outputs. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct DroppedLines { + console: usize, + file: usize, +} + +impl DroppedLines { + /// Returns an empty dropped-line snapshot. + #[must_use] + pub const fn zero() -> Self { + return Self { console: 0, file: 0 }; + } + + /// Returns the number of console lines dropped since Logging initialization. + #[must_use] + pub const fn console(&self) -> usize { + return self.console; + } + + /// Returns the number of file lines dropped since Logging initialization. + #[must_use] + pub const fn file(&self) -> usize { + return self.file; + } + + /// Returns the total number of dropped lines across console and file outputs. + #[must_use] + pub const fn total(&self) -> usize { + return self.console.saturating_add(self.file); + } + + const fn saturating_add(self, other: Self) -> Self { + return Self { console: self.console.saturating_add(other.console), file: self.file.saturating_add(other.file) }; + } +} + +/// Guard owning the mutable runtime state and non-blocking writers of the installed KSP logging subscriber. pub struct LoggingGuard { reload_handle: RuntimeReloadHandle, settings: crate::LoggingSettings, + outputs: RuntimeOutputs, + retired_dropped_lines: crate::DroppedLines, } impl LoggingGuard { @@ -16,23 +55,76 @@ impl LoggingGuard { pub fn settings(&self) -> &crate::LoggingSettings { return &self.settings; } + + /// Returns cumulative dropped-line counters across active and previously reloaded outputs. + #[must_use] + pub fn dropped_lines(&self) -> crate::DroppedLines { + return self.retired_dropped_lines.saturating_add(self.outputs.dropped_lines()); + } } type BoxedRuntimeLayer = std::boxed::Box + std::marker::Send + std::marker::Sync + 'static>; type RuntimeLayers = std::vec::Vec; type RuntimeReloadHandle = tracing_subscriber::reload::Handle; +struct PreparedRuntime { + layers: RuntimeLayers, + outputs: RuntimeOutputs, +} + +#[derive(Default)] +struct RuntimeOutputs { + console: std::option::Option, + file: std::option::Option, +} + +impl RuntimeOutputs { + fn dropped_lines(&self) -> crate::DroppedLines { + let console = match self.console.as_ref() { + std::option::Option::Some(output) => output.dropped_lines(), + std::option::Option::None => 0, + }; + let file = match self.file.as_ref() { + std::option::Option::Some(output) => output.dropped_lines(), + std::option::Option::None => 0, + }; + return crate::DroppedLines { console, file }; + } +} + +struct RuntimeOutput { + _worker_guard: tracing_appender::non_blocking::WorkerGuard, + error_counter: tracing_appender::non_blocking::ErrorCounter, +} + +impl RuntimeOutput { + fn dropped_lines(&self) -> usize { + return self.error_counter.dropped_lines(); + } +} + +struct PreparedOutput { + layer: BoxedRuntimeLayer, + output: RuntimeOutput, +} + /// Installs the global KSP tracing subscriber. /// -/// This function may succeed only once for the lifetime of the process. The returned guard is then used by [`crate::reinitialize`] to replace the active -/// KSP logging configuration without installing a second global subscriber. +/// This function may succeed only once for the lifetime of the process. The returned guard owns all non-blocking writer guards and is then used by +/// [`crate::reinitialize`] to replace the active KSP logging configuration without installing a second global subscriber. pub fn initialize(settings: &crate::LoggingSettings) -> ksp_core_lib::Result { - return prepare_runtime_layers(settings).and_then(|runtime_layers| -> ksp_core_lib::Result { - let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(runtime_layers); + return prepare_runtime(settings).and_then(|prepared| -> ksp_core_lib::Result { + let PreparedRuntime { layers, outputs } = prepared; + let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(layers); let subscriber = tracing_subscriber::registry().with(reload_layer); let install_result = tracing::subscriber::set_global_default(subscriber); return match install_result { - std::result::Result::Ok(()) => std::result::Result::Ok(crate::LoggingGuard { reload_handle, settings: settings.clone() }), + std::result::Result::Ok(()) => std::result::Result::Ok(crate::LoggingGuard { + reload_handle, + settings: settings.clone(), + outputs, + retired_dropped_lines: crate::DroppedLines::zero(), + }), std::result::Result::Err(error) => std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error), ), @@ -40,16 +132,21 @@ pub fn initialize(settings: &crate::LoggingSettings) -> ksp_core_lib::Result ksp_core_lib::Result<()> { - return prepare_runtime_layers(settings).and_then(|runtime_layers| -> ksp_core_lib::Result<()> { - let reload_result = guard.reload_handle.reload(runtime_layers); + return prepare_runtime(settings).and_then(|prepared| -> ksp_core_lib::Result<()> { + let PreparedRuntime { layers, outputs } = prepared; + let reload_result = guard.reload_handle.reload(layers); return match reload_result { std::result::Result::Ok(()) => { + guard.retired_dropped_lines = guard.retired_dropped_lines.saturating_add(guard.outputs.dropped_lines()); + let retired_outputs = std::mem::replace(&mut guard.outputs, outputs); guard.settings = settings.clone(); + drop(retired_outputs); std::result::Result::Ok(()) }, std::result::Result::Err(error) => std::result::Result::Err( @@ -59,38 +156,84 @@ pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSe }); } -fn prepare_runtime_layers(settings: &crate::LoggingSettings) -> ksp_core_lib::Result { +fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result { let validation_error = settings.validate().err(); if let std::option::Option::Some(error) = validation_error { return std::result::Result::Err(error); } - if settings.file().is_some() { - return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output is not available until the file backend is introduced") - .with_context("field", "file"), - ); + if settings.console().is_none() && settings.file().is_none() { + return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs: RuntimeOutputs::default() }); } + let prepared_file = match settings.file() { + std::option::Option::Some(file) => match build_file_output(file, settings) { + std::result::Result::Ok(output) => std::option::Option::Some(output), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }, + std::option::Option::None => std::option::Option::None, + }; + let prepared_console = settings.console().map(|console| -> PreparedOutput { + return build_console_output(console, settings); + }); let mut layers = RuntimeLayers::new(); - if let std::option::Option::Some(console) = settings.console() { - let takeover_filter: BoxedRuntimeLayer = std::boxed::Box::new(build_target_filter(settings)); - layers.push(takeover_filter); - layers.push(build_console_layer(console, settings)); + layers.push(std::boxed::Box::new(build_target_filter(settings))); + let mut outputs = RuntimeOutputs::default(); + if let std::option::Option::Some(console) = prepared_console { + layers.push(console.layer); + outputs.console = std::option::Option::Some(console.output); } - return std::result::Result::Ok(layers); + if let std::option::Option::Some(file) = prepared_file { + layers.push(file.layer); + outputs.file = std::option::Option::Some(file.output); + } + return std::result::Result::Ok(PreparedRuntime { layers, outputs }); } -fn build_console_layer(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> BoxedRuntimeLayer { - let writer = match console.output() { - crate::ConsoleOutput::Stdout => tracing_subscriber::fmt::writer::BoxMakeWriter::new(std::io::stdout), - crate::ConsoleOutput::Stderr => tracing_subscriber::fmt::writer::BoxMakeWriter::new(std::io::stderr), +fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput { + return match console.output() { + crate::ConsoleOutput::Stdout => build_non_blocking_output(std::io::stdout(), "ksp-logging-console", settings), + crate::ConsoleOutput::Stderr => build_non_blocking_output(std::io::stderr(), "ksp-logging-console", settings), }; - let layer = tracing_subscriber::fmt::layer() +} + +fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettings) -> ksp_core_lib::Result { + let appender_result = tracing_appender::rolling::RollingFileAppender::builder() + .rotation(map_file_rotation(file.rotation())) + .filename_prefix(file.file_name_prefix()) + .build(file.directory()); + let appender = match appender_result { + std::result::Result::Ok(appender) => appender, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED, "unable to initialize the KSP rolling file appender") + .with_context("directory", file.directory().display().to_string()) + .with_context("file_name_prefix", file.file_name_prefix()) + .with_source(error), + ); + }, + }; + let stripped_writer = crate::writer::StripAnsiWriter::new(appender); + return std::result::Result::Ok(build_non_blocking_output(stripped_writer, "ksp-logging-file", settings)); +} + +fn build_non_blocking_output(writer: W, thread_name: &str, settings: &crate::LoggingSettings) -> PreparedOutput +where + W: std::io::Write + std::marker::Send + 'static, +{ + let (non_blocking, worker_guard) = tracing_appender::non_blocking::NonBlockingBuilder::default().lossy(true).thread_name(thread_name).finish(writer); + let error_counter = non_blocking.error_counter(); + let layer = build_format_layer(non_blocking, settings); + return PreparedOutput { layer, output: RuntimeOutput { _worker_guard: worker_guard, error_counter } }; +} + +fn build_format_layer(writer: tracing_appender::non_blocking::NonBlocking, settings: &crate::LoggingSettings) -> BoxedRuntimeLayer { + return tracing_subscriber::fmt::layer() .with_writer(writer) .with_ansi(false) .with_target(true) + .with_file(true) + .with_line_number(true) .with_span_events(map_span_events(settings.span_events())) .boxed(); - return layer; } fn build_target_filter(settings: &crate::LoggingSettings) -> tracing_subscriber::filter::Targets { @@ -114,6 +257,14 @@ const fn map_filter_level(level: crate::LogFilterLevel) -> tracing_subscriber::f }; } +const fn map_file_rotation(rotation: crate::FileRotation) -> tracing_appender::rolling::Rotation { + return match rotation { + crate::FileRotation::Never => tracing_appender::rolling::Rotation::NEVER, + crate::FileRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY, + crate::FileRotation::Daily => tracing_appender::rolling::Rotation::DAILY, + }; +} + fn map_span_events(span_events: crate::SpanEvents) -> tracing_subscriber::fmt::format::FmtSpan { return match span_events { crate::SpanEvents::Off => tracing_subscriber::fmt::format::FmtSpan::NONE, diff --git a/crates/ksp-logging-lib/src/writer.rs b/crates/ksp-logging-lib/src/writer.rs new file mode 100644 index 0000000..a93def7 --- /dev/null +++ b/crates/ksp-logging-lib/src/writer.rs @@ -0,0 +1,105 @@ +// file: crates/ksp-logging-lib/src/writer.rs +// version: 1 + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StripAnsiState { + Text, + Escape, + Csi, + Osc, + OscEscape, + String, + StringEscape, +} + +pub(crate) struct StripAnsiWriter { + inner: W, + state: StripAnsiState, +} + +impl StripAnsiWriter { + pub(crate) const fn new(inner: W) -> Self { + return Self { inner, state: StripAnsiState::Text }; + } + + #[cfg(test)] + fn into_inner(self) -> W { + return self.inner; + } +} + +impl std::io::Write for StripAnsiWriter +where + W: std::io::Write, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut stripped = std::vec::Vec::with_capacity(buf.len()); + for byte in buf { + self.consume_byte(*byte, &mut stripped); + } + let write_result = std::io::Write::write_all(&mut self.inner, stripped.as_slice()); + return match write_result { + std::result::Result::Ok(()) => std::result::Result::Ok(buf.len()), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + fn flush(&mut self) -> std::io::Result<()> { + return std::io::Write::flush(&mut self.inner); + } +} + +impl StripAnsiWriter { + fn consume_byte(&mut self, byte: u8, output: &mut std::vec::Vec) { + self.state = match self.state { + StripAnsiState::Text => { + if byte == 0x1B { + StripAnsiState::Escape + } else { + output.push(byte); + StripAnsiState::Text + } + }, + StripAnsiState::Escape => match byte { + b'[' => StripAnsiState::Csi, + b']' => StripAnsiState::Osc, + b'P' | b'X' | b'^' | b'_' => StripAnsiState::String, + 0x1B => StripAnsiState::Escape, + _ => StripAnsiState::Text, + }, + StripAnsiState::Csi => { + if (0x40..=0x7E).contains(&byte) { + StripAnsiState::Text + } else { + StripAnsiState::Csi + } + }, + StripAnsiState::Osc => match byte { + 0x07 => StripAnsiState::Text, + 0x1B => StripAnsiState::OscEscape, + _ => StripAnsiState::Osc, + }, + StripAnsiState::OscEscape => match byte { + b'\\' => StripAnsiState::Text, + 0x1B => StripAnsiState::OscEscape, + _ => StripAnsiState::Osc, + }, + StripAnsiState::String => { + if byte == 0x1B { + StripAnsiState::StringEscape + } else { + StripAnsiState::String + } + }, + StripAnsiState::StringEscape => match byte { + b'\\' => StripAnsiState::Text, + 0x1B => StripAnsiState::StringEscape, + _ => StripAnsiState::String, + }, + }; + } +} + +#[cfg(test)] +#[path = "../unit_tests/writer.rs"] +mod tests; diff --git a/crates/ksp-logging-lib/tests/public_api.rs b/crates/ksp-logging-lib/tests/public_api.rs index 24166eb..fe25f62 100644 --- a/crates/ksp-logging-lib/tests/public_api.rs +++ b/crates/ksp-logging-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/tests/public_api.rs -// version: 3 +// version: 4 //! Integration tests for the public crate-root surface of `ksp-logging-lib`. @@ -59,4 +59,9 @@ fn public_runtime_surface_is_addressable_without_installing_it() { let _reinitialize = ksp_logging_lib::reinitialize; let _already_initialized = ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED; let _reload_failed = ksp_logging_lib::ERROR_CODE_RELOAD_FAILED; + let _file_initialization_failed = ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED; + let dropped = ksp_logging_lib::DroppedLines::zero(); + assert_eq!(dropped.console(), 0); + assert_eq!(dropped.file(), 0); + assert_eq!(dropped.total(), 0); } diff --git a/crates/ksp-logging-lib/tests/runtime.rs b/crates/ksp-logging-lib/tests/runtime.rs index aa19810..9d2f69e 100644 --- a/crates/ksp-logging-lib/tests/runtime.rs +++ b/crates/ksp-logging-lib/tests/runtime.rs @@ -1,7 +1,7 @@ // file: crates/ksp-logging-lib/tests/runtime.rs -// version: 1 +// version: 2 -//! Integration tests for global initialization, takeover filtering and hot reload. +//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload. const LOGGING_TARGET: &str = "ksp-logging-lib"; const OTHER_KSP_TARGET: &str = "ksp-store-lib"; @@ -23,8 +23,49 @@ fn external_error_enabled() -> bool { return tracing::enabled!(target: EXTERNAL_TARGET, tracing::Level::ERROR); } +fn test_root_directory() -> std::path::PathBuf { + return std::env::temp_dir().join(format!("ksp-logging-lib-runtime-{}", std::process::id())); +} + +fn reset_directory(path: &std::path::Path) { + if path.exists() { + let remove_result = std::fs::remove_dir_all(path); + assert!(remove_result.is_ok()); + } +} + +fn read_directory_text(path: &std::path::Path) -> std::string::String { + let read_result = std::fs::read_dir(path); + let entries = match read_result { + std::result::Result::Ok(entries) => entries, + std::result::Result::Err(_) => return std::string::String::new(), + }; + let mut output = std::string::String::new(); + for entry_result in entries { + let entry = match entry_result { + std::result::Result::Ok(entry) => entry, + std::result::Result::Err(_) => continue, + }; + let file_type = match entry.file_type() { + std::result::Result::Ok(file_type) => file_type, + std::result::Result::Err(_) => continue, + }; + if !file_type.is_file() { + continue; + } + let content = match std::fs::read_to_string(entry.path()) { + std::result::Result::Ok(content) => content, + std::result::Result::Err(_) => continue, + }; + output.push_str(content.as_str()); + } + return output; +} + #[test] -fn global_runtime_supports_takeover_hot_reload_and_single_initialization() { +fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_initialization() { + let root = test_root_directory(); + reset_directory(root.as_path()); let disabled = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::Off, @@ -37,34 +78,66 @@ fn global_runtime_supports_takeover_hot_reload_and_single_initialization() { std::result::Result::Ok(guard) => guard, std::result::Result::Err(_) => return, }; + assert_eq!(guard.dropped_lines(), ksp_logging_lib::DroppedLines::zero()); assert!(!logging_trace_enabled()); assert!(!other_ksp_info_enabled()); assert!(!external_error_enabled()); - let enabled = ksp_logging_lib::LoggingSettings::new( + let console_enabled = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::NewAndClose, std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), std::option::Option::None, ) .with_target_filter(ksp_logging_lib::TargetFilter::new(LOGGING_TARGET, ksp_logging_lib::LogFilterLevel::Trace)); - let reload_result = ksp_logging_lib::reinitialize(&mut guard, &enabled); + let reload_result = ksp_logging_lib::reinitialize(&mut guard, &console_enabled); assert!(reload_result.is_ok()); - assert_eq!(guard.settings(), &enabled); + assert_eq!(guard.settings(), &console_enabled); assert!(logging_trace_enabled()); assert!(other_ksp_info_enabled()); assert!(!other_ksp_debug_enabled()); assert!(!external_error_enabled()); - let unsupported_file = ksp_logging_lib::LoggingSettings::new( + let blocked_directory = root.join("not-a-directory"); + let create_root = std::fs::create_dir_all(root.as_path()); + assert!(create_root.is_ok()); + let create_blocker = std::fs::write(blocked_directory.as_path(), b"file blocks directory creation"); + assert!(create_blocker.is_ok()); + let invalid_file = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Error, ksp_logging_lib::SpanEvents::Full, std::option::Option::None, - std::option::Option::Some(ksp_logging_lib::FileSettings::new("logs", "ksp", ksp_logging_lib::FileRotation::Daily)), + std::option::Option::Some(ksp_logging_lib::FileSettings::new(blocked_directory.as_path(), "invalid", ksp_logging_lib::FileRotation::Daily)), ); - let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &unsupported_file); + let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &invalid_file); assert!(failed_reload.is_err()); - assert_eq!(guard.settings(), &enabled); + let file_error = match failed_reload { + std::result::Result::Ok(()) => return, + std::result::Result::Err(error) => error, + }; + assert_eq!(file_error.code(), ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED); + assert_eq!(guard.settings(), &console_enabled); assert!(logging_trace_enabled()); assert!(!external_error_enabled()); + let log_directory = root.join("logs"); + let file_enabled = ksp_logging_lib::LoggingSettings::new( + ksp_logging_lib::LogFilterLevel::Info, + ksp_logging_lib::SpanEvents::Off, + std::option::Option::None, + std::option::Option::Some(ksp_logging_lib::FileSettings::new(log_directory.as_path(), "runtime-test.log", ksp_logging_lib::FileRotation::Never)), + ); + let file_reload = ksp_logging_lib::reinitialize(&mut guard, &file_enabled); + assert!(file_reload.is_ok()); + ksp_logging_lib::info!(target: LOGGING_TARGET, "file \x1b[31moutput\x1b[0m marker"); + tracing::error!(target: EXTERNAL_TARGET, "external marker must remain silent"); + let disable_after_file = ksp_logging_lib::reinitialize(&mut guard, &disabled); + assert!(disable_after_file.is_ok()); + let file_text = read_directory_text(log_directory.as_path()); + assert!(file_text.contains("file output marker")); + assert!(file_text.contains(LOGGING_TARGET)); + assert!(file_text.contains("runtime.rs")); + assert!(!file_text.contains("\x1b[")); + assert!(!file_text.contains("external marker must remain silent")); + let dropped = guard.dropped_lines(); + assert_eq!(dropped.total(), dropped.console().saturating_add(dropped.file())); let second_initialize = ksp_logging_lib::initialize(&disabled); assert!(second_initialize.is_err()); let error = match second_initialize { @@ -72,4 +145,5 @@ fn global_runtime_supports_takeover_hot_reload_and_single_initialization() { std::result::Result::Err(error) => error, }; assert_eq!(error.code(), ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED); + reset_directory(root.as_path()); } diff --git a/crates/ksp-logging-lib/unit_tests/runtime.rs b/crates/ksp-logging-lib/unit_tests/runtime.rs index 00fc347..eeeaaea 100644 --- a/crates/ksp-logging-lib/unit_tests/runtime.rs +++ b/crates/ksp-logging-lib/unit_tests/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/unit_tests/runtime.rs -// version: 2 +// version: 3 #[test] fn level_mapping_covers_all_ksp_levels() { @@ -39,35 +39,52 @@ fn span_event_mapping_supports_disabled_timing_and_full_lifecycle() { } #[test] -fn file_output_is_rejected_until_file_runtime_is_introduced() { - let settings = crate::LoggingSettings::new( - crate::LogFilterLevel::Info, - crate::SpanEvents::Off, - std::option::Option::None, - std::option::Option::Some(crate::FileSettings::new("logs", "ksp", crate::FileRotation::Daily)), - ); - let result = super::prepare_runtime_layers(&settings); - assert!(result.is_err()); - let error = match result { - std::result::Result::Ok(_) => return, - std::result::Result::Err(error) => error, - }; - assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS); +fn file_rotation_mapping_covers_supported_cadences() { + assert_eq!(super::map_file_rotation(crate::FileRotation::Never), tracing_appender::rolling::Rotation::NEVER); + assert_eq!(super::map_file_rotation(crate::FileRotation::Hourly), tracing_appender::rolling::Rotation::HOURLY); + assert_eq!(super::map_file_rotation(crate::FileRotation::Daily), tracing_appender::rolling::Rotation::DAILY); } #[test] -fn console_runtime_keeps_takeover_filter_separate_from_formatter() { +fn disabled_runtime_has_no_layers_or_outputs() { + let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None); + let result = super::prepare_runtime(&settings); + assert!(result.is_ok()); + let prepared = match result { + std::result::Result::Ok(prepared) => prepared, + std::result::Result::Err(_) => return, + }; + assert!(prepared.layers.is_empty()); + assert!(prepared.outputs.console.is_none()); + assert!(prepared.outputs.file.is_none()); +} + +#[test] +fn console_runtime_keeps_takeover_filter_separate_from_formatter_and_owns_guard() { let settings = crate::LoggingSettings::new( crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(crate::ConsoleSettings::stdout()), std::option::Option::None, ); - let result = super::prepare_runtime_layers(&settings); + let result = super::prepare_runtime(&settings); assert!(result.is_ok()); - let layers = match result { - std::result::Result::Ok(layers) => layers, + let prepared = match result { + std::result::Result::Ok(prepared) => prepared, std::result::Result::Err(_) => return, }; - assert_eq!(layers.len(), 2); + assert_eq!(prepared.layers.len(), 2); + assert!(prepared.outputs.console.is_some()); + assert!(prepared.outputs.file.is_none()); + assert_eq!(prepared.outputs.dropped_lines(), crate::DroppedLines::zero()); +} + +#[test] +fn dropped_line_snapshots_add_saturating_by_sink() { + let first = crate::DroppedLines { console: usize::MAX, file: 4 }; + let second = crate::DroppedLines { console: 1, file: 7 }; + let combined = first.saturating_add(second); + assert_eq!(combined.console(), usize::MAX); + assert_eq!(combined.file(), 11); + assert_eq!(combined.total(), usize::MAX); } diff --git a/crates/ksp-logging-lib/unit_tests/writer.rs b/crates/ksp-logging-lib/unit_tests/writer.rs new file mode 100644 index 0000000..5fc671f --- /dev/null +++ b/crates/ksp-logging-lib/unit_tests/writer.rs @@ -0,0 +1,30 @@ +// file: crates/ksp-logging-lib/unit_tests/writer.rs +// version: 1 + +#[test] +fn ansi_writer_strips_csi_sequences() { + let mut writer = super::StripAnsiWriter::new(std::vec::Vec::::new()); + let write_result = std::io::Write::write_all(&mut writer, b"before\x1b[31mred\x1b[0mafter"); + assert!(write_result.is_ok()); + assert_eq!(writer.into_inner(), b"beforeredafter"); +} + +#[test] +fn ansi_writer_preserves_state_across_split_writes() { + let mut writer = super::StripAnsiWriter::new(std::vec::Vec::::new()); + let first = std::io::Write::write_all(&mut writer, b"a\x1b["); + let second = std::io::Write::write_all(&mut writer, b"32mb"); + assert!(first.is_ok()); + assert!(second.is_ok()); + assert_eq!(writer.into_inner(), b"ab"); +} + +#[test] +fn ansi_writer_strips_osc_sequences_terminated_by_bell_or_st() { + let mut writer = super::StripAnsiWriter::new(std::vec::Vec::::new()); + let first = std::io::Write::write_all(&mut writer, b"a\x1b]0;title\x07b"); + let second = std::io::Write::write_all(&mut writer, b"c\x1b]8;;https://example.invalid\x1b\\d"); + assert!(first.is_ok()); + assert!(second.is_ok()); + assert_eq!(writer.into_inner(), b"abcd"); +} diff --git a/deltas/0.1.2/pre.004.md b/deltas/0.1.2/pre.004.md new file mode 100644 index 0000000..a7d6c7a --- /dev/null +++ b/deltas/0.1.2/pre.004.md @@ -0,0 +1,301 @@ + + + +# Delta 0.1.2-pre.004 + +## Base requise + +Livraison précédente validée : + +```text +0.1.2-pre.003-fix.001 +``` + +La base de développement validée porte : + +```text +workspace.package.version = "0.1.2-pre.3.fix.1" +Cargo.toml header version = 30 +``` + +Les validations remontées avant l'ouverture de cette tranche sont propres : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +``` + +## Objectif + +Compléter le runtime Logging avec les sorties réellement retenues pour `0.1.2` : + +- ajouter `tracing-appender` ; +- rendre console et fichier non bloquants pour le caller ; +- posséder les `WorkerGuard` jusqu'au reload/shutdown approprié ; +- exposer les dropped-line counters ; +- activer le fichier `Never/Hourly/Daily` avec construction fallible ; +- supprimer les séquences ANSI avant persistence ; +- conserver le takeover et le hot reload transactionnel établis par `pre.003-fix.001`. + +## Version Cargo + +`workspace.package.version` passe de : + +```text +0.1.2-pre.3.fix.1 +``` + +à : + +```text +0.1.2-pre.4 +``` + +L'identifiant de livraison est : + +```text +0.1.2-pre.004 +``` + +Le header du `Cargo.toml` racine passe de version 30 à 31. + +## Dépendance `tracing-appender` + +L'audit du 2026-08-14 confirme `tracing-appender 0.2.5`, publié le 2026-04-17, dans la génération `^0.2`. + +La dépendance est centralisée sous `[workspace.dependencies]` : + +```toml +tracing-appender = { version = "^0.2", default-features = false } +``` + +`ksp-logging-lib` la consomme avec : + +```toml +tracing-appender.workspace = true +``` + +Aucune feature optionnelle n'est activée. Le backend expose `NonBlockingBuilder`, `WorkerGuard`, `ErrorCounter` et `RollingFileAppender` sans feature supplémentaire. + +## Console non bloquante + +La console n'utilise plus directement `stdout`/`stderr` dans le formatter. + +Chaque sink console construit : + +```text +Stdout | Stderr + -> NonBlockingBuilder(lossy = true) + -> fmt layer + + WorkerGuard + + ErrorCounter +``` + +Le mode lossy est explicite : lorsque la queue est saturée, un log peut être abandonné au lieu de bloquer le thread appelant. + +Le thread worker console est nommé : + +```text +ksp-logging-console +``` + +## Fichier et rotation + +`FileSettings` est maintenant réellement consommé par le runtime. + +Le mapping est : + +```text +FileRotation::Never -> Rotation::NEVER +FileRotation::Hourly -> Rotation::HOURLY +FileRotation::Daily -> Rotation::DAILY +``` + +Le runtime utilise uniquement : + +```text +RollingFileAppender::builder() + .rotation(...) + .filename_prefix(...) + .build(directory) +``` + +La forme builder retourne un `Result`; aucune API de construction qui panique n'est utilisée par KSP. + +Un échec retourne : + +```text +logging.file_output_initialization_failed +``` + +avec le directory, le file-name prefix et l'erreur `InitError` externe conservés dans le contrat Core. + +## Stripping ANSI + +Le fichier est composé comme suit : + +```text +fmt layer + -> NonBlocking queue + -> StripAnsiWriter + -> RollingFileAppender +``` + +Le stripping est donc effectué par le thread logging et non par le caller. + +`StripAnsiWriter` conserve un état entre les appels `Write` afin de retirer correctement une séquence terminal coupée entre plusieurs buffers. La première surface couvre : + +- CSI (`ESC [` ... final byte) ; +- OSC terminé par BEL ou ST ; +- autres chaînes terminal ESC de type DCS/SOS/PM/APC terminées par ST. + +Ce mécanisme est générique et n'introduit aucune dépendance Tauri. + +## Formatter humain + +Console et fichier partagent le formatter humain KSP avec : + +- timestamp standard `tracing-subscriber` ; +- niveau ; +- target ; +- champs/message ; +- source file ; +- line number ; +- ANSI du formatter désactivé ; +- lifecycle de spans selon `SpanEvents`. + +La ponctuation exacte du formatter reste hors contrat public. + +## Ownership et reload + +`LoggingGuard` possède désormais les outputs actifs : + +```text +LoggingGuard +├── reload handle +├── current LoggingSettings +├── active console WorkerGuard/ErrorCounter +├── active file WorkerGuard/ErrorCounter +└── cumulative retired dropped-line counters +``` + +`reinitialize()` : + +1. valide les nouveaux settings ; +2. construit entièrement le nouveau file appender et tous les nouveaux non-blocking writers/guards ; +3. construit les nouveaux layers ; +4. remplace le `Vec` reloadable ; +5. mémorise les dropped lines des anciens sinks ; +6. remplace les outputs actifs ; +7. détruit les anciens `WorkerGuard`, provoquant leur flush borné par le backend. + +Une erreur avant le swap détruit uniquement les nouveaux outputs préparés et laisse l'ancienne configuration active. + +## Dropped lines + +Nouvelle surface publique : + +```text +DroppedLines +LoggingGuard::dropped_lines() -> DroppedLines +``` + +`DroppedLines` expose : + +```text +console() +file() +total() +``` + +Les valeurs sont cumulées pour toute la durée de vie du `LoggingGuard`, y compris après plusieurs hot reloads. Les `ErrorCounter` de `tracing-appender` ne sont pas exposés directement aux consumers. + +## Tests + +### Unitaires + +- mapping `Never/Hourly/Daily` ; +- runtime sans sink ; +- console préparée avec filter séparé, non-blocking output et guard ; +- addition saturante des dropped-line counters ; +- stripping CSI ; +- stripping d'une CSI coupée entre deux writes ; +- stripping OSC terminé par BEL/ST. + +### Intégration runtime global + +Le test global vérifie désormais : + +1. initialisation silencieuse sans sink ; +2. hot reload console non bloquante ; +3. takeover KSP et silence `sqlx` ; +4. erreur de création d'un file appender sur un chemin invalide ; +5. conservation des settings précédents après cet échec ; +6. hot reload vers un fichier `Never` ; +7. émission d'un message contenant des codes ANSI ; +8. retrait du sink fichier par reload, donc drop/flush de son guard ; +9. présence du message KSP dans le fichier ; +10. absence des codes ANSI persistés ; +11. absence du message externe `sqlx` ; +12. présence du target et de la source ; +13. lecture de la statistique cumulée ; +14. refus d'un second `initialize()`. + +La saturation déterministe avec une queue artificiellement petite est reportée à `pre.005`, où un writer de test injecté pourra être utilisé sans rendre la capacité de queue publique dans `LoggingSettings`. + +## Fichiers ajoutés + +- `crates/ksp-logging-lib/src/writer.rs` +- `crates/ksp-logging-lib/unit_tests/writer.rs` +- `deltas/0.1.2/pre.004.md` + +## Fichiers modifiés + +- `Cargo.toml` +- `crates/ksp-logging-lib/Cargo.toml` +- `crates/ksp-logging-lib/src/error.rs` +- `crates/ksp-logging-lib/src/lib.rs` +- `crates/ksp-logging-lib/src/runtime.rs` +- `crates/ksp-logging-lib/unit_tests/runtime.rs` +- `crates/ksp-logging-lib/tests/runtime.rs` +- `crates/ksp-logging-lib/tests/public_api.rs` +- `docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md` + +## Validations exécutées pendant la préparation + +- revérification documentaire officielle de `tracing-appender 0.2.5` ; +- vérification de la sémantique lossy de `NonBlockingBuilder` ; +- vérification de `WorkerGuard` et `ErrorCounter::dropped_lines()` ; +- vérification du builder fallible de `RollingFileAppender` ; +- contrôle TOML des manifests ; +- contrôle des headers `file:` / `version:` ; +- contrôle de la centralisation de `tracing-appender` sous `[workspace.dependencies]` ; +- contrôle que le code production ajouté n'utilise ni `unwrap`, ni `expect`, ni `panic`, ni opérateur `?`, ni `unsafe` ; +- contrôle que les usages directs de `tracing-appender` restent dans `ksp-logging-lib`. + +## Validations à exécuter dans le workspace + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +cargo tree -p ksp-logging-lib +cargo tree -p ksp-logging-lib -d +cargo tree -p ksp-logging-lib -e features +``` + +Aucune validation Cargo non exécutable dans l'environnement de préparation n'est déclarée réussie. + +## Suite + +Après validation de `pre.004`, passer à `0.1.2-pre.005` : + +- concurrence/reloads répétés ; +- saturation déterministe et dropped lines ; +- audits de façade et usages directs de la stack tracing ; +- audits Cargo/features/doublons ; +- mesure grossière de l'overhead du reload/runtime ; +- compléments de tests et documentation de crate avant la tranche finale. diff --git a/docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md b/docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md index 0eba974..75530ab 100644 --- a/docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md +++ b/docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md @@ -1,13 +1,13 @@ - + # Plan KSP 0.1.2 — Logging foundation ## Statut -Plan actif de `0.1.2`, établi par `0.1.2-pre.001`, corrigé par `0.1.2-pre.001-fix.001`, concrétisé par la façade de `0.1.2-pre.002` puis étendu au runtime subscriber par `0.1.2-pre.003` et son correctif `0.1.2-pre.003-fix.001`. +Plan actif de `0.1.2`, établi par `0.1.2-pre.001`, corrigé par `0.1.2-pre.001-fix.001`, concrétisé par la façade de `0.1.2-pre.002`, étendu au runtime subscriber par `0.1.2-pre.003`/`pre.003-fix.001`, puis complété par les sorties non bloquantes de `0.1.2-pre.004`. -`pre.002-fix.001` a été validé dans l'environnement de développement avec `cargo fmt`, `cargo check`, `cargo clippy --workspace --all-targets` et `cargo test --workspace` propres sur la version Cargo `0.1.2-pre.2.fix.1`. Pour `pre.003`, `cargo fmt`, `cargo check` et `cargo clippy --workspace --all-targets` sont propres, mais `cargo test --workspace` a révélé un panic de `tracing-subscriber` lors du premier hot reload avec console : un `Filtered` nouvellement injecté dans le `Vec` reloadable ne possédait pas de `FilterId` enregistré. `pre.003-fix.001` sépare donc le `Targets` global du formatter afin qu'aucun `Filtered` ne soit remplacé par `Handle::reload`. Les writers non bloquants, le fichier, les guards et le stripping ANSI restent réservés à `pre.004`. +`pre.003-fix.001` a été validé dans l'environnement de développement avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test --workspace` propres sur la version Cargo `0.1.2-pre.3.fix.1`. `pre.004` ajoute `tracing-appender`, remplace la console synchrone par un writer non bloquant, introduit le fichier `Never/Hourly/Daily`, possède les `WorkerGuard`, expose des compteurs cumulés de dropped lines, déporte le stripping ANSI côté worker fichier et conserve le hot reload transactionnel des sinks. Les validations Cargo de cette nouvelle tranche restent à exécuter dans le workspace utilisateur avant validation. ## Base auditée @@ -93,7 +93,7 @@ tracing-appender 0.2.5 MSRV annoncé : rustc 1.63+ Ces exigences restent inférieures au MSRV déjà imposé indirectement par la génération Solana retenue dans `0.1.1` (`solana-pubkey 4.3.0` / workspace Solana SDK auditée à Rust 1.89.0). Logging ne relève donc pas le plancher observé du workspace. -Les versions sont revérifiées au moment exact de leur ajout effectif au manifest. Pour `pre.002`, `tracing` reste à `0.1.44`, publié dans la génération `^0.1`; `tracing-subscriber` et `tracing-appender` ne sont pas encore ajoutés. +Les versions sont revérifiées au moment exact de leur ajout effectif au manifest. `pre.002` a ajouté `tracing` dans la génération `^0.1`, `pre.003` a ajouté `tracing-subscriber` dans la génération `^0.3`, et `pre.004` revérifie puis ajoute `tracing-appender 0.2.5` dans la génération `^0.2`. ### `tracing` @@ -155,7 +155,15 @@ tracing-appender = { version = "^0.2", default-features = false } tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt"] } ``` -La feature `fmt` apporte `registry` et `std`, nécessaires à la composition des layers, `Targets`, `reload` et au formatter console. `env-filter`, `ansi`, `tracing-log`, `json`, `time` et les autres features optionnelles ne sont pas activées. `tracing-appender` reste absent jusqu'à `pre.004`, où il sera réellement consommé. +La feature `fmt` apporte `registry` et `std`, nécessaires à la composition des layers, `Targets`, `reload` et aux formatters. `env-filter`, `ansi`, `tracing-log`, `json`, `time` et les autres features optionnelles ne sont pas activées. + +`pre.004` revérifie `tracing-appender 0.2.5` et ajoute réellement : + +```toml +tracing-appender = { version = "^0.2", default-features = false } +``` + +Aucune feature optionnelle n'est activée. Le `NonBlockingBuilder` reste explicitement en mode lossy afin que la saturation de sa queue n'applique pas de backpressure au hot path. Après chaque ajout réel : @@ -468,7 +476,7 @@ Objectifs : Le mode de queue doit privilégier le non-blocage du caller : lorsque la queue est saturée, les lignes peuvent être abandonnées plutôt que d'appliquer une backpressure au hot path. -Le compteur `ErrorCounter` correspondant est conservé par le runtime afin que les pertes puissent être observées/inspectées ; l'API publique exacte de cette statistique sera fixée pendant l'implémentation. +Le compteur `ErrorCounter` correspondant est conservé par le runtime. `pre.004` stabilise l'observation publique via `LoggingGuard::dropped_lines() -> DroppedLines`, avec compteurs `console`, `file` et `total` cumulés pendant toute la durée de vie du guard, y compris à travers les hot reloads. Le format initial est un format humain unique. Il doit inclure au minimum : @@ -522,6 +530,8 @@ La taille de file reste celle du backend tant qu'un besoin réel ne justifie pas La sortie fichier passe par un writer KSP de stripping ANSI avant persistence afin de supprimer les séquences de terminal déjà présentes dans les données écrites. +`pre.004` place ce writer **derrière** la queue `NonBlocking`, autour du `RollingFileAppender`. Le parsing/stripping et l'I/O disque s'exécutent donc sur le worker logging plutôt que dans le hot path du caller. Le stripper conserve son état entre plusieurs appels `Write` afin de gérer une séquence ANSI coupée entre deux buffers ; les séquences CSI ainsi que les séquences terminal de type OSC/string terminées par BEL/ST sont couvertes. + Cette responsabilité reste générique : `ksp-logging-lib` ne dépend pas de Tauri. Elle évite simplement que des séquences ANSI injectées par une couche d'application/framework se retrouvent persistées dans les fichiers. Le stripping n'est pas présenté comme un mécanisme de redaction de données. @@ -740,6 +750,7 @@ ksp_logging_lib::ConsoleSettings ksp_logging_lib::FileRotation ksp_logging_lib::FileSettings ksp_logging_lib::LoggingSettings +ksp_logging_lib::DroppedLines ksp_logging_lib::LoggingGuard ksp_logging_lib::initialize ksp_logging_lib::reinitialize @@ -765,14 +776,16 @@ crates/ksp-logging-lib/ │ ├── macros.rs │ ├── settings.rs │ ├── span.rs -│ └── runtime.rs +│ ├── runtime.rs +│ └── writer.rs ├── unit_tests/ │ ├── settings.rs │ ├── span.rs -│ └── runtime.rs +│ ├── runtime.rs +│ └── writer.rs └── tests/ ├── callsite.rs - ├── reload.rs + ├── runtime.rs └── public_api.rs ``` @@ -947,16 +960,23 @@ Le runtime reloadable reste un `Vec>>` placé derrière ### `0.1.2-pre.004` — non-blocking console/fichier + guards + ANSI + reload sinks -Objectifs : +Statut : implémenté, validations Cargo à exécuter. -- revérifier puis ajouter `tracing-appender` ; -- passer console et fichier sur writers non bloquants ; -- implémenter `FileSettings` et Never/Hourly/Daily ; -- utiliser le builder fallible du file appender ; -- conserver les `WorkerGuard` et `ErrorCounter` ; -- implémenter le stripping ANSI fichier ; -- permettre le remplacement à chaud des sinks/settings concernés ; -- tester flush/lifetime, saturation et erreurs de fichier. +Réalisé : + +- `tracing-appender 0.2.5` ajouté via `^0.2`, sans feature optionnelle ; +- console stdout/stderr remplacée par `NonBlockingBuilder` en mode lossy ; +- fichier optionnel réellement activé avec `RollingFileAppender::builder()` fallible et rotations `Never/Hourly/Daily` ; +- console et fichier possèdent chacun leur `WorkerGuard` et leur `ErrorCounter` ; +- `LoggingGuard::dropped_lines()` expose un `DroppedLines` cumulatif pour console/fichier/total, sans perdre les compteurs des sinks retirés lors d'un reload ; +- formatter humain enrichi avec target, source file et line, ANSI du formatter désactivé ; +- stripping ANSI fichier effectué côté worker, avant persistence, avec état conservé entre buffers ; +- hot reload prépare les nouveaux writers/guards avant le swap, remplace les layers, mémorise les dropped lines des anciens sinks puis détruit leurs guards pour provoquer leur flush ; +- une erreur de construction du nouveau file appender retourne `logging.file_output_initialization_failed` et conserve la configuration précédente ; +- test d'intégration étendu pour couvrir fichier, erreur transactionnelle, flush lors du retrait du sink, takeover externe et stripping ANSI ; +- tests unitaires ajoutés pour rotations, runtime disabled/non bloquant, statistiques et stripper ANSI. + +La saturation déterministe des queues, les reloads concurrents et l'audit complet des dépendances restent à renforcer dans `pre.005`. ### `0.1.2-pre.005` — intégration + concurrence + tests + audits @@ -1031,7 +1051,7 @@ La release peut être stabilisée lorsque : - les validations workspace et audits présents sont propres ; - la documentation finale et le prompt `0.1.3` sont prêts. -## Questions ouvertes après `pre.003` +## Questions ouvertes après `pre.004` Les deux questions d'API propres à `pre.002` sont résolues : @@ -1042,8 +1062,8 @@ La composition de reload est désormais fixée pour cette release à un `Vec` de Restent à confirmer par les prereleases suivantes sans remettre en cause ce contrat : -1. l'API publique exacte d'observation des dropped lines ; -2. le détail visuel exact du formatter humain, sans transformer sa ponctuation en contrat public ; -3. le comportement de flush/rotation et le swap transactionnel des `WorkerGuard` lorsque les sinks non bloquants seront introduits. +1. le détail visuel exact du formatter humain, sans transformer sa ponctuation en contrat public ; +2. la stratégie de test déterministe de saturation des queues avec writer injecté, sans exposer la taille de queue dans `LoggingSettings` ; +3. les mesures d'overhead et tests de concurrence/reload prévus pour `pre.005`. -La prochaine action après validation de `pre.003-fix.001` est `0.1.2-pre.004` : `tracing-appender`, console/fichier non bloquants, guards, rotation, stripping ANSI et reload des sinks. +La prochaine action après validation de `pre.004` est `0.1.2-pre.005` : intégration, concurrence, saturation déterministe, audits de façade/dépendances/features et mesure grossière de l'overhead de reload.