ktstr/test_support/
shell_descriptor.rs

1//! Shared `ShellTestDescriptor` for the `--ktstr-shell-test=<NAME>`
2//! probe wire format.
3//!
4//! Producer: `dispatch::maybe_dispatch_shell_test` serializes a
5//! fully-populated descriptor to stdout when a test binary is probed.
6//!
7//! Consumer: `cargo_ktstr::misc::shell::resolve_shell_from_test_entry`
8//! deserializes the stdout, then `run_shell` passes the descriptor's
9//! fields to [`crate::run_shell`] so the shell VM mirrors the test's
10//! topology, scheduler, wprof, and performance settings.
11//!
12//! Older `cargo-ktstr` binaries can deserialize JSON emitted by a
13//! newer test binary that adds a field because the derived
14//! `Deserialize` impl ignores unknown fields (no
15//! `#[serde(deny_unknown_fields)]`): the field the old struct doesn't
16//! know about is dropped, and existing fields remain populated. The
17//! reverse direction (newer `cargo-ktstr` reading older JSON that
18//! lacks a field the new code added) works because every field carries
19//! `#[serde(default)]`, which supplies a default for each missing
20//! field.
21
22use serde::{Deserialize, Serialize};
23
24/// Discriminator for the test's scheduler-spec shape on the
25/// `--ktstr-shell-test=<NAME>` wire format. Wire-byte-compatible with
26/// the prior stringly-typed `"eevdf" | "discover" | "path" |
27/// "kernel_builtin"` values via `#[serde(rename_all = "snake_case")]`;
28/// the typed enum replaces the stringly-typed boundary so a rename on
29/// either side (producer in `dispatch::maybe_dispatch_shell_test`,
30/// consumer in `cargo_ktstr::misc::shell`) is a compile error instead
31/// of a silent banner-emit-gate regression.
32///
33/// 1:1 with [`crate::test_support::SchedulerSpec`]'s 4 variants; the
34/// payload data (scheduler binary name, KernelBuiltin enable/disable
35/// commands) rides separately on the descriptor's `scheduler_name` /
36/// `scheduler_enable_cmds` / `scheduler_disable_cmds` fields so this
37/// type carries only the discriminator.
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum SchedulerKind {
41    /// Kernel-default scheduling — no userspace binary, no
42    /// kernel-builtin lifecycle. The no-scx control.
43    #[default]
44    Eevdf,
45    /// Userspace scx binary located via `resolve_scheduler` cascade
46    /// (name-only, path is discovered at run time).
47    Discover,
48    /// Userspace scx binary at a fully-qualified path.
49    Path,
50    /// In-kernel scheduling class activated via `scheduler_enable_cmds`
51    /// before workload start, torn down via `scheduler_disable_cmds`
52    /// on shell exit / test teardown.
53    KernelBuiltin,
54}
55
56impl std::fmt::Display for SchedulerKind {
57    /// Renders the snake_case wire form (`"eevdf"`, `"discover"`,
58    /// `"path"`, `"kernel_builtin"`) so banner format strings + log
59    /// lines stay byte-compatible with the prior stringly-typed
60    /// behavior.
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        let s = match self {
63            SchedulerKind::Eevdf => "eevdf",
64            SchedulerKind::Discover => "discover",
65            SchedulerKind::Path => "path",
66            SchedulerKind::KernelBuiltin => "kernel_builtin",
67        };
68        f.write_str(s)
69    }
70}
71
72impl From<&crate::test_support::SchedulerSpec> for SchedulerKind {
73    /// Single source of truth for the SchedulerSpec → SchedulerKind
74    /// mapping. Exhaustive match so adding a 5th SchedulerSpec
75    /// variant triggers a compile error here, forcing the
76    /// discriminator to grow in lockstep.
77    fn from(spec: &crate::test_support::SchedulerSpec) -> Self {
78        match spec {
79            crate::test_support::SchedulerSpec::Eevdf => SchedulerKind::Eevdf,
80            crate::test_support::SchedulerSpec::Discover(_) => SchedulerKind::Discover,
81            crate::test_support::SchedulerSpec::Path(_) => SchedulerKind::Path,
82            crate::test_support::SchedulerSpec::KernelBuiltin { .. } => {
83                SchedulerKind::KernelBuiltin
84            }
85        }
86    }
87}
88
89/// Wire-format descriptor exchanged between a test binary and
90/// `cargo ktstr shell --test <NAME>` to let the shell VM mirror the
91/// named `#[ktstr_test]`'s topology, scheduler, wprof config, and
92/// performance mode.
93///
94/// `scheduler_kind` is a typed [`SchedulerKind`] discriminator so the
95/// banner can hint at how to repro the scheduler (Discover/Path =
96/// userspace binary at `/bin/<n>`; KernelBuiltin = no binary, runs
97/// `scheduler_enable_cmds` before drop-to-shell and
98/// `scheduler_disable_cmds` on shell exit; Eevdf = no setup needed).
99///
100/// `scheduler_enable_cmds` and `scheduler_disable_cmds` are extracted
101/// from the [`crate::test_support::SchedulerSpec::KernelBuiltin`]
102/// variant's `enable` and `disable` slices respectively; the other
103/// three variants emit empty vecs (no kernel-builtin shell ops to
104/// invoke).
105///
106/// `wprof_args`: requires the `wprof` cargo feature. When the
107/// feature is enabled and `Some`, replaces `WprofConfig::args`;
108/// without the feature, the value is ignored by `run_shell`.
109/// `None` means "use the default wprof args."
110///
111/// `performance_mode` mirrors the test's
112/// `#[ktstr_test(performance_mode)]` attribute so the shell VM
113/// reproduces vCPU pinning, hugepages, NUMA mbind, and SCHED_FIFO
114/// promotion when the test requested them.
115#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
116pub struct ShellTestDescriptor {
117    #[serde(default)]
118    pub numa_nodes: u32,
119    #[serde(default)]
120    pub llcs: u32,
121    #[serde(default)]
122    pub cores: u32,
123    #[serde(default)]
124    pub threads: u32,
125    #[serde(default)]
126    pub memory_mib: u32,
127    #[serde(default)]
128    pub wprof: bool,
129    #[serde(default)]
130    pub extra_include_files: Vec<String>,
131    #[serde(default)]
132    pub scheduler_name: String,
133    #[serde(default)]
134    pub scheduler_kind: SchedulerKind,
135    /// Custom wprof CLI args (requires the `wprof` cargo feature).
136    /// When `Some` and the feature is enabled, the shell VM
137    /// overrides `WprofConfig::args` with the space-tokenised
138    /// value before booting.
139    #[serde(default)]
140    pub wprof_args: Option<String>,
141    /// Mirrors `KtstrTestEntry::performance_mode`. The shell VM
142    /// forwards this to
143    /// `crate::vmm::KtstrVmBuilder::performance_mode`.
144    #[serde(default)]
145    pub performance_mode: bool,
146    /// Shell commands invoked before drop-to-busybox when the test's
147    /// scheduler is a
148    /// [`crate::test_support::SchedulerSpec::KernelBuiltin`] variant —
149    /// empty vec for the other three variants. Populated from the
150    /// variant's `enable` slice.
151    #[serde(default)]
152    pub scheduler_enable_cmds: Vec<String>,
153    /// Shell commands invoked on shell exit when the test's scheduler
154    /// is a [`crate::test_support::SchedulerSpec::KernelBuiltin`]
155    /// variant — empty vec for the other three variants. Populated
156    /// from the variant's `disable` slice.
157    #[serde(default)]
158    pub scheduler_disable_cmds: Vec<String>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn fully_populated() -> ShellTestDescriptor {
166        ShellTestDescriptor {
167            numa_nodes: 2,
168            llcs: 4,
169            cores: 6,
170            threads: 2,
171            memory_mib: 4096,
172            wprof: true,
173            extra_include_files: vec!["a:/x".to_string(), "b:/y".to_string()],
174            scheduler_name: "scx_test".to_string(),
175            scheduler_kind: SchedulerKind::KernelBuiltin,
176            wprof_args: Some("-d 2000 -e sched,irq".to_string()),
177            performance_mode: true,
178            scheduler_enable_cmds: vec!["echo on > /sys/kernel/debug/foo".to_string()],
179            scheduler_disable_cmds: vec!["echo off > /sys/kernel/debug/foo".to_string()],
180        }
181    }
182
183    #[test]
184    fn roundtrip_preserves_every_field() {
185        let original = fully_populated();
186        let json = serde_json::to_string(&original).expect("serialize fully-populated descriptor");
187        let parsed: ShellTestDescriptor =
188            serde_json::from_str(&json).expect("deserialize fully-populated descriptor");
189        assert_eq!(parsed, original);
190    }
191
192    #[test]
193    fn missing_new_fields_default_to_empty() {
194        // JSON shape emitted by an older test binary that doesn't
195        // know about wprof_args, performance_mode,
196        // scheduler_enable_cmds, or scheduler_disable_cmds. The
197        // four new fields are absent — each must take its serde
198        // default (None, false, empty vec) rather than failing the
199        // deserialize.
200        let legacy_json = r#"{
201            "numa_nodes": 1,
202            "llcs": 1,
203            "cores": 2,
204            "threads": 1,
205            "memory_mib": 1024,
206            "wprof": false,
207            "extra_include_files": [],
208            "scheduler_name": "scx_legacy",
209            "scheduler_kind": "discover"
210        }"#;
211        let parsed: ShellTestDescriptor =
212            serde_json::from_str(legacy_json).expect("legacy JSON missing new fields must parse");
213        assert_eq!(parsed.wprof_args, None);
214        assert!(!parsed.performance_mode);
215        assert!(parsed.scheduler_enable_cmds.is_empty());
216        assert!(parsed.scheduler_disable_cmds.is_empty());
217        // Existing fields still populate correctly.
218        assert_eq!(parsed.numa_nodes, 1);
219        assert_eq!(parsed.scheduler_name, "scx_legacy");
220        // Pin the snake_case-string → enum conversion at the
221        // legacy-JSON forward-compat boundary: a regression that
222        // dropped the rename-all gate would deserialize Discover
223        // as Eevdf default and break the consumer's banner branch.
224        assert_eq!(parsed.scheduler_kind, SchedulerKind::Discover);
225    }
226
227    /// Per-variant wire-roundtrip pin. Catches regressions that
228    /// rename a variant's snake_case form (e.g. accidentally
229    /// dropping the `_` in `kernel_builtin`) or break a single
230    /// variant's serde codepath while leaving the others working.
231    #[test]
232    fn scheduler_kind_serde_each_variant_roundtrips_snake_case() {
233        for (variant, wire) in [
234            (SchedulerKind::Eevdf, "\"eevdf\""),
235            (SchedulerKind::Discover, "\"discover\""),
236            (SchedulerKind::Path, "\"path\""),
237            (SchedulerKind::KernelBuiltin, "\"kernel_builtin\""),
238        ] {
239            let json = serde_json::to_string(&variant).expect("serialize");
240            assert_eq!(json, wire, "serialize mismatch for {variant:?}");
241            let back: SchedulerKind = serde_json::from_str(wire).expect("deserialize");
242            assert_eq!(back, variant, "roundtrip mismatch for {variant:?}");
243        }
244    }
245
246    /// Display impl must produce the same lowercase strings the
247    /// wire format does. Banner formatting depends on this
248    /// equivalence — a drift between Display and serde would
249    /// silently print a string the operator can't grep-correlate
250    /// with the JSON shape they'd see in any tooling output.
251    #[test]
252    fn scheduler_kind_display_matches_serde_snake_case() {
253        for variant in [
254            SchedulerKind::Eevdf,
255            SchedulerKind::Discover,
256            SchedulerKind::Path,
257            SchedulerKind::KernelBuiltin,
258        ] {
259            let json = serde_json::to_string(&variant).expect("serialize");
260            let unquoted = json.trim_matches('"');
261            assert_eq!(
262                variant.to_string(),
263                unquoted,
264                "Display must equal serde wire form for {variant:?}",
265            );
266        }
267    }
268
269    /// From<&SchedulerSpec> exhaustive coverage — pins each
270    /// variant's mapping. Catches typo regressions like
271    /// `SchedulerSpec::Path → SchedulerKind::Discover` that the
272    /// compile-time exhaustive match wouldn't catch (both arms
273    /// would still type-check; the wrong-mapping is silent).
274    #[test]
275    fn scheduler_kind_from_scheduler_spec_per_variant() {
276        use crate::test_support::SchedulerSpec;
277        assert_eq!(
278            SchedulerKind::from(&SchedulerSpec::Eevdf),
279            SchedulerKind::Eevdf,
280        );
281        assert_eq!(
282            SchedulerKind::from(&SchedulerSpec::Discover("scx_test")),
283            SchedulerKind::Discover,
284        );
285        assert_eq!(
286            SchedulerKind::from(&SchedulerSpec::Path("/bin/scx_test")),
287            SchedulerKind::Path,
288        );
289        assert_eq!(
290            SchedulerKind::from(&SchedulerSpec::KernelBuiltin {
291                enable: &[],
292                disable: &[],
293            }),
294            SchedulerKind::KernelBuiltin,
295        );
296    }
297
298    /// Unknown wire values MUST fail deserialize rather than
299    /// silently fall back to a default. Pins the typed-enum
300    /// strictness: a regression to `#[serde(other)]` catchall or
301    /// re-introduction of a String field would silently accept the
302    /// junk value and break consumer banner gates.
303    #[test]
304    fn scheduler_kind_unknown_variant_fails_deserialize() {
305        let r: Result<SchedulerKind, _> = serde_json::from_str("\"rust\"");
306        assert!(r.is_err(), "unknown variant 'rust' must reject; got {r:?}",);
307    }
308
309    #[test]
310    fn missing_every_field_yields_full_defaults() {
311        // Pathological case: an empty object. Every field is
312        // #[serde(default)] so deserialize succeeds and every
313        // field becomes its type default. Forward-compat applies to
314        // existing fields too — a future cargo-ktstr that adds yet
315        // another field shouldn't choke on an old JSON missing the
316        // new field, and the same logic protects existing fields
317        // against a JSON shape they happen to be missing from.
318        let empty_json = "{}";
319        let parsed: ShellTestDescriptor =
320            serde_json::from_str(empty_json).expect("empty JSON must parse with all defaults");
321        assert_eq!(parsed.numa_nodes, 0);
322        assert_eq!(parsed.scheduler_name, "");
323        // SchedulerKind::Default = Eevdf, the no-scx control — the
324        // safest fallback for an empty object: the consumer's
325        // KernelBuiltin/non-Eevdf banner gates won't fire, matching
326        // the prior empty-string behavior where the !="kernel_builtin"
327        // and !="eevdf" string compares fell through.
328        assert_eq!(parsed.scheduler_kind, SchedulerKind::Eevdf);
329        assert!(parsed.extra_include_files.is_empty());
330        assert_eq!(parsed.wprof_args, None);
331        assert!(!parsed.performance_mode);
332        assert!(parsed.scheduler_enable_cmds.is_empty());
333        assert!(parsed.scheduler_disable_cmds.is_empty());
334    }
335}