use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::{ArgAction, CommandFactory, Parser, Subcommand};
use ktstr::cli;
use ktstr::cli::KernelCommand;
use ktstr::ctprof;
use ktstr::ctprof_compare;
use ktstr::topology::TestTopology;
#[path = "ktstr/show_render.rs"]
mod show_render;
#[derive(Parser)]
#[command(
name = "ktstr",
about = "Run ktstr scheduler test scenarios on the host",
after_help = "See also: `cargo ktstr` for cargo-integrated workflows \
(test, coverage, llvm-cov, verifier, stats)."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Topo,
Kernel {
#[command(subcommand)]
command: KernelCommand,
},
Shell {
#[arg(long, help = ktstr::cli::KERNEL_HELP_NO_RAW)]
kernel: Option<String>,
#[arg(long, default_value = "1,1,1,1")]
topology: String,
#[arg(short = 'i', long = "include-files", action = ArgAction::Append)]
include_files: Vec<PathBuf>,
#[arg(long = "memory-mib", value_parser = clap::value_parser!(u32).range(128..))]
memory_mib: Option<u32>,
#[arg(long)]
dmesg: bool,
#[arg(long)]
exec: Option<String>,
#[arg(long, value_parser = humantime::parse_duration, default_value = "120s")]
exec_timeout: std::time::Duration,
#[arg(long)]
no_perf_mode: bool,
#[arg(long, requires = "no_perf_mode", help = ktstr::cli::CPU_CAP_HELP)]
cpu_cap: Option<usize>,
#[arg(long, help = ktstr::cli::DISK_HELP)]
disk: Option<String>,
},
Ctprof {
#[command(subcommand)]
command: CtprofCommand,
},
Completions {
shell: clap_complete::Shell,
#[arg(long, default_value = "ktstr")]
binary: String,
},
Locks {
#[arg(long)]
json: bool,
#[arg(long, value_parser = humantime::parse_duration)]
watch: Option<std::time::Duration>,
},
}
#[derive(Subcommand)]
enum CtprofCommand {
Capture {
#[arg(short, long)]
output: PathBuf,
},
Compare(ctprof_compare::CtprofCompareArgs),
Show(CtprofShowArgs),
MetricList,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ShowGroupBy {
Pcomm,
Cgroup,
Comm,
CommExact,
}
impl From<ShowGroupBy> for ctprof_compare::GroupBy {
fn from(g: ShowGroupBy) -> Self {
match g {
ShowGroupBy::Pcomm => ctprof_compare::GroupBy::Pcomm,
ShowGroupBy::Cgroup => ctprof_compare::GroupBy::Cgroup,
ShowGroupBy::Comm => ctprof_compare::GroupBy::Comm,
ShowGroupBy::CommExact => ctprof_compare::GroupBy::CommExact,
}
}
}
#[derive(Debug, clap::Args)]
pub struct CtprofShowArgs {
pub snapshot: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = ShowGroupBy::Pcomm, help_heading = "Grouping")]
pub group_by: ShowGroupBy,
#[arg(long, help_heading = "Grouping")]
pub cgroup_flatten: Vec<String>,
#[arg(long, help_heading = "Grouping")]
pub no_thread_normalize: bool,
#[arg(long, help_heading = "Grouping")]
pub no_cg_normalize: bool,
#[arg(long, default_value = "", help_heading = "Display")]
pub sort_by: String,
#[arg(long, default_value = "", help_heading = "Display")]
pub columns: String,
#[arg(long, default_value = "", help_heading = "Filter")]
pub sections: String,
#[arg(long, default_value = "", help_heading = "Filter")]
pub metrics: String,
#[arg(long, help_heading = "Display")]
pub wrap: bool,
#[arg(long, default_value_t = 500, help_heading = "Display")]
pub limit: usize,
}
#[allow(clippy::too_many_arguments)]
fn kernel_build(
version: Option<String>,
source: Option<PathBuf>,
git: Option<String>,
git_ref: Option<String>,
force: bool,
clean: bool,
cpu_cap: Option<usize>,
extra_kconfig: Option<PathBuf>,
skip_sha256: bool,
) -> Result<()> {
let extra_content: Option<String> = match extra_kconfig.as_ref() {
Some(p) => {
Some(ktstr::cli::read_extra_kconfig(p, "ktstr").map_err(|e| anyhow::anyhow!("{e}"))?)
}
None => None,
};
if source.is_none()
&& git.is_none()
&& let Some(ref v) = version
{
use ktstr::kernel_path::KernelId;
let id = KernelId::parse(v);
id.validate()
.map_err(|e| anyhow::anyhow!("--kernel {id}: {e}"))?;
if let KernelId::Range { start, end, .. } = id {
let versions = ktstr::cli::expand_kernel_range(&start, &end, "ktstr")?;
let total = versions.len();
let mut failures: Vec<(String, anyhow::Error)> = Vec::new();
for (i, ver) in versions.iter().enumerate() {
eprintln!("ktstr: [{}/{total}] kernel build {ver}", i + 1);
if let Err(e) = kernel_build_one(
Some(ver.clone()),
None,
None,
None,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
) {
eprintln!("ktstr: {ver}: {e:#}");
failures.push((ver.clone(), e));
}
}
if failures.is_empty() {
Ok(())
} else {
anyhow::bail!(
"kernel build range {start}..{end}: {failed}/{total} \
version(s) failed: {names}",
failed = failures.len(),
names = failures
.iter()
.map(|(v, _)| v.as_str())
.collect::<Vec<_>>()
.join(", "),
);
}
} else {
kernel_build_one(
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
)
}
} else {
kernel_build_one(
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
)
}
}
#[allow(clippy::too_many_arguments)]
fn kernel_build_one(
version: Option<String>,
source: Option<PathBuf>,
git: Option<String>,
git_ref: Option<String>,
force: bool,
clean: bool,
cpu_cap: Option<usize>,
extra_kconfig: Option<&str>,
skip_sha256: bool,
) -> Result<()> {
use ktstr::cache::CacheDir;
use ktstr::fetch;
if cpu_cap.is_some() && ktstr::bypass_llc_locks_active() {
anyhow::bail!(
"--cpu-cap conflicts with KTSTR_BYPASS_LLC_LOCKS=1; unset one of them. \
--cpu-cap is a resource contract; bypass disables the contract entirely."
);
}
let resolved_cap = cli::CpuCap::resolve(cpu_cap)?;
let cache = CacheDir::new()?;
let tmp_dir = tempfile::TempDir::new()?;
let client = fetch::shared_client();
let mut acquired = if let Some(ref src_path) = source {
fetch::local_source(src_path)?
} else if let Some(ref url) = git {
let ref_name = git_ref.as_deref().expect("clap requires --ref with --git");
fetch::git_clone(url, ref_name, tmp_dir.path(), "ktstr")?
} else {
let ver = match version {
Some(v) if fetch::is_major_minor_prefix(&v) => {
fetch::fetch_version_for_prefix(client, &v, "ktstr")?
}
Some(v) => v,
None => fetch::fetch_latest_stable_version(client, "ktstr")?,
};
let (arch, _) = fetch::arch_info();
let cache_key = format!(
"{ver}-tarball-{arch}-kc{}",
ktstr::cache_key_suffix_with_extra(extra_kconfig),
);
if !force && let Some(entry) = cli::cache_lookup(&cache, &cache_key, "ktstr") {
eprintln!("ktstr: cached kernel found: {}", entry.path.display());
eprintln!("ktstr: use --force to rebuild");
return Ok(());
}
let sp = cli::Spinner::start("Downloading kernel...");
let result = fetch::download_tarball(client, &ver, tmp_dir.path(), "ktstr", skip_sha256);
drop(sp);
let mut acquired = result?;
acquired.cache_key = cache_key;
acquired
};
if source.is_some() || git.is_some() {
cli::append_extra_kconfig_suffix(&mut acquired.cache_key, extra_kconfig);
}
if !force
&& (source.is_some() || git.is_some())
&& !acquired.is_dirty
&& let Some(entry) = cli::cache_lookup(&cache, &acquired.cache_key, "ktstr")
{
eprintln!("ktstr: cached kernel found: {}", entry.path.display());
eprintln!("ktstr: use --force to rebuild");
return Ok(());
}
if force {
let _force_check = cache.try_acquire_exclusive_lock(&acquired.cache_key)?;
}
cli::kernel_build_pipeline(
&acquired,
&cache,
"ktstr",
clean,
source.is_some(),
resolved_cap,
extra_kconfig,
)?;
Ok(())
}
fn run_completions(shell: clap_complete::Shell, binary: &str) {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, binary, &mut std::io::stdout());
}
fn run_show(args: &CtprofShowArgs) -> Result<i32> {
use anyhow::Context;
let sort_by = ctprof_compare::parse_sort_by(&args.sort_by)
.with_context(|| format!("parse --sort-by {:?}", args.sort_by))?;
let columns = ctprof_compare::parse_columns(&args.columns, false)
.with_context(|| format!("parse --columns {:?}", args.columns))?;
let sections = ctprof_compare::parse_sections(&args.sections)
.with_context(|| format!("parse --sections {:?}", args.sections))?;
let metrics = ctprof_compare::parse_metrics(&args.metrics)
.with_context(|| format!("parse --metrics {:?}", args.metrics))?;
let group_by: ctprof_compare::GroupBy = args.group_by.into();
ctprof_compare::warn_cgroup_only_sections_under_non_cgroup(§ions, group_by);
let snap = ktstr::ctprof::CtprofSnapshot::load(&args.snapshot)
.with_context(|| format!("load snapshot {}", args.snapshot.display()))?;
let mut out = String::new();
let _ = write_show(
&mut out,
&snap,
group_by,
&args.cgroup_flatten,
args.no_thread_normalize,
args.no_cg_normalize,
&sort_by,
&columns,
§ions,
&metrics,
args.wrap,
);
if args.limit > 0 {
print!("{}", ctprof_compare::limit_sections(&out, args.limit));
} else {
print!("{out}");
}
Ok(0)
}
#[allow(clippy::too_many_arguments)]
fn write_show<W: std::fmt::Write>(
w: &mut W,
snap: &ktstr::ctprof::CtprofSnapshot,
group_by: ctprof_compare::GroupBy,
cgroup_flatten: &[String],
no_thread_normalize: bool,
no_cg_normalize: bool,
sort_by: &[ctprof_compare::SortKey],
columns: &[ctprof_compare::Column],
sections: &[ctprof_compare::Section],
metrics: &[&'static str],
wrap: bool,
) -> std::fmt::Result {
let flatten = ctprof_compare::compile_flatten_patterns(cgroup_flatten);
let cgroup_key_map = if group_by == ctprof_compare::GroupBy::Cgroup && !no_cg_normalize {
Some(ctprof_compare::build_cgroup_key_map(snap, snap, &flatten))
} else {
None
};
let groups = ctprof_compare::build_groups(
snap,
group_by,
&flatten,
None,
cgroup_key_map.as_ref(),
no_thread_normalize,
);
let group_header = match group_by {
ctprof_compare::GroupBy::Pcomm => "pcomm",
ctprof_compare::GroupBy::Cgroup => "cgroup",
ctprof_compare::GroupBy::Comm => "comm-pattern",
ctprof_compare::GroupBy::CommExact => "comm",
ctprof_compare::GroupBy::All => unreachable!("All is decomposed before write_show"),
};
let mut display_options = ctprof_compare::DisplayOptions::default();
display_options.columns = columns.to_vec();
display_options.sections = sections.to_vec();
display_options.metrics = metrics.to_vec();
display_options.wrap = wrap;
let resolved_columns = display_options.resolved_show_columns();
let group_order: Vec<&String> = if sort_by.is_empty() {
groups.keys().collect()
} else {
let mut keys: Vec<&String> = groups.keys().collect();
let group_tuple = |group_key: &str| -> Vec<f64> {
let group = groups.get(group_key);
sort_by
.iter()
.map(|k| {
group
.and_then(|g| g.metrics.get(k.metric))
.and_then(|a| a.numeric())
.unwrap_or(if k.descending {
f64::NEG_INFINITY
} else {
f64::INFINITY
})
})
.collect()
};
keys.sort_by(|a, b| {
let ta = group_tuple(a);
let tb = group_tuple(b);
for (i, key) in sort_by.iter().enumerate() {
let (va, vb) = (ta[i], tb[i]);
let ord = if key.descending {
vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
} else {
va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
};
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
a.cmp(b)
});
keys
};
show_render::write_show_primary(
w,
&display_options,
&groups,
&group_order,
&resolved_columns,
group_header,
group_by,
no_thread_normalize,
)?;
show_render::write_show_derived(
w,
&display_options,
&groups,
&group_order,
&resolved_columns,
group_header,
group_by,
no_thread_normalize,
)?;
show_render::write_show_cgroup_sections(
w,
&display_options,
snap,
group_by,
&flatten,
cgroup_key_map.as_ref(),
)?;
show_render::write_show_host_pressure(w, &display_options, snap)?;
show_render::write_show_smaps(w, &display_options, snap, no_thread_normalize)?;
show_render::write_show_sched_ext(w, &display_options, snap)?;
Ok(())
}
type PsiAccessor = (&'static str, fn(&ctprof::Psi) -> ctprof::PsiResource);
fn psi_resources() -> [PsiAccessor; 4] {
[
("cpu", |p| p.cpu),
("memory", |p| p.memory),
("io", |p| p.io),
("irq", |p| p.irq),
]
}
fn format_psi_avg(centi_percent: u16) -> String {
let int = centi_percent / 100;
let frac = centi_percent % 100;
format!("{int}.{frac:02}%")
}
fn host_psi_has_data(psi: &ctprof::Psi) -> bool {
[psi.cpu, psi.memory, psi.io, psi.irq]
.iter()
.any(psi_resource_has_data)
}
fn psi_resource_has_data(r: &ctprof::PsiResource) -> bool {
let h =
|h: &ctprof::PsiHalf| h.avg10 != 0 || h.avg60 != 0 || h.avg300 != 0 || h.total_usec != 0;
h(&r.some) || h(&r.full)
}
#[cfg(test)]
mod psi_show_tests {
use super::*;
use ktstr::ctprof::{Psi, PsiHalf, PsiResource};
#[test]
fn format_psi_avg_renders_centi_percent_with_two_decimal_digits() {
assert_eq!(format_psi_avg(0), "0.00%");
assert_eq!(format_psi_avg(1), "0.01%");
assert_eq!(format_psi_avg(50), "0.50%");
assert_eq!(format_psi_avg(1859), "18.59%");
assert_eq!(format_psi_avg(10000), "100.00%");
assert_eq!(format_psi_avg(10099), "100.99%");
}
#[test]
fn psi_resources_lists_four_in_canonical_order() {
let names: Vec<&str> = psi_resources().iter().map(|(n, _)| *n).collect();
assert_eq!(names, vec!["cpu", "memory", "io", "irq"]);
}
fn psi_resource_with_some_avg10(v: u16) -> PsiResource {
let mut half = PsiHalf::default();
half.avg10 = v;
let mut r = PsiResource::default();
r.some = half;
r
}
fn psi_resource_with_full_avg10(v: u16) -> PsiResource {
let mut half = PsiHalf::default();
half.avg10 = v;
let mut r = PsiResource::default();
r.full = half;
r
}
#[test]
fn psi_resources_accessors_route_to_correct_field() {
let mut psi = Psi::default();
psi.cpu = psi_resource_with_some_avg10(1);
psi.memory = psi_resource_with_some_avg10(2);
psi.io = psi_resource_with_some_avg10(3);
psi.irq = psi_resource_with_full_avg10(4);
let accessors = psi_resources();
assert_eq!(accessors[0].1(&psi).some.avg10, 1, "cpu accessor");
assert_eq!(accessors[1].1(&psi).some.avg10, 2, "memory accessor");
assert_eq!(accessors[2].1(&psi).some.avg10, 3, "io accessor");
assert_eq!(accessors[3].1(&psi).full.avg10, 4, "irq accessor");
}
#[test]
fn host_psi_has_data_returns_false_for_all_zero_bundle() {
assert!(!host_psi_has_data(&Psi::default()));
}
#[test]
fn host_psi_has_data_returns_true_for_any_nonzero_field() {
for resource_idx in 0..4 {
for is_full in [false, true] {
let mut psi = Psi::default();
let target_resource = match resource_idx {
0 => &mut psi.cpu,
1 => &mut psi.memory,
2 => &mut psi.io,
_ => &mut psi.irq,
};
let half = if is_full {
&mut target_resource.full
} else {
&mut target_resource.some
};
half.total_usec = 1;
assert!(
host_psi_has_data(&psi),
"resource_idx={resource_idx}, is_full={is_full} should be detected"
);
}
}
}
}
fn main() -> Result<()> {
ktstr::cli::restore_sigpipe_default();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
let args = Cli::parse();
match args.command {
Command::Topo => {
let topo = TestTopology::from_system()?;
println!("CPUs: {}", topo.total_cpus());
println!("LLCs: {}", topo.num_llcs());
println!("NUMA nodes: {}", topo.num_numa_nodes());
for (i, llc) in topo.llcs().iter().enumerate() {
println!(" LLC {} (node {}): {:?}", i, llc.numa_node(), llc.cpus(),);
}
}
Command::Kernel { command } => match command {
KernelCommand::List { json, range } => match range {
Some(r) => cli::kernel_list_range_preview(json, &r)?,
None => cli::kernel_list(json)?,
},
KernelCommand::Build {
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_kconfig,
skip_sha256,
} => kernel_build(
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_kconfig,
skip_sha256,
)?,
KernelCommand::Clean {
keep,
force,
corrupt_only,
} => cli::kernel_clean(keep, force, corrupt_only)?,
},
Command::Shell {
kernel,
topology,
include_files,
memory_mib,
dmesg,
exec,
exec_timeout,
no_perf_mode,
cpu_cap,
disk,
} => {
if no_perf_mode {
unsafe { std::env::set_var(ktstr::KTSTR_NO_PERF_MODE_ENV, "1") };
}
if let Some(cap) = cpu_cap {
if ktstr::bypass_llc_locks_active() {
anyhow::bail!(
"--cpu-cap conflicts with KTSTR_BYPASS_LLC_LOCKS=1; unset \
one of them. --cpu-cap is a resource contract; bypass \
disables the contract entirely."
);
}
cli::CpuCap::new(cap)?;
unsafe { std::env::set_var(ktstr::KTSTR_CPU_CAP_ENV, cap.to_string()) };
}
let disk_cfg = cli::parse_disk_arg(disk.as_deref())?;
cli::check_kvm()?;
let kernel_path = cli::resolve_kernel_image(
kernel.as_deref(),
&cli::KernelResolvePolicy {
accept_raw_image: false,
cli_label: "ktstr",
},
)?;
let (numa_nodes, llcs, cores, threads) = cli::parse_topology_string(&topology)?;
let resolved_includes = cli::resolve_include_files(&include_files)?;
let include_refs: Vec<(&str, &Path)> = resolved_includes
.iter()
.map(|(a, p)| (a.as_str(), p.as_path()))
.collect();
let exec_code = ktstr::run_shell(
kernel_path,
numa_nodes,
llcs,
cores,
threads,
&include_refs,
memory_mib,
dmesg,
exec.as_deref(),
exec_timeout,
disk_cfg,
None,
false,
&[],
&[],
)?;
std::process::exit(exec_code.unwrap_or(0));
}
Command::Ctprof { command } => match command {
CtprofCommand::Capture { output } => {
ctprof::capture_to(&output)?;
eprintln!("ktstr: wrote ctprof snapshot to {}", output.display());
}
CtprofCommand::Compare(args) => {
let code = ctprof_compare::run_compare(&args)?;
if code != 0 {
std::process::exit(code);
}
}
CtprofCommand::Show(args) => {
let code = run_show(&args)?;
if code != 0 {
std::process::exit(code);
}
}
CtprofCommand::MetricList => {
let code = ctprof_compare::run_metric_list()?;
if code != 0 {
std::process::exit(code);
}
}
},
Command::Completions { shell, binary } => {
run_completions(shell, &binary);
}
Command::Locks { json, watch } => cli::list_locks(json, watch)?,
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ktstr::metric_types::{MonotonicCount, MonotonicNs};
#[test]
fn parse_shell_cpu_cap_with_no_perf_mode_succeeds() {
let parsed = Cli::try_parse_from(["ktstr", "shell", "--cpu-cap", "4", "--no-perf-mode"])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Shell {
cpu_cap,
no_perf_mode,
..
} => {
assert_eq!(cpu_cap, Some(4));
assert!(no_perf_mode, "--no-perf-mode must be set");
}
_ => panic!("expected Shell"),
}
}
#[test]
fn parse_shell_cpu_cap_without_no_perf_mode_fails() {
let msg = match Cli::try_parse_from(["ktstr", "shell", "--cpu-cap", "4"]) {
Err(e) => e.to_string(),
Ok(_) => panic!("--cpu-cap without --no-perf-mode must fail the parse"),
};
assert!(
msg.to_ascii_lowercase().contains("no-perf-mode")
|| msg.to_ascii_lowercase().contains("no_perf_mode"),
"clap error must name the missing --no-perf-mode flag, got: {msg}",
);
}
#[test]
fn parse_shell_no_perf_mode_without_cpu_cap_succeeds() {
let parsed = Cli::try_parse_from(["ktstr", "shell", "--no-perf-mode"])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Shell {
cpu_cap,
no_perf_mode,
..
} => {
assert_eq!(cpu_cap, None, "no --cpu-cap must produce None");
assert!(no_perf_mode);
}
_ => panic!("expected Shell"),
}
}
#[test]
fn parse_shell_memory_mb_rejected() {
let rejected = Cli::try_parse_from(["ktstr", "shell", "--memory-mb", "256"]);
assert!(
rejected.is_err(),
"`--memory-mb` (old flag name) must be rejected — the \
canonical name is `--memory-mib`. A regression that \
re-added an alias for the old form would surface here.",
);
}
#[test]
fn parse_ctprof_show_positional_only_succeeds() {
let parsed = Cli::try_parse_from(["ktstr", "ctprof", "show", "/tmp/snap.ctprof.zst"])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Show(args),
} => {
assert_eq!(
args.snapshot,
std::path::PathBuf::from("/tmp/snap.ctprof.zst")
);
assert_eq!(args.group_by, ShowGroupBy::Pcomm);
assert!(args.cgroup_flatten.is_empty());
assert!(!args.no_thread_normalize);
assert!(!args.no_cg_normalize);
assert!(args.sort_by.is_empty());
}
_ => panic!("expected Ctprof/Show"),
}
}
#[test]
fn parse_ctprof_show_group_by_all_is_rejected() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"show",
"/tmp/snap.ctprof.zst",
"--group-by",
"all",
]);
let Err(err) = parsed else {
panic!(
"`--group-by all` is a compare-only axis with no show \
renderer; it must be rejected at parse time, not \
accepted and panicked on in write_show"
);
};
let msg = err.to_string();
assert!(
msg.contains("all"),
"rejection message must name the invalid value `all`, got: {msg}",
);
}
#[test]
fn parse_ctprof_show_group_by_supported_axes() {
for (token, expected) in [
("pcomm", ShowGroupBy::Pcomm),
("cgroup", ShowGroupBy::Cgroup),
("comm", ShowGroupBy::Comm),
("comm-exact", ShowGroupBy::CommExact),
] {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"show",
"/tmp/snap.ctprof.zst",
"--group-by",
token,
])
.unwrap_or_else(|e| panic!("`--group-by {token}` must parse: {e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Show(args),
} => assert_eq!(
args.group_by, expected,
"`--group-by {token}` must parse to {expected:?}",
),
_ => panic!("expected Ctprof/Show for `--group-by {token}`"),
}
}
}
#[test]
fn show_group_by_converts_to_compare_group_by_without_all() {
for (show, expected) in [
(ShowGroupBy::Pcomm, ctprof_compare::GroupBy::Pcomm),
(ShowGroupBy::Cgroup, ctprof_compare::GroupBy::Cgroup),
(ShowGroupBy::Comm, ctprof_compare::GroupBy::Comm),
(ShowGroupBy::CommExact, ctprof_compare::GroupBy::CommExact),
] {
let got: ctprof_compare::GroupBy = show.into();
assert_eq!(got, expected, "{show:?} must convert to {expected:?}");
assert_ne!(
got,
ctprof_compare::GroupBy::All,
"no ShowGroupBy variant may convert to GroupBy::All",
);
}
}
#[test]
fn parse_ctprof_show_with_every_flag_succeeds() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"show",
"/tmp/snap.ctprof.zst",
"--group-by",
"comm",
"--cgroup-flatten",
"/kubepods/*/workload",
"--no-thread-normalize",
"--no-cg-normalize",
"--sort-by",
"run_time_ns:desc,wait_sum:asc",
])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Show(args),
} => {
assert_eq!(args.group_by, ShowGroupBy::Comm);
assert_eq!(
args.cgroup_flatten,
vec!["/kubepods/*/workload".to_string()]
);
assert!(args.no_thread_normalize);
assert!(args.no_cg_normalize);
assert_eq!(args.sort_by, "run_time_ns:desc,wait_sum:asc");
}
_ => panic!("expected Ctprof/Show"),
}
}
#[test]
fn parse_ctprof_show_sort_by_single_key_succeeds() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"show",
"/tmp/snap.ctprof.zst",
"--sort-by",
"run_time_ns",
])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Show(args),
} => {
assert_eq!(args.sort_by, "run_time_ns");
}
_ => panic!("expected Ctprof/Show"),
}
}
#[test]
fn parse_ctprof_compare_sort_by_defaults_to_empty() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"compare",
"/tmp/a.ctprof.zst",
"/tmp/b.ctprof.zst",
])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Compare(args),
} => {
assert_eq!(args.baseline, std::path::PathBuf::from("/tmp/a.ctprof.zst"));
assert_eq!(
args.candidate,
std::path::PathBuf::from("/tmp/b.ctprof.zst")
);
assert!(args.sort_by.is_empty());
}
_ => panic!("expected Ctprof/Compare"),
}
}
#[test]
fn parse_ctprof_compare_with_sort_by_succeeds() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"compare",
"/tmp/a.ctprof.zst",
"/tmp/b.ctprof.zst",
"--sort-by",
"run_time_ns:desc,wait_time_ns:asc",
])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Compare(args),
} => {
assert_eq!(args.sort_by, "run_time_ns:desc,wait_time_ns:asc");
}
_ => panic!("expected Ctprof/Compare"),
}
}
#[test]
fn parse_ctprof_compare_with_every_flag() {
let parsed = Cli::try_parse_from([
"ktstr",
"ctprof",
"compare",
"/tmp/a.ctprof.zst",
"/tmp/b.ctprof.zst",
"--group-by",
"comm",
"--cgroup-flatten",
"/kubepods/*/workload",
"--no-thread-normalize",
"--no-cg-normalize",
"--sort-by",
"run_time_ns:desc,wait_sum:asc",
])
.unwrap_or_else(|e| panic!("{e}"));
match parsed.command {
Command::Ctprof {
command: CtprofCommand::Compare(args),
} => {
assert_eq!(args.baseline, std::path::PathBuf::from("/tmp/a.ctprof.zst"));
assert_eq!(
args.candidate,
std::path::PathBuf::from("/tmp/b.ctprof.zst")
);
assert_eq!(args.group_by, ctprof_compare::GroupBy::Comm);
assert_eq!(
args.cgroup_flatten,
vec!["/kubepods/*/workload".to_string()]
);
assert!(args.no_thread_normalize);
assert!(args.no_cg_normalize);
assert_eq!(args.sort_by, "run_time_ns:desc,wait_sum:asc");
}
_ => panic!("expected Ctprof/Compare"),
}
}
#[test]
fn write_show_renders_pcomm_grouping_with_expected_columns() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t1 = ktstr::ctprof::ThreadState::default();
t1.pcomm = "worker-proc".to_string();
t1.comm = "worker-0".to_string();
t1.nr_wakeups = MonotonicCount(1);
let mut t2 = ktstr::ctprof::ThreadState::default();
t2.pcomm = "worker-proc".to_string();
t2.comm = "worker-1".to_string();
t2.nr_wakeups = MonotonicCount(2);
snap.threads.push(t1);
snap.threads.push(t2);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("pcomm"),
"header must include `pcomm` for GroupBy::Pcomm, got: {out}",
);
assert!(
out.contains("threads"),
"header must include `threads` column, got: {out}",
);
assert!(
out.contains("metric"),
"header must include `metric` column, got: {out}",
);
assert!(
out.contains("value"),
"header must include `value` column, got: {out}",
);
assert!(
out.contains("worker-proc"),
"group key must surface in the rendered table, got: {out}",
);
assert!(
out.contains("nr_wakeups"),
"Sum metric `nr_wakeups` must render a row, got: {out}",
);
assert!(
out.contains(" 3 "),
"summed nr_wakeups (1+2) must surface as 3 in a value cell, got: {out}",
);
}
#[test]
fn write_show_header_switches_on_group_by() {
let snap = ktstr::ctprof::CtprofSnapshot::default();
for (axis, expected_header) in [
(ctprof_compare::GroupBy::Pcomm, "pcomm"),
(ctprof_compare::GroupBy::Cgroup, "cgroup"),
(ctprof_compare::GroupBy::Comm, "comm-pattern"),
(ctprof_compare::GroupBy::CommExact, "comm"),
] {
let mut out = String::new();
write_show(
&mut out,
&snap,
axis,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains(expected_header),
"header for {axis:?} must contain `{expected_header}`, got: {out}",
);
}
}
#[test]
fn write_show_empty_snapshot_renders_header_only() {
let snap = ktstr::ctprof::CtprofSnapshot::default();
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail on empty snapshot");
assert!(
out.contains("pcomm"),
"empty snapshot must still emit the header, got: {out}",
);
assert!(
!out.contains("run_time_ns"),
"no thread → no metric row; got run_time_ns surfaced: {out}",
);
}
#[test]
fn write_show_sort_by_orders_groups_by_metric_descending() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t_alpha = ktstr::ctprof::ThreadState::default();
t_alpha.pcomm = "alpha".to_string();
t_alpha.comm = "alpha-w".to_string();
t_alpha.run_time_ns = MonotonicNs(100);
let mut t_bravo = ktstr::ctprof::ThreadState::default();
t_bravo.pcomm = "bravo".to_string();
t_bravo.comm = "bravo-w".to_string();
t_bravo.run_time_ns = MonotonicNs(500);
let mut t_charlie = ktstr::ctprof::ThreadState::default();
t_charlie.pcomm = "charlie".to_string();
t_charlie.comm = "charlie-w".to_string();
t_charlie.run_time_ns = MonotonicNs(250);
snap.threads.push(t_alpha);
snap.threads.push(t_bravo);
snap.threads.push(t_charlie);
let sort_by = vec![ctprof_compare::SortKey {
metric: "run_time_ns",
descending: true,
}];
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&sort_by,
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
let bravo_at = out.find("bravo").expect("bravo must surface in output");
let charlie_at = out.find("charlie").expect("charlie must surface in output");
let alpha_at = out.find("alpha").expect("alpha must surface in output");
assert!(
bravo_at < charlie_at,
"sort_by run_time_ns:desc must place bravo (500) before charlie (250); \
alpha={alpha_at} bravo={bravo_at} charlie={charlie_at}\nout:\n{out}",
);
assert!(
charlie_at < alpha_at,
"sort_by run_time_ns:desc must place charlie (250) before alpha (100); \
alpha={alpha_at} bravo={bravo_at} charlie={charlie_at}\nout:\n{out}",
);
}
#[test]
fn write_show_cgroup_stats_renders_single_value_no_delta() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.cgroup = "/app".to_string();
snap.threads.push(t);
let one_gib: u64 = 1024 * 1024 * 1024;
let mut cgs = ktstr::ctprof::CgroupStats::default();
cgs.cpu.usage_usec = 1_500_000;
cgs.cpu.nr_throttled = 50;
cgs.cpu.throttled_usec = 200;
cgs.memory.current = one_gib;
snap.cgroup_stats.insert("/app".to_string(), cgs);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Cgroup,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("1.500s"),
"cpu_usage_usec 1_500_000 µs must scale to '1.500s', got:\n{out}",
);
assert!(
out.contains("1.000GiB"),
"memory_current 1 GiB must scale to '1.000GiB', got:\n{out}",
);
assert!(
!out.contains("→"),
"show cgroup-stats must not emit a `→` arrow \
(compare's two-value cell shape); got:\n{out}",
);
assert!(
!out.contains("(+0"),
"show cgroup-stats must not carry a `(+0…)` zero-delta tail; \
got:\n{out}",
);
}
#[test]
fn write_show_renders_tagged_metric_cell() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.nr_wakeups_affine = MonotonicCount(7);
snap.threads.push(t);
let columns = vec![
ctprof_compare::Column::Group,
ctprof_compare::Column::Threads,
ctprof_compare::Column::Metric,
ctprof_compare::Column::Tags,
ctprof_compare::Column::Value,
];
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&columns,
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("[cfs-only] [SCHEDSTATS]"),
"tagged metric tags missing from rendered tags column:\n{out}",
);
assert!(
out.contains("nr_wakeups_affine"),
"tagged metric name missing from rendered show table:\n{out}",
);
}
#[test]
fn write_show_emits_derived_section() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.run_time_ns = MonotonicNs(4_000);
t.timeslices = MonotonicCount(8);
snap.threads.push(t);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("## Derived metrics"),
"missing derived section header from show output:\n{out}",
);
assert!(
out.contains("avg_slice_ns"),
"missing avg_slice_ns row in derived section of show output:\n{out}",
);
}
#[test]
fn write_show_columns_override_emits_only_selected_columns() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.nr_wakeups = MonotonicCount(1);
snap.threads.push(t);
let columns = vec![
ctprof_compare::Column::Metric,
ctprof_compare::Column::Value,
];
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&columns,
&[],
&[],
false,
)
.expect("write_show into String must not fail");
let header_line = out
.lines()
.find(|line| line.contains("metric") && !line.starts_with("##"))
.unwrap_or("");
assert!(
header_line.contains("metric"),
"metric column must appear in header: {header_line}",
);
assert!(
header_line.contains("value"),
"value column must appear in header: {header_line}",
);
assert!(
!header_line.contains("threads"),
"threads column must NOT appear when --columns excludes it: {header_line}",
);
}
#[test]
fn write_show_empty_sort_by_keeps_alphabetical_default() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t_zulu = ktstr::ctprof::ThreadState::default();
t_zulu.pcomm = "zulu".to_string();
t_zulu.comm = "zulu-w".to_string();
t_zulu.run_time_ns = MonotonicNs(999);
let mut t_alpha = ktstr::ctprof::ThreadState::default();
t_alpha.pcomm = "alpha".to_string();
t_alpha.comm = "alpha-w".to_string();
t_alpha.run_time_ns = MonotonicNs(1);
snap.threads.push(t_zulu);
snap.threads.push(t_alpha);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
let alpha_at = out.find("alpha").expect("alpha must surface");
let zulu_at = out.find("zulu").expect("zulu must surface");
assert!(
alpha_at < zulu_at,
"empty sort_by must keep alphabetical order \
(alpha before zulu); alpha={alpha_at} zulu={zulu_at}\nout:\n{out}",
);
}
#[test]
fn write_show_smaps_default_normalization_collapses_pids() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
for (pcomm, tgid, rss) in [
("worker-0", 100, 1024_u64),
("worker-1", 200, 2048),
("worker-2", 300, 4096),
] {
let mut t = ktstr::ctprof::ThreadState::default();
t.tid = tgid;
t.tgid = tgid;
t.pcomm = pcomm.to_string();
t.comm = pcomm.to_string();
t.smaps_rollup_kib.insert("Rss".into(), rss);
t.smaps_rollup_kib.insert("Pss".into(), rss / 2);
snap.threads.push(t);
}
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false, false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show must not fail");
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
assert!(
after.contains("worker-{N}"),
"show smaps must collapse to `worker-{{N}}` under default normalization:\n{after}",
);
for literal in &["worker-0[100]", "worker-1[200]", "worker-2[300]"] {
assert!(
!after.contains(literal),
"literal per-PID key {literal:?} must NOT appear under default \
normalization:\n{after}",
);
}
assert!(
after.contains("7.000MiB"),
"summed Rss must render as `7.000MiB` (3-PID collapse via field-sum):\n{after}",
);
}
#[test]
fn write_show_smaps_literal_mode_preserves_per_pid_keys() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
for (pcomm, tgid, rss) in [
("worker-0", 100, 1024_u64),
("worker-1", 200, 2048),
("worker-2", 300, 4096),
] {
let mut t = ktstr::ctprof::ThreadState::default();
t.tid = tgid;
t.tgid = tgid;
t.pcomm = pcomm.to_string();
t.comm = pcomm.to_string();
t.smaps_rollup_kib.insert("Rss".into(), rss);
snap.threads.push(t);
}
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
true, false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show must not fail");
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
for literal in &["worker-0[100]", "worker-1[200]", "worker-2[300]"] {
assert!(
after.contains(literal),
"literal per-PID key {literal:?} must surface under \
--no-thread-normalize:\n{after}",
);
}
assert!(
!after.contains("worker-{N}"),
"normalized `worker-{{N}}` key must NOT surface under \
--no-thread-normalize:\n{after}",
);
}
#[test]
fn write_show_smaps_orders_by_rss_descending() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut bash = ktstr::ctprof::ThreadState::default();
bash.tid = 1;
bash.tgid = 1;
bash.pcomm = "bash".to_string();
bash.comm = "bash".to_string();
bash.smaps_rollup_kib.insert("Rss".into(), 100 * 1024);
bash.smaps_rollup_kib.insert("Pss".into(), 50 * 1024);
snap.threads.push(bash);
let mut zulu = ktstr::ctprof::ThreadState::default();
zulu.tid = 2;
zulu.tgid = 2;
zulu.pcomm = "zulu".to_string();
zulu.comm = "zulu".to_string();
zulu.smaps_rollup_kib.insert("Rss".into(), 1024);
zulu.smaps_rollup_kib.insert("Pss".into(), 512);
snap.threads.push(zulu);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show must not fail");
let smaps_at = out
.find("## smaps_rollup")
.expect("smaps section must render");
let after = &out[smaps_at..];
let bash_pos = after.find("bash").expect("bash key must surface");
let zulu_pos = after.find("zulu").expect("zulu key must surface");
assert!(
bash_pos < zulu_pos,
"smaps must sort by Rss desc — bash (100 MiB) ahead of \
zulu (1 MiB) regardless of alphabetical order; \
bash@{bash_pos} zulu@{zulu_pos}\nafter:\n{after}",
);
}
fn cgroup_fixture() -> (ktstr::ctprof::CtprofSnapshot, ktstr::ctprof::CgroupStats) {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.cgroup = "/app".to_string();
snap.threads.push(t);
(snap, ktstr::ctprof::CgroupStats::default())
}
#[test]
fn write_show_emits_cgroup_limits_table_with_scaled_knobs() {
let (mut snap, mut cgs) = cgroup_fixture();
cgs.cpu.max_quota_us = Some(50_000);
cgs.cpu.max_period_us = 100_000;
cgs.cpu.weight = Some(200);
cgs.memory.max = Some(2 * 1024 * 1024 * 1024);
cgs.memory.current = 1;
cgs.pids.current = Some(7);
snap.cgroup_stats.insert("/app".to_string(), cgs);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Cgroup,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("## Cgroup limits / knobs"),
"limits section must render when a cgroup exposes a knob:\n{out}",
);
assert!(
out.contains("50.000ms/100.000ms"),
"cpu.max 50_000µs/100_000µs must render via format_cpu_max \
as '50.000ms/100.000ms':\n{out}",
);
assert!(
out.contains(" 200 "),
"cpu.weight 200 must render via format_scaled_u64(Unitless) \
as the bare integer 200:\n{out}",
);
assert!(
out.contains("2.000GiB"),
"memory.max 2 GiB must render via format_optional_limit(Bytes) \
as '2.000GiB':\n{out}",
);
assert!(
out.contains(" 7 "),
"pids.current 7 must render via format_scaled_u64(Unitless) \
as the bare integer 7:\n{out}",
);
assert!(
out.contains(" max "),
"memory.high=None / pids.max=None must render the standalone \
`max` cell via format_optional_limit's None arm:\n{out}",
);
}
#[test]
fn write_show_emits_memory_stat_table_suppressing_zero_rows() {
let (mut snap, mut cgs) = cgroup_fixture();
cgs.memory.stat.insert("anon".to_string(), 4096);
cgs.memory.stat.insert("file".to_string(), 0);
snap.cgroup_stats.insert("/app".to_string(), cgs);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Cgroup,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("## memory.stat"),
"memory.stat section must render when any key is non-zero:\n{out}",
);
assert!(
out.contains("anon"),
"non-zero `anon` key must render a row:\n{out}",
);
assert!(
out.contains("4.096K"),
"anon=4096 must render via format_scaled_u64(Unitless) \
stepping up to '4.096K':\n{out}",
);
assert!(
!out.contains("file"),
"zero-valued `file` key must be suppressed by the if *stat_value == 0 \
continue:\n{out}",
);
}
#[test]
fn write_show_emits_memory_events_table_suppressing_zero_rows() {
let (mut snap, mut cgs) = cgroup_fixture();
cgs.memory.events.insert("oom_kill".to_string(), 3);
cgs.memory.events.insert("low".to_string(), 0);
snap.cgroup_stats.insert("/app".to_string(), cgs);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Cgroup,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("## memory.events"),
"memory.events section must render when any event is non-zero:\n{out}",
);
assert!(
out.contains("oom_kill"),
"non-zero `oom_kill` event must render a row:\n{out}",
);
assert!(
out.contains(" 3 "),
"oom_kill=3 must render via format_scaled_u64(Unitless) \
as the bare integer 3:\n{out}",
);
assert!(
!out.contains("low"),
"zero-count `low` event must be suppressed by the if *event_value == 0 \
continue:\n{out}",
);
}
#[test]
fn write_show_emits_per_cgroup_pressure_tables_skipping_zero_resources() {
let (mut snap, mut cgs) = cgroup_fixture();
cgs.psi.cpu.some.avg10 = 1859;
cgs.psi.cpu.some.total_usec = 1_500_000;
snap.cgroup_stats.insert("/app".to_string(), cgs);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Cgroup,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
out.contains("## Pressure / cpu"),
"cpu pressure sub-table must render when cpu PSI has data:\n{out}",
);
assert!(
out.contains("18.59%"),
"cpu.some.avg10=1859 must render via format_psi_avg as '18.59%':\n{out}",
);
assert!(
out.contains("1.500s"),
"cpu.some.total_usec=1_500_000 must render via \
format_scaled_u64(Us) as '1.500s':\n{out}",
);
assert!(
out.contains("some"),
"the `some` PSI row must render:\n{out}",
);
assert!(
out.contains("full"),
"the `full` PSI row must render:\n{out}",
);
assert!(
!out.contains("## Pressure / memory"),
"all-zero memory PSI must be skipped by the if !any_data \
continue:\n{out}",
);
assert!(
!out.contains("## Pressure / io"),
"all-zero io PSI must be skipped by the if !any_data \
continue:\n{out}",
);
}
#[test]
fn write_show_emits_host_pressure_tables_skipping_zero_resources() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
snap.threads.push(t);
snap.psi.memory.full.avg60 = 500;
snap.psi.memory.full.total_usec = 2_000_000;
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
let host_at = out
.find("## Host pressure / memory")
.expect("host memory pressure sub-table must render");
assert!(
out.contains("5.00%"),
"memory.full.avg60=500 must render via format_psi_avg as '5.00%':\n{out}",
);
assert!(
out.contains("2.000s"),
"memory.full.total_usec=2_000_000 must render via \
format_scaled_u64(Us) as '2.000s':\n{out}",
);
assert!(
!out.contains("## Host pressure / cpu"),
"all-zero host cpu PSI must be skipped by the \
psi_resource_has_data continue:\n{out}",
);
let host_region = &out[host_at..];
assert!(
!host_region.contains("cgroup"),
"host pressure header is `row | avg10 | avg60 | avg300 | total` \
with no cgroup column:\n{host_region}",
);
}
#[test]
fn write_show_emits_sched_ext_table_with_state_and_scaled_counters() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
snap.threads.push(t);
let mut scx = ktstr::ctprof::SchedExtSysfs::default();
scx.state = "enabled".to_string();
scx.switch_all = 1;
scx.nr_rejected = 2;
scx.hotplug_seq = 3;
scx.enable_seq = 1234;
snap.sched_ext = Some(scx);
let mut out = String::new();
write_show(
&mut out,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[],
&[],
false,
)
.expect("write_show into String must not fail");
let scx_at = out
.find("## sched_ext")
.expect("sched_ext section must render when snap.sched_ext is Some");
let scx_region = &out[scx_at..];
assert!(
scx_region.contains("state"),
"sched_ext table must carry the `state` attr row:\n{scx_region}",
);
assert!(
scx_region.contains("enabled"),
"non-empty state must render verbatim ('enabled'), NOT the `-` \
sentinel:\n{scx_region}",
);
assert!(
scx_region.contains("switch_all"),
"sched_ext table must carry the `switch_all` row:\n{scx_region}",
);
assert!(
scx_region.contains("nr_rejected"),
"sched_ext table must carry the `nr_rejected` row:\n{scx_region}",
);
assert!(
scx_region.contains("1.234K"),
"enable_seq=1234 must render via format_scaled_u64(Unitless) \
stepping up to '1.234K':\n{scx_region}",
);
assert!(
scx_region.contains(" 1 "),
"switch_all=1 must render via format_scaled_u64(Unitless) \
as the bare integer 1:\n{scx_region}",
);
}
#[test]
fn write_show_sections_filter_suppresses_unnamed_sections() {
let mut snap = ktstr::ctprof::CtprofSnapshot::default();
let mut t = ktstr::ctprof::ThreadState::default();
t.pcomm = "worker".to_string();
t.comm = "w".to_string();
t.run_time_ns = MonotonicNs(4_000);
t.timeslices = MonotonicCount(8);
snap.threads.push(t);
let mut derived_only = String::new();
write_show(
&mut derived_only,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[ctprof_compare::Section::Derived],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
!derived_only.contains("## Primary metrics"),
"`--sections derived` must suppress the Primary section:\n{derived_only}",
);
assert!(
derived_only.contains("## Derived metrics"),
"`--sections derived` must still emit the Derived section:\n{derived_only}",
);
let mut primary_only = String::new();
write_show(
&mut primary_only,
&snap,
ctprof_compare::GroupBy::Pcomm,
&[],
false,
false,
&[],
&[],
&[ctprof_compare::Section::Primary],
&[],
false,
)
.expect("write_show into String must not fail");
assert!(
primary_only.contains("## Primary metrics"),
"`--sections primary` must emit the Primary section:\n{primary_only}",
);
assert!(
!primary_only.contains("## Derived metrics"),
"`--sections primary` must suppress the Derived section:\n{primary_only}",
);
}
#[test]
fn run_show_fails_fast_on_invalid_sort_by_before_snapshot_load() {
let args = CtprofShowArgs {
snapshot: std::path::PathBuf::from("/nonexistent/snap.ctprof.zst"),
group_by: ShowGroupBy::Pcomm,
cgroup_flatten: vec![],
no_thread_normalize: false,
no_cg_normalize: false,
sort_by: "not_a_metric:bogusdir".to_string(),
columns: String::new(),
sections: String::new(),
metrics: String::new(),
wrap: false,
limit: 0,
};
let err = run_show(&args).expect_err(
"an invalid --sort-by spec must fail before the absent snapshot \
is loaded",
);
let flat = format!("{err:#}");
assert!(
flat.contains("parse --sort-by"),
"error must come from the sort-by parser (the `parse --sort-by` \
with_context label), got: {flat}",
);
assert!(
!flat.contains("load snapshot"),
"the snapshot load must not run — its `load snapshot` context \
label must be absent, proving the parse fails first, got: {flat}",
);
}
}