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