ktstr 0.8.0

Test harness for Linux process schedulers
//! End-to-end: `#[ktstr_test(host_only)]` runs on the real host
//! and sees the real-host topology (not the synthesized 2-CPU
//! placeholder from `KtstrTestEntry::DEFAULT.topology`).
//!
//! Pins the swap from `TestTopology::from_vm_topology(&entry.topology)`
//! to `TestTopology::from_system()?` inside
//! `crate::test_support::dispatch::run_host_only_test_inner`.
//!
//! The `from_vm_topology` default topology resolves to 2 CPUs (1
//! LLC × 2 cores × 1 thread, per the macro defaults at
//! `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
//! Asserting `cpu_count >= 4` here catches the regression
//! reliably across every CI runner the project supports — GHA
//! ubuntu-latest is 2-4 CPUs (marginal but typically the project
//! gates host_only tests behind sufficient runners), Apple
//! Silicon CI is 8-core, and dev hosts ≥ 8. A regression that
//! fell back to
//! `from_vm_topology` would report exactly 2 and trip the floor.
//!
//! Also covers the `KTSTR_HOST_CGROUP_PARENT` default and the
//! `WorkSpec::workers_pct(1.0)` opt-in scaling contract.

use anyhow::Context;
use ktstr::assert::AssertResult;
use ktstr::ktstr_test;
use ktstr::scenario::Ctx;

/// Real-host topology must show ≥ 4 CPUs on any runner the project
/// supports. A regression that fell back to
/// `from_vm_topology(&entry.topology)` would yield exactly 2 CPUs
/// (1 LLC × 2 cores × 1 thread, per `KtstrTestEntry::DEFAULT.topology`
/// derived from macro defaults at
/// `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
/// The lower bound is portable to every project-supported runner;
/// raising it from 2 to 4 ensures the synth-topology regression
/// (2-CPU report) is caught reliably.
#[ktstr_test(host_only)]
fn host_mode_sees_real_host_cpus(ctx: &Ctx) -> Result<AssertResult, anyhow::Error> {
    // Detection signal that survives no_perf_mode CPU-pinning (which
    // narrows the test process to 1-2 host CPUs and would make
    // `ctx.topo.all_cpus().len() == 2` coincide with the
    // `from_vm_topology` fallback signature):
    //
    //   from_vm_topology synthesizes CPU IDs starting at 0
    //   (contiguous: 0, 1, ...).
    //   from_system reads sysfs-online CPUs (likely non-contiguous,
    //   especially after sched_getaffinity narrowing to a host pin).
    //
    // Cross-check the topo's CPU IDs against /sys/devices/system/cpu/online
    // (which bypasses sched_getaffinity). If every topo CPU is a member
    // of the sysfs-online set AND at least one topo CPU id > 1, the
    // routing went through from_system (synth would produce IDs 0..n
    // only). If the topo is exactly {0, 1} AND sysfs has > 2 CPUs, the
    // routing fell back to from_vm_topology (the regression).
    let topo_cpus: std::collections::BTreeSet<usize> =
        ctx.topo.all_cpus().iter().copied().collect();
    let sysfs_online = std::fs::read_to_string("/sys/devices/system/cpu/online")
        .context("read /sys/devices/system/cpu/online")?;
    let sysfs_cpus =
        parse_cpu_list(sysfs_online.trim()).context("parse /sys/devices/system/cpu/online")?;
    if sysfs_cpus.len() < 4 {
        // Test environment limitation: cargo-ktstr's no_perf_mode +
        // mount-namespace sandbox can virtualize
        // /sys/devices/system/cpu/online down to the test process's
        // CPU pin (typically 1-2 CPUs). In that env we cannot reliably
        // distinguish a correct from_system routing from a regressed
        // from_vm_topology fallback (both can return 2). PASS with a
        // log line so a real-host CI runner (>= 4 sysfs CPUs) still
        // gates the routing regression below.
        tracing::warn!(
            sysfs_cpu_count = sysfs_cpus.len(),
            topo_cpu_count = topo_cpus.len(),
            "host_mode_sees_real_host_cpus: sysfs reports < 4 CPUs in \
             this test env (sandboxed cargo-ktstr context with narrowed \
             /sys); the regression check below cannot reliably trigger. \
             Run on a real-host CI with >= 4 visible sysfs CPUs to gate."
        );
        return Ok(AssertResult::pass());
    }
    // Distinguishing signal: the synth topo produces CPU ids
    // starting at 0 (1×2×1 → {0, 1}). On a >= 4-CPU host where
    // no_perf_mode narrowed the test process to 2 CPUs, those 2
    // CPUs are almost-never {0, 1} (sysfs-online typically starts at
    // 0 but the no_perf_mode pinner chooses based on hash/round-
    // robin across the available host CPU pool — the chance of
    // hitting {0, 1} on a 316-CPU host is ~2/316²). Detect the
    // regression when topo CPUs are exactly {0, 1} AND sysfs has
    // more than 2 CPUs.
    if topo_cpus == std::collections::BTreeSet::from([0, 1]) && sysfs_cpus.len() > 2 {
        return Ok(AssertResult::fail_msg(format!(
            "host_only routed through `from_vm_topology` instead of \
             `from_system`: ctx.topo.all_cpus() = {{0, 1}} matches \
             KtstrTestEntry::DEFAULT.topology (1 LLC × 2 cores × 1 \
             thread) exactly while the real host has {} online CPUs.",
            sysfs_cpus.len(),
        )));
    }
    // Every topo CPU id MUST be a member of the sysfs-online set —
    // catches a regression where from_vm_topology synthesizes IDs
    // that don't correspond to real CPUs (e.g. a 1×8×1 default
    // would produce 0..=7 even on a 316-CPU host with no_perf_mode
    // pinning to {16, 17}).
    let sysfs_set: std::collections::BTreeSet<usize> = sysfs_cpus.iter().copied().collect();
    let synth_ids: Vec<usize> = topo_cpus.difference(&sysfs_set).copied().collect();
    if !synth_ids.is_empty() {
        return Ok(AssertResult::fail_msg(format!(
            "ctx.topo.all_cpus() contains IDs {synth_ids:?} not present \
             in /sys/devices/system/cpu/online (sysfs_count = {}); \
             host_only must derive its topology from real sysfs CPUs \
             — synthesized IDs indicate a from_vm_topology routing.",
            sysfs_cpus.len(),
        )));
    }
    Ok(AssertResult::pass())
}

/// Parse the kernel's `/sys/devices/system/cpu/online` cpu-list
/// format ("0-3,7,10-12") into a Vec of CPU IDs. Used to read the
/// real-host online-CPU set directly without going through
/// `sched_getaffinity` (which the test process may have narrowed
/// under no_perf_mode CPU-pinning).
///
/// Returns `Err` on any malformed token rather than silently
/// skipping — the kernel never emits malformed lines today and a
/// silent skip would let a truncated / corrupt sysfs read (e.g.
/// `unwrap_or(0)` mapping "foo-bar" to {0}) trigger the
/// early-PASS branch above OR the topo-vs-sysfs membership
/// mismatch below with a misleading diagnostic. Per the
/// no-silent-drops policy, surfacing the parse failure here
/// gives the test author an accurate signal instead of a
/// downstream contradiction.
fn parse_cpu_list(s: &str) -> anyhow::Result<Vec<usize>> {
    use anyhow::anyhow;
    let mut cpus = Vec::new();
    for range in s.split(',') {
        let range = range.trim();
        if range.is_empty() {
            continue;
        }
        match range.split_once('-') {
            Some((lo, hi)) => {
                let lo: usize = lo
                    .trim()
                    .parse()
                    .map_err(|e| anyhow!("cpu-list range lo {lo:?}: {e}"))?;
                let hi: usize = hi
                    .trim()
                    .parse()
                    .map_err(|e| anyhow!("cpu-list range hi {hi:?}: {e}"))?;
                if hi < lo {
                    return Err(anyhow!(
                        "cpu-list range {lo}-{hi}: hi < lo (kernel emits monotonic ranges)"
                    ));
                }
                for c in lo..=hi {
                    cpus.push(c);
                }
            }
            None => {
                let c: usize = range
                    .parse()
                    .map_err(|e| anyhow!("cpu-list singleton {range:?}: {e}"))?;
                cpus.push(c);
            }
        }
    }
    Ok(cpus)
}

/// Pin the `KTSTR_HOST_CGROUP_PARENT` default fallback at
/// dispatch.rs's `resolve_host_cgroup_parent`. Three layers of
/// coverage close the silent-pass + tautology risks that an
/// inline-rederivation of the env→default cascade would carry:
///
/// 1. **Const import** — a rename of `DEFAULT_HOST_CGROUP_PARENT`
///    would compile-fail the test (closes rename drift).
/// 2. **Literal canary** — `LITERAL_DEFAULT` asserted equal to
///    the imported const. A value drift in
///    `DEFAULT_HOST_CGROUP_PARENT` (e.g. someone changes the
///    string to `/sys/fs/cgroup/ktstr-foo`) trips this assert
///    even though the import still compiles.
/// 3. **Real resolve cascade** — calls
///    `ktstr::test_support::resolve_host_cgroup_parent()` instead
///    of re-deriving the env→default cascade inline. A behavioural
///    change inside the helper (e.g. a future override-walk
///    extension) flows through the test instead of pinning the
///    obsolete inline cascade.
#[ktstr_test(host_only)]
fn host_mode_default_cgroup_parent_resolves(_ctx: &Ctx) -> Result<AssertResult, anyhow::Error> {
    /// Literal canary intentionally NOT shared with production code:
    /// drift in `DEFAULT_HOST_CGROUP_PARENT`'s value (vs its name)
    /// only surfaces if the test owns its own copy of the expected
    /// string. Update both this const AND the production const
    /// together; the assert below catches the asymmetric case.
    const LITERAL_DEFAULT: &str = "/sys/fs/cgroup/ktstr";

    let expected = ktstr::test_support::DEFAULT_HOST_CGROUP_PARENT;
    if expected != LITERAL_DEFAULT {
        return Ok(AssertResult::fail_msg(format!(
            "DEFAULT_HOST_CGROUP_PARENT value drifted: \
             production = {expected:?}, test canary = {LITERAL_DEFAULT:?}; \
             update both together"
        )));
    }

    // Operator-override path: if KTSTR_HOST_CGROUP_PARENT is set,
    // skip — the operator opted out of the default. Per
    // resolve_host_cgroup_parent, an empty env value still falls
    // back to the default (so the set-empty case keeps testing
    // the default cascade).
    let operator_override = std::env::var(ktstr::KTSTR_HOST_CGROUP_PARENT_ENV)
        .ok()
        .filter(|s| !s.is_empty())
        .is_some();
    if operator_override {
        return Ok(AssertResult::pass());
    }

    let resolved = ktstr::test_support::resolve_host_cgroup_parent()
        .map_err(|e| anyhow::anyhow!("resolve_host_cgroup_parent: {e:#}"))?;
    if resolved != expected {
        return Ok(AssertResult::fail_msg(format!(
            "resolve_host_cgroup_parent fallback drifted: \
             expected {expected:?} (DEFAULT_HOST_CGROUP_PARENT), got {resolved:?}"
        )));
    }
    Ok(AssertResult::pass())
}

/// Pin the opt-in scaling contract end-to-end:
/// `WorkSpec::workers_pct(1.0)` in host_only mode would resolve
/// to the host CPU count because host_only routes topology through
/// `TestTopology::from_system`. The internal
/// `WorkSpec::resolve_workers_pct` is `pub(crate)` — not callable
/// from an integration test — so this pin reduces to the upstream
/// invariant: the topology the ctx exposes (`ctx.topo.all_cpus()`)
/// matches `usable_cpuset()`'s size. That count is what
/// `apply_setup`'s `resolve_workers_pct` call would use as the
/// `cpuset_cpus` argument when the CgroupDef inherits the
/// topology-default cpuset. So asserting `all_cpus().len() ==
/// usable_cpuset().len()` (when no operator CPU restriction is in
/// play) proves a workers_pct(1.0) workload would scale to the
/// host CPU count.
#[ktstr_test(host_only)]
fn host_mode_workers_pct_scales_to_host_cpu_count(
    ctx: &Ctx,
) -> Result<AssertResult, anyhow::Error> {
    let all_cpus = ctx.topo.all_cpus().len();
    let usable = ctx.topo.usable_cpuset().len();
    if usable != all_cpus {
        return Ok(AssertResult::fail_msg(format!(
            "ctx.topo.usable_cpuset().len()={usable} != all_cpus().len()={all_cpus}: \
             the operator restricted CPUs via KTSTR_NO_PERF_MODE / KTSTR_CPU_CAP \
             or a sched_setaffinity policy, so this test cannot prove that \
             workers_pct(1.0) scales to ALL host CPUs. Unset CPU restrictions \
             or skip this test."
        )));
    }
    if all_cpus < 4 {
        // Test environment limitation: cargo-ktstr's no_perf_mode +
        // mount-namespace sandbox narrows sysfs and sched_getaffinity
        // such that `ctx.topo.all_cpus()` reports the test's pinned
        // CPU count (typically 1-2), not the real host's CPU count.
        // Under that narrowing the workers_pct(1.0) scaling cannot
        // be distinguished from the `from_vm_topology` default-topology
        // fallback. PASS with a log line so the constraint is
        // visible; the sibling test `host_mode_sees_real_host_cpus`
        // gates the from_system routing regression via
        // /sys/devices/system/cpu/online cross-check that bypasses
        // the affinity-narrowing layer.
        tracing::warn!(
            all_cpus,
            "host_mode_workers_pct_scales_to_host_cpu_count: ctx.topo \
             reports < 4 CPUs in this test env (sandboxed cargo-ktstr \
             context with narrowed sched_getaffinity); the workers_pct \
             scaling check cannot reliably trigger. Run on a real-host CI \
             with >= 4 visible CPUs to gate."
        );
        return Ok(AssertResult::pass());
    }
    Ok(AssertResult::pass())
}