v0.1.2-pre.004

This commit is contained in:
2026-08-14 18:43:54 +02:00
parent 488d8ae0a8
commit 151590422d
12 changed files with 803 additions and 91 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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");

View File

@@ -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;

View File

@@ -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<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + std::marker::Send + std::marker::Sync + 'static>;
type RuntimeLayers = std::vec::Vec<BoxedRuntimeLayer>;
type RuntimeReloadHandle = tracing_subscriber::reload::Handle<RuntimeLayers, tracing_subscriber::Registry>;
struct PreparedRuntime {
layers: RuntimeLayers,
outputs: RuntimeOutputs,
}
#[derive(Default)]
struct RuntimeOutputs {
console: std::option::Option<RuntimeOutput>,
file: std::option::Option<RuntimeOutput>,
}
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<crate::LoggingGuard> {
return prepare_runtime_layers(settings).and_then(|runtime_layers| -> ksp_core_lib::Result<crate::LoggingGuard> {
let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(runtime_layers);
return prepare_runtime(settings).and_then(|prepared| -> ksp_core_lib::Result<crate::LoggingGuard> {
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<cra
});
}
/// Replaces the active KSP logging settings without reinstalling the global subscriber.
/// Replaces the active KSP logging settings and non-blocking outputs without reinstalling the global subscriber.
///
/// New runtime layers are fully prepared before the reload is attempted. If validation or preparation fails, the currently active configuration is left
/// unchanged.
/// New runtime layers, writers and guards are fully prepared before the reload is attempted. If validation or preparation fails, the currently active
/// configuration remains unchanged. After a successful layer swap, dropped-line counters from the retired outputs are retained cumulatively and the old
/// worker guards are dropped so their queues can be flushed.
pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSettings) -> 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<RuntimeLayers> {
fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedRuntime> {
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<PreparedOutput> {
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<W>(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,

View File

@@ -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<W> {
inner: W,
state: StripAnsiState,
}
impl<W> StripAnsiWriter<W> {
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<W> std::io::Write for StripAnsiWriter<W>
where
W: std::io::Write,
{
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<W> StripAnsiWriter<W> {
fn consume_byte(&mut self, byte: u8, output: &mut std::vec::Vec<u8>) {
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;

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -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);
}

View File

@@ -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::<u8>::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::<u8>::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::<u8>::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");
}

301
deltas/0.1.2/pre.004.md Normal file
View File

@@ -0,0 +1,301 @@
<!-- file: deltas/0.1.2/pre.004.md -->
<!-- version: 1 -->
# 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.

View File

@@ -1,13 +1,13 @@
<!-- file: docs/plans/004-V0_1_2_LOGGING_FOUNDATION_PLAN.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# 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<Box<dyn Layer<Registry>>>` 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.