use std::path::{Path, PathBuf};
use std::process::Command;
use ktstr::cli;
use ktstr::test_support::ShellTestDescriptor;
use super::probe::{ProbeError, probe_collect};
use crate::kernel::resolve_kernel_image;
fn format_test_banner(
name: &str,
desc: &ShellTestDescriptor,
memory_mib: u32,
operator_include_count: usize,
) -> String {
let wprof_args_display = desc
.wprof_args
.as_deref()
.map(|s| if s.is_empty() { "<empty>" } else { s }.to_string())
.unwrap_or_else(|| "default".to_string());
format!(
"ktstr shell: test={} scheduler={} ({}) memory_mib={} \
topology={}n{}l{}c{}t includes=test:{}+cli:{} \
perf={} wprof_args={}",
name,
desc.scheduler_name,
desc.scheduler_kind,
memory_mib,
desc.numa_nodes,
desc.llcs,
desc.cores,
desc.threads,
desc.extra_include_files.len(),
operator_include_count,
if desc.performance_mode { "on" } else { "off" },
wprof_args_display,
)
}
fn resolve_shell_from_test_entry(test: &str) -> Result<ShellTestDescriptor, String> {
let test_flag = format!("--ktstr-shell-test={test}");
let configure_cmd = |bin: &std::path::Path| {
let mut cmd = Command::new(bin);
cmd.arg(&test_flag)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd
};
let on_success = |bin: &std::path::Path,
out: &std::process::Output|
-> Result<(PathBuf, ShellTestDescriptor), String> {
let stdout = String::from_utf8_lossy(&out.stdout);
let desc = serde_json::from_str::<ShellTestDescriptor>(stdout.trim()).map_err(|e| {
format!(
"shell-test descriptor from {}: invalid JSON ({e}); \
candidate stdout: {stdout:?}",
bin.display(),
)
})?;
Ok((bin.to_path_buf(), desc))
};
match probe_collect(None, false, configure_cmd, on_success) {
Ok(matches) => {
if matches.len() > 1 {
let names: Vec<String> = matches
.iter()
.map(|(p, _)| p.display().to_string())
.collect();
return Err(format!(
"test '{test}' is ambiguous — registered in {} workspace \
test binaries: [{}]. Rename one of the registrations \
or specify a single binary explicitly (--package not \
yet supported on shell mode).",
matches.len(),
names.join(", "),
));
}
let (_, desc) = matches
.into_iter()
.next()
.expect("probe_collect Ok is non-empty per its contract");
Ok(desc)
}
Err(ProbeError::Setup(msg)) => Err(msg),
Err(ProbeError::Miss(miss)) => Err(miss.render(test, "cannot be used for shell mode")),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_shell(
kernel: Option<String>,
test: Option<String>,
topology: String,
include_files: Vec<PathBuf>,
memory_mib: Option<u32>,
dmesg: bool,
exec: Option<String>,
no_perf_mode: bool,
cpu_cap: Option<usize>,
disk: Option<String>,
) -> Result<Option<i32>, String> {
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() {
return Err(
"--cpu-cap conflicts with KTSTR_BYPASS_LLC_LOCKS=1; unset one of them. \
--cpu-cap is a resource contract; bypass disables the contract entirely."
.to_string(),
);
}
cli::CpuCap::new(cap).map_err(|e| format!("{e:#}"))?;
unsafe { std::env::set_var(ktstr::KTSTR_CPU_CAP_ENV, cap.to_string()) };
}
let disk_cfg = cli::parse_disk_arg(disk.as_deref()).map_err(|e| format!("{e:#}"))?;
cli::check_kvm().map_err(|e| format!("{e:#}"))?;
let kernel_path = resolve_kernel_image(kernel.as_deref())?;
let (
numa_nodes,
llcs,
cores,
threads,
resolved_memory,
mut extra_includes,
wprof_args,
performance_mode,
sched_enable_cmds,
sched_disable_cmds,
) = if let Some(name) = test.as_deref() {
let desc = resolve_shell_from_test_entry(name)?;
let mem = desc.memory_mib;
#[cfg(feature = "wprof")]
let mem = ktstr::apply_wprof_memory_floor(mem, true);
eprintln!(
"{}",
format_test_banner(name, &desc, mem, include_files.len()),
);
if desc.scheduler_kind == ktstr::test_support::SchedulerKind::KernelBuiltin {
if desc.scheduler_enable_cmds.is_empty() {
eprintln!(
"ktstr shell: scheduler '{}' is KernelBuiltin with no enable cmds \
declared — drop-to-shell will run under the kernel default; \
refer to the test's #[ktstr_test(...)] attributes for sysctl \
to repro the workload.",
desc.scheduler_name,
);
} else {
eprintln!(
"ktstr shell: scheduler '{}' is KernelBuiltin — running {} enable \
cmd(s) before drop-to-shell and {} disable cmd(s) on shell exit. \
You can manually re-disable inside busybox if you want to inspect \
the kernel default mid-session.",
desc.scheduler_name,
desc.scheduler_enable_cmds.len(),
desc.scheduler_disable_cmds.len(),
);
}
} else if desc.scheduler_kind != ktstr::test_support::SchedulerKind::Eevdf {
eprintln!(
"ktstr shell: repro the workload by invoking the scheduler binary \
inside the guest (e.g. /bin/{}) — its sched_args are encoded in \
the test source; this v1 doesn't stage the scheduler binary \
into the guest, so you may need to copy it via additional `-i`.",
desc.scheduler_name,
);
}
(
desc.numa_nodes,
desc.llcs,
desc.cores,
desc.threads,
Some(mem),
desc.extra_include_files
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>(),
desc.wprof_args,
desc.performance_mode,
desc.scheduler_enable_cmds,
desc.scheduler_disable_cmds,
)
} else {
let (n, l, c, t) = cli::parse_topology_string(&topology).map_err(|e| format!("{e:#}"))?;
(
n,
l,
c,
t,
memory_mib,
Vec::new(),
None,
false,
Vec::new(),
Vec::new(),
)
};
extra_includes.extend(include_files.iter().cloned());
let resolved_includes =
cli::resolve_include_files(&extra_includes).map_err(|e| format!("{e:#}"))?;
let include_refs: Vec<(&str, &Path)> = resolved_includes
.iter()
.map(|(a, p)| (a.as_str(), p.as_path()))
.collect();
let sched_enable_refs: Vec<&str> = sched_enable_cmds.iter().map(String::as_str).collect();
let sched_disable_refs: Vec<&str> = sched_disable_cmds.iter().map(String::as_str).collect();
ktstr::run_shell(
kernel_path,
numa_nodes,
llcs,
cores,
threads,
&include_refs,
resolved_memory,
dmesg,
exec.as_deref(),
disk_cfg,
wprof_args.as_deref(),
performance_mode,
&sched_enable_refs,
&sched_disable_refs,
)
.map_err(|e| format!("{e:#}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_desc(extras: Vec<&'static str>) -> ShellTestDescriptor {
ShellTestDescriptor {
numa_nodes: 1,
llcs: 1,
cores: 2,
threads: 1,
memory_mib: 1024,
wprof: false,
extra_include_files: extras.into_iter().map(String::from).collect(),
scheduler_name: "scx_rusty".to_string(),
scheduler_kind: ktstr::test_support::SchedulerKind::Discover,
wprof_args: None,
performance_mode: false,
scheduler_enable_cmds: Vec::new(),
scheduler_disable_cmds: Vec::new(),
}
}
#[test]
fn banner_zero_includes_emits_test_0_cli_0() {
let desc = sample_desc(vec![]);
let line = format_test_banner("my_test", &desc, 2048, 0);
assert!(
line.contains("includes=test:0+cli:0"),
"banner must surface zero counts so a dropped -i is obvious; got: {line}",
);
}
#[test]
fn banner_operator_include_count_propagates() {
let desc = sample_desc(vec![]);
let line = format_test_banner("my_test", &desc, 2048, 3);
assert!(
line.contains("includes=test:0+cli:3"),
"banner must echo operator -i count (3); got: {line}",
);
}
#[test]
fn banner_unions_test_and_cli_include_counts() {
let desc = sample_desc(vec!["a:/x", "b:/y"]);
let line = format_test_banner("my_test", &desc, 2048, 4);
assert!(
line.contains("includes=test:2+cli:4"),
"banner must show both test (2) and cli (4) counts \
separately so the operator can verify each source; got: {line}",
);
}
#[test]
fn banner_preserves_topology_and_memory_fields() {
let mut desc = sample_desc(vec![]);
desc.numa_nodes = 2;
desc.llcs = 4;
desc.cores = 6;
desc.threads = 2;
let line = format_test_banner("topo_test", &desc, 4096, 0);
assert!(line.contains("topology=2n4l6c2t"), "topology axes: {line}");
assert!(line.contains("memory_mib=4096"), "memory: {line}");
assert!(line.contains("test=topo_test"), "test name: {line}");
}
#[test]
fn banner_defaults_show_perf_off_and_wprof_default() {
let desc = sample_desc(vec![]);
let line = format_test_banner("plain", &desc, 2048, 0);
assert!(
line.contains("perf=off"),
"banner must surface performance_mode=false as perf=off so \
operator sees the absence of vCPU pinning / SCHED_FIFO / \
hugepages; got: {line}",
);
assert!(
line.contains("wprof_args=default"),
"banner must surface wprof_args=None as wprof_args=default \
so operator distinguishes 'no override' from 'override with \
empty args' (which renders as wprof_args=<empty>); got: {line}",
);
}
#[test]
fn banner_surfaces_perf_on_and_custom_wprof_args() {
let mut desc = sample_desc(vec![]);
desc.performance_mode = true;
desc.wprof_args = Some("--sched-events --kstack".to_string());
let line = format_test_banner("perf_test", &desc, 2048, 0);
assert!(
line.contains("perf=on"),
"banner must surface performance_mode=true as perf=on so \
the operator can correlate observed timings with the \
host-side optimization stack; got: {line}",
);
assert!(
line.contains("wprof_args=--sched-events --kstack"),
"banner must echo the wprof_args override verbatim so \
reproducing the same flags by hand is one copy-paste; \
got: {line}",
);
}
#[test]
fn banner_distinguishes_empty_wprof_override_from_default() {
let mut desc = sample_desc(vec![]);
desc.wprof_args = Some(String::new());
let line = format_test_banner("empty_args", &desc, 2048, 0);
assert!(
line.contains("wprof_args=<empty>"),
"Some(\"\") MUST render as <empty> to disambiguate from \
None (which renders as default) — the two have different \
semantics per the run_shell contract; got: {line}",
);
}
}