ktstr 0.6.0

Test harness for Linux process schedulers
# Custom Scenarios

For dynamic scenarios (cgroup creation/removal, cpuset changes), prefer
the [ops/steps system](../concepts/ops.md) over a hand-written custom
scenario function.

A custom scenario is just a `fn(&Ctx) -> Result<AssertResult>` that
runs in place of one of the canned `scenarios::*` entry points. Use
this shape only when you need logic that the ops system cannot
express.

## Writing a custom scenario

```rust,ignore
use ktstr::prelude::*;
use ktstr::scenario::*;

fn my_custom_scenario(ctx: &Ctx) -> Result<AssertResult> {
    let wl = dfl_wl(ctx);
    let (handles, _guard) = setup_cgroups(ctx, 2, &wl)?;

    // Custom logic: resize cpusets, move workers, etc.
    std::thread::sleep(ctx.duration);

    Ok(collect_all(handles, &ctx.assert))
}
```

## Helper functions

**`setup_cgroups(ctx, n, wl)`** -- creates N cgroups, spawns workers,
returns `Result<(Vec<`[`WorkloadHandle`](../architecture/workload-handle.md)`>, `[`CgroupGroup`](../architecture/cgroup-group.md)`)>`.
Bind the `CgroupGroup` to a named variable (e.g. `_guard`) so it
lives until end of scope.
See [CgroupGroup](../architecture/cgroup-group.md) for drop semantics.

> **Imports:** `setup_cgroups` and `dfl_wl` live in `ktstr::scenario`,
> not in the prelude. The `use ktstr::scenario::*;` line in the
> example above is required — `use ktstr::prelude::*;` alone does
> not bring them into scope.

**`collect_all(handles, checks)`** -- stops all workers, collects
reports, runs worker-level checks when `checks.has_worker_checks()`
is true; when no worker checks are configured the merge step is
skipped entirely and the result stays `pass` (no implicit
"`assert_not_starved` fallback"). Merges results: if any worker
group fails an enabled check, the overall result fails.

**`dfl_wl(ctx)`** -- creates a `WorkloadConfig` with
`ctx.workers_per_cgroup` workers and default settings.

**`spawn_diverse(ctx, cgroup_names)`** -- spawns different
[work types](../concepts/work-types.md) across cgroups, rotating
through (SpinWait, Bursty{50ms burst / 100ms sleep}, IoSyncWrite,
Mixed, YieldHeavy). Each cgroup uses `ctx.workers_per_cgroup`
workers except IoSyncWrite cgroups, which always use 2 workers so
blocking IO does not drown the scenario.

## The Ctx struct

Custom scenarios receive a `Ctx` reference:

```rust,ignore
pub struct Ctx<'a> {
    pub cgroups: &'a dyn CgroupOps,
    pub topo: &'a TestTopology,
    pub duration: Duration,
    pub workers_per_cgroup: usize,
    pub sched_pid: Option<libc::pid_t>,
    pub settle: Duration,
    pub work_type_override: Option<WorkType>,
    pub assert: Assert,
    pub wait_for_map_write: bool,
    pub current_step: Arc<AtomicU16>,
}
```

`current_step` is the live phase counter — `0` during BASELINE,
`1..=N` while step ordinal N is running. Read via
`ctx.current_step.load(Ordering::Acquire)` to gate scenario-side
behavior on phase. The host-side periodic-capture pipeline stamps
each capture with the same value.

**`cgroups`** -- create/remove cgroups, set cpusets, move tasks. The
slot is a `&dyn CgroupOps` trait object, not a concrete
[`CgroupManager`](../architecture/cgroup-manager.md), so tests can
substitute a no-op double for host-only scenarios while production
paths receive the real manager. Method signatures are defined on
`CgroupOps`; see `CgroupManager` for the production implementation.

**`topo`** -- query CPU topology (LLCs, NUMA nodes, memory info,
distances). Provides CPU enumeration, LLC/NUMA partitioning, cpuset
generation, and inter-node distance queries. See
[TestTopology](../concepts/topology.md) for the full API reference.

**`sched_pid`** -- scheduler process ID (`Option<libc::pid_t>`) for
liveness checks. `None` when the test runs without an scx scheduler
(the EEVDF default path has no userspace scheduler binary). Unwrap
or `is_some_and(...)` before passing to `process_alive` or
`kill(Pid::from_raw(pid), None)`.

**`settle`** -- time to wait after cgroup creation for the scheduler
to stabilize.

**`duration`** -- total scenario wall-clock budget. Custom scenarios
that hold workers running for a fixed period should sleep for this
duration after `start()` and before `stop_and_collect()`. Honoring
this value keeps custom scenarios composable with the gauntlet
budget controller and with `#[ktstr_test(duration_s = N)]` overrides.

**`workers_per_cgroup`** -- default per-cgroup worker count derived
from the test's `workers` attribute (or the topology when unset).
`dfl_wl(ctx)` pre-fills this. Custom scenarios that hand-build a
`WorkloadConfig` should source `num_workers` from this field unless
the test explicitly wants a different value.

**`work_type_override`** -- gauntlet-supplied or programmatic
`WorkType` to swap in for any `CgroupDef` whose `swappable = true`
and for the `dfl_wl` default. `None` keeps the per-cgroup work type
the scenario specified.

**`assert`** -- the merged `Assert` (`default_checks() →
scheduler → per-test`) the test framework expects the scenario to
evaluate against. Pass to `collect_all(handles, &ctx.assert)` or
the manual `assert.assert_cgroup(...)` paths.

**`wait_for_map_write`** -- when `true`, the framework will not
spawn workers until the test's `bpf_map_write` declarations have
been applied. Custom scenarios that don't gate worker spawn on BPF
map state can ignore this field; the framework wires it
automatically.

## Checking in custom scenarios

Use `Assert` for both direct report checking and ops-based scenarios.
Call `assert.assert_cgroup(reports, cpuset)` for manual report
collection, or use `execute_steps_with()` for ops-based scenarios. See
[Checking](../concepts/checking.md#worker-checks-via-assert).