Files
zstd-rs/rust/src/zstdcli_trace.rs
T
ddidderr 829f781855 feat(cli): move trace callbacks into Rust
The CLI trace translation unit still contained the complete CSV writer and
strong callback implementations even though the Rust CLI archive already had
the equivalent trace module. Reduce the C file to its public-header shim and
make the Rust implementation authoritative, preserving the exact CSV header,
version assertion, timing fields, and no-trace build behavior. Focused tests
cover header creation and the version-mismatch assertion.

Test Plan:
- CLI tests: 161 default and 127 reduced-feature -- passed
- Focused trace tests: 3/3 in both configurations -- passed
- `make -B -C programs -j2` for all CLI variants -- passed
- Trace compression/decompression smoke test -- passed
- C shim compile with and without `ZSTD_NOTRACE` -- passed
- Clippy, nightly rustfmt, and `git diff --check` -- passed
2026-07-18 18:00:42 +02:00

258 lines
7.6 KiB
Rust

#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(clippy::missing_safety_doc)]
//! Command-line trace callbacks.
//!
//! `programs/zstdcli_trace.c` is a declaration-only ABI shim. These functions
//! provide the strong-symbol override for the library's weak tracing
//! callbacks. Keep the wire-visible structures and CSV format here so the CLI
//! archive does not need a C implementation merely to enable tracing. The
//! decompression-only CLI still gets the no-op callbacks below; that variant
//! does not link the compression parameter API.
use std::ffi::{c_char, c_int, c_uint, c_void, CStr};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
/* Mirrors ZSTD_VERSION_NUMBER in lib/zstd.h for the CLI/library ABI check. */
const ZSTD_VERSION_NUMBER: c_uint = 10_507;
const TRACE_HEADER: &str = "Algorithm, Version, Method, Mode, Level, Workers, Dictionary Size, Uncompressed Size, Compressed Size, Duration Nanos, Compression Ratio, Speed MB/s";
#[cfg(feature = "compression")]
const ZSTD_C_COMPRESSION_LEVEL: c_int = 100;
#[cfg(feature = "compression")]
const ZSTD_C_NB_WORKERS: c_int = 400;
#[repr(C)]
pub struct ZSTD_Trace {
pub version: c_uint,
pub streaming: c_int,
pub dictionaryID: c_uint,
pub dictionaryIsCold: c_int,
pub dictionarySize: usize,
pub uncompressedSize: usize,
pub compressedSize: usize,
pub params: *const c_void,
pub cctx: *const c_void,
pub dctx: *const c_void,
}
#[derive(Default)]
struct TraceState {
file: Option<File>,
enabled_at: Option<Instant>,
}
fn trace_state() -> &'static Mutex<TraceState> {
static STATE: OnceLock<Mutex<TraceState>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(TraceState::default()))
}
#[cfg(feature = "compression")]
unsafe extern "C" {
fn ZSTD_CCtxParams_getParameter(
params: *const c_void,
param: c_int,
value: *mut c_int,
) -> usize;
}
fn elapsed_nanos(start: Option<Instant>) -> u64 {
start
.map(|instant| instant.elapsed().as_nanos().min(u64::MAX as u128) as u64)
.unwrap_or(0)
}
#[no_mangle]
pub unsafe extern "C" fn TRACE_enable(filename: *const c_char) {
let Some(filename) = (!filename.is_null()).then(|| unsafe { CStr::from_ptr(filename) }) else {
return;
};
let Ok(path_string) = filename.to_str() else {
return;
};
let path = Path::new(path_string);
let was_regular_file = std::fs::metadata(path)
.map(|metadata| metadata.is_file())
.unwrap_or(false);
let file = OpenOptions::new().create(true).append(true).open(path).ok();
let mut state = trace_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(mut file) = file {
if !was_regular_file {
let _ = writeln!(file, "{TRACE_HEADER}");
}
state.file = Some(file);
} else {
state.file = None;
}
state.enabled_at = Some(Instant::now());
}
#[no_mangle]
pub extern "C" fn TRACE_finish() {
let mut state = trace_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.file = None;
state.enabled_at = None;
}
fn trace_begin() -> u64 {
let state = trace_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if state.file.is_none() {
return 0;
}
elapsed_nanos(state.enabled_at).max(1)
}
fn trace_log(method: &str, begin: u64, trace: &ZSTD_Trace, state: &mut TraceState) {
let Some(file) = state.file.as_mut() else {
return;
};
let duration = elapsed_nanos(state.enabled_at).saturating_sub(begin);
let duration = duration.max(1);
let (level, workers) = {
#[cfg(feature = "compression")]
{
let mut level = 0;
let mut workers = 0;
if !trace.params.is_null() {
unsafe {
ZSTD_CCtxParams_getParameter(
trace.params,
ZSTD_C_COMPRESSION_LEVEL,
&mut level,
);
ZSTD_CCtxParams_getParameter(trace.params, ZSTD_C_NB_WORKERS, &mut workers);
}
}
(level, workers)
}
#[cfg(not(feature = "compression"))]
{
(0, 0)
}
};
let ratio = trace.uncompressedSize as f64 / trace.compressedSize.max(1) as f64;
let speed = trace.uncompressedSize as f64 * 1000.0 / duration as f64;
let _ = writeln!(
file,
"zstd, {}, {}, {}, {}, {}, {}, {}, {}, {}, {:.2}, {:.2}",
trace.version,
method,
if trace.streaming != 0 {
"streaming"
} else {
"single-pass"
},
level,
workers,
trace.dictionarySize,
trace.uncompressedSize,
trace.compressedSize,
duration,
ratio,
speed,
);
}
fn assert_trace_version(trace: &ZSTD_Trace) {
debug_assert_eq!(
trace.version, ZSTD_VERSION_NUMBER,
"CLI version must match trace version"
);
}
#[no_mangle]
pub extern "C" fn ZSTD_trace_compress_begin(_cctx: *const c_void) -> u64 {
trace_begin()
}
#[no_mangle]
pub unsafe extern "C" fn ZSTD_trace_compress_end(ctx: u64, trace: *const ZSTD_Trace) {
if ctx == 0 || trace.is_null() {
return;
}
let mut state = trace_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert_trace_version(unsafe { &*trace });
trace_log("compress", ctx, unsafe { &*trace }, &mut state);
}
#[no_mangle]
pub extern "C" fn ZSTD_trace_decompress_begin(_dctx: *const c_void) -> u64 {
trace_begin()
}
#[no_mangle]
pub unsafe extern "C" fn ZSTD_trace_decompress_end(ctx: u64, trace: *const ZSTD_Trace) {
if ctx == 0 || trace.is_null() {
return;
}
let mut state = trace_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert_trace_version(unsafe { &*trace });
trace_log("decompress", ctx, unsafe { &*trace }, &mut state);
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
use std::fs;
#[test]
fn disabled_trace_callbacks_are_noops() {
TRACE_finish();
assert_eq!(ZSTD_trace_compress_begin(std::ptr::null()), 0);
assert_eq!(ZSTD_trace_decompress_begin(std::ptr::null()), 0);
}
#[test]
fn enabling_trace_writes_the_header_once() {
let path = std::env::temp_dir().join(format!(
"zstd-rust-trace-{}-{}.csv",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let path_string = path.to_string_lossy().into_owned();
let path_c = CString::new(path_string).unwrap();
let _ = fs::remove_file(&path);
unsafe { TRACE_enable(path_c.as_ptr()) };
assert_ne!(ZSTD_trace_compress_begin(std::ptr::null()), 0);
TRACE_finish();
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents, format!("{TRACE_HEADER}\n"));
let _ = fs::remove_file(path);
}
#[test]
#[should_panic(expected = "CLI version must match trace version")]
fn trace_version_mismatch_panics_like_c_assertion() {
let trace = ZSTD_Trace {
version: ZSTD_VERSION_NUMBER - 1,
streaming: 0,
dictionaryID: 0,
dictionaryIsCold: 0,
dictionarySize: 0,
uncompressedSize: 0,
compressedSize: 1,
params: std::ptr::null(),
cctx: std::ptr::null(),
dctx: std::ptr::null(),
};
assert_trace_version(&trace);
}
}