ktstr/scenario/ops/types/
step.rs

1//! Scenario-step composition: [`Setup`] (defs / factory variants
2//! with manual `Clone`+`Debug`+`Default`+`From` impls), [`Step`]
3//! (ops + setup + hold), [`HoldSpec`] (frac / fixed / loop hold
4//! shapes), plus the [`Op`] / [`OpKind`] helper impls that operate
5//! around step-execution (`Op::discriminant`, `OpKind::bit_index`,
6//! the `Op::*` per-variant constructor surface).
7//!
8//! The `impl Op` blocks here are siblings to the `Op` enum
9//! definition in [`super::op`]; Rust permits multiple impl blocks
10//! across files in the same crate. The split tracks responsibility:
11//! op.rs owns the variant taxonomy + the `CpusetSpec` constructor
12//! surface; this file owns the step-side helpers (bit-index map for
13//! the `op_kinds` bitmask, per-variant constructor sugar).
14
15use std::borrow::Cow;
16use std::time::Duration;
17
18use crate::scenario::Ctx;
19use crate::workload::{AffinityIntent, WorkSpec, WorkType};
20
21use super::{
22    CgroupDef, CpusetSpec, IrqSelector, KernelTarget, KernelValue, KernelValueWidth, Op, OpKind,
23    SpawnPlacement,
24};
25
26// ---------------------------------------------------------------------------
27// Step / HoldSpec
28// ---------------------------------------------------------------------------
29
30/// How to produce the CgroupDefs for a step's setup phase.
31///
32/// Construct via `Setup::Defs(vec)` (variant constructor for a static
33/// list), [`Setup::with_factory`] (runtime-generated from `&Ctx`),
34/// `Setup::default()` (no cgroups — `Setup::Defs(Vec::new())`), or via
35/// [`From<Vec<CgroupDef>>`](Self::from) (`vec![def1, def2].into()`).
36pub enum Setup {
37    /// Static list of cgroup definitions.
38    Defs(Vec<CgroupDef>),
39    /// Factory that generates definitions from the runtime context.
40    Factory(fn(&Ctx) -> Vec<CgroupDef>),
41}
42
43impl Clone for Setup {
44    fn clone(&self) -> Self {
45        match self {
46            Setup::Defs(defs) => Setup::Defs(defs.clone()),
47            Setup::Factory(f) => Setup::Factory(*f),
48        }
49    }
50}
51
52impl std::fmt::Debug for Setup {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Setup::Defs(defs) => f.debug_tuple("Defs").field(defs).finish(),
56            Setup::Factory(_) => f
57                .debug_tuple("Factory")
58                .field(&"fn(&Ctx) -> Vec<CgroupDef>")
59                .finish(),
60        }
61    }
62}
63
64impl Setup {
65    /// Construct a [`Setup::Factory`] from a function pointer.
66    /// `with_factory` per the scenario-module builder convention
67    /// (`with_X` = constructor variant; see the module-level docs
68    /// on [`crate::scenario`]). The `Defs` variant is constructed
69    /// directly via `Setup::Defs(vec)` (variant constructor) or
70    /// `Default::default()` for the empty case; `Factory` needs a
71    /// named constructor because variant construction with a `fn`
72    /// pointer literal is awkward to read.
73    pub const fn with_factory(f: fn(&Ctx) -> Vec<CgroupDef>) -> Self {
74        Setup::Factory(f)
75    }
76
77    pub(in crate::scenario::ops) fn resolve(&self, ctx: &Ctx) -> Vec<CgroupDef> {
78        match self {
79            Setup::Defs(defs) => defs.clone(),
80            Setup::Factory(f) => f(ctx),
81        }
82    }
83
84    pub(in crate::scenario::ops) fn is_empty(&self) -> bool {
85        match self {
86            Setup::Defs(defs) => defs.is_empty(),
87            Setup::Factory(_) => false,
88        }
89    }
90}
91
92impl Default for Setup {
93    /// Empty `Setup::Defs` — no cgroups created. The `Factory` variant
94    /// cannot serve as a Default because it holds a fn pointer with no
95    /// semantic no-op.
96    fn default() -> Self {
97        Setup::Defs(Vec::new())
98    }
99}
100
101impl From<Vec<CgroupDef>> for Setup {
102    fn from(defs: Vec<CgroupDef>) -> Self {
103        Setup::Defs(defs)
104    }
105}
106
107/// A sequence of ops followed by a hold period.
108///
109/// For non-`Loop` steps, `ops` are applied first, then `setup` cgroups
110/// are created, configured, and populated. For `Loop` steps, `setup`
111/// runs once before the ops loop.
112///
113/// Construct via [`Step::new`] (ops-only, no setup), [`Step::with_defs`]
114/// (cgroup setup + hold), or [`Step::with_payload`] (payload-driven step).
115/// For chained mutation of the ops list, [`Step::set_ops`] REPLACES
116/// the existing vec — Backdrop's `extend_ops` semantics (APPEND) are not
117/// mirrored here because Step is single-phase.
118#[derive(Clone, Debug)]
119pub struct Step {
120    /// Cgroup setup applied before (non-`Loop`) or once above (`Loop`)
121    /// the ops list. Runtime cgroups are spawned from this spec.
122    pub setup: Setup,
123    /// Ordered operations applied each time the step body runs:
124    /// cpuset edits, task moves, spawn/despawn, etc.
125    pub ops: Vec<Op>,
126    /// How long, and whether to loop, after the ops finish one pass.
127    pub hold: HoldSpec,
128}
129
130impl Step {
131    /// Create a step with ops only (no CgroupDef setup). Prefer
132    /// this constructor over the struct-literal `Step { setup,
133    /// ops, hold }` form — the constructor preserves struct
134    /// stability across non_exhaustive field additions
135    /// (e.g. future `Step::with_*` builder methods) and is the
136    /// stable surface tracked by [`Self::with_defs`] +
137    /// [`Self::hold`].
138    ///
139    /// ```ignore
140    /// // Common one-liner: spawn workers then hold for 1s.
141    /// let s = Step::new(vec![Op::add_cgroup("cg_a"), Op::spawn(...)],
142    ///                   HoldSpec::Fixed(Duration::from_secs(1)));
143    /// // For setup-only steps (CgroupDef + spawns) use Step::with_defs.
144    /// // For wait-only phases (no ops) use Step::hold.
145    /// ```
146    #[must_use = "dropping a Step discards its ops and hold for that scenario phase"]
147    pub fn new(ops: Vec<Op>, hold: HoldSpec) -> Self {
148        Self {
149            setup: Setup::Defs(Vec::new()),
150            ops,
151            hold,
152        }
153    }
154
155    /// Create a step that applies NO ops and just holds. Sugar for
156    /// `Step::new(vec![], hold)` — the most common shape for "wait
157    /// for the workload to settle before the next op" phases in
158    /// A/B test scenarios.
159    #[must_use = "dropping a Step discards its hold for that scenario phase"]
160    pub fn hold(hold: HoldSpec) -> Self {
161        Self::new(Vec::new(), hold)
162    }
163
164    /// Create a step that applies a single op then holds. Sugar for
165    /// `Step::new(vec![op], hold)` — the most common shape for "swap
166    /// scheduler / attach scheduler / replace scheduler then hold"
167    /// phases in A/B test scenarios.
168    #[must_use = "dropping a Step discards its op and hold for that scenario phase"]
169    pub fn with_op(op: Op, hold: HoldSpec) -> Self {
170        Self::new(vec![op], hold)
171    }
172
173    /// Create a step with CgroupDef setup and a hold period.
174    ///
175    /// Most steps only need cgroup definitions and a hold duration.
176    /// Use [`set_ops`](Step::set_ops) to chain ops onto the step.
177    #[must_use = "dropping a Step discards its CgroupDef setup and hold for that scenario phase"]
178    pub fn with_defs(defs: Vec<CgroupDef>, hold: HoldSpec) -> Self {
179        Self {
180            setup: Setup::Defs(defs),
181            ops: vec![],
182            hold,
183        }
184    }
185
186    /// Replace the ops for a step, consuming and returning it.
187    ///
188    /// Named `set_ops` rather than `extend_ops` because the semantics
189    /// are REPLACE, not EXTEND — contrast
190    /// [`Backdrop::extend_ops`](crate::scenario::backdrop::Backdrop::extend_ops),
191    /// which appends. A chained `Step::new(ops).set_ops(more)`
192    /// drops `ops` and keeps only `more`.
193    #[must_use = "builder methods consume self; bind the result"]
194    pub fn set_ops(mut self, ops: Vec<Op>) -> Self {
195        self.ops = ops;
196        self
197    }
198
199    /// Replace the hold spec for a step, consuming and returning it.
200    /// Sibling of [`set_ops`](Self::set_ops) — both REPLACE a single
201    /// field. Bare-verb `set_` prefix matches `set_ops` for
202    /// prefix-consistency within Step; the convention reserves
203    /// `with_X` for alternative constructors (see [`with_defs`](Self::with_defs),
204    /// [`with_payload`](Self::with_payload)).
205    #[must_use = "builder methods consume self; bind the result"]
206    pub const fn set_hold(mut self, hold: HoldSpec) -> Self {
207        self.hold = hold;
208        self
209    }
210
211    /// Create a step that spawns a single userspace
212    /// [`Payload`](crate::test_support::Payload) binary in the
213    /// background and holds for the given duration before teardown.
214    ///
215    /// Shorthand for `Step::new(vec![Op::run_payload(payload,
216    /// vec![])], hold)`. The returned step is chainable — add
217    /// `.set_ops(...)` to replace the ops vec (note the
218    /// REPLACE-not-EXTEND semantics), or use
219    /// `Op::wait_payload(name)` / `Op::kill_payload(name)` on later
220    /// steps to control the spawned child.
221    ///
222    /// Test authors who want the payload placed in a named cgroup
223    /// should use `Op::run_payload_in_cgroup` directly; this
224    /// convenience targets the common "one payload, whole step"
225    /// shape.
226    #[must_use = "dropping a Step discards its payload and hold for that scenario phase"]
227    pub fn with_payload(payload: &'static crate::test_support::Payload, hold: HoldSpec) -> Self {
228        Self {
229            setup: Setup::Defs(Vec::new()),
230            ops: vec![Op::run_payload(payload, [] as [&str; 0])],
231            hold,
232        }
233    }
234}
235
236impl Default for Step {
237    /// Empty setup, no ops, hold for the full scenario duration
238    /// ([`HoldSpec::FULL`]). Useful as a sentinel in test fixtures
239    /// that compose Steps via `..Default::default()` field overrides.
240    fn default() -> Self {
241        Self {
242            setup: Setup::Defs(Vec::new()),
243            ops: Vec::new(),
244            hold: HoldSpec::FULL,
245        }
246    }
247}
248
249/// How a step advances after its ops are applied. `Frac` and `Fixed`
250/// hold for a duration; `Loop` repeatedly re-applies `Step::ops` at a
251/// fixed interval instead of holding.
252///
253/// Construct via the constructor methods ([`Self::fixed`],
254/// [`Self::frac`], [`Self::loop_at`], or the [`Self::FULL`] const) —
255/// variant syntax is reserved for pattern-matching in `match` arms.
256///
257/// `Copy` because every variant carries only `Copy` types (`f64`,
258/// [`Duration`]); reuse the same `HoldSpec` value across multiple
259/// [`Step::new`] / [`Step::with_defs`] / [`Step::with_payload`]
260/// calls in a construction loop without an explicit `.clone()`.
261/// `PartialEq` is derived so tests can `assert_eq!(step.hold, ...)`
262/// and user code can pattern-compare values directly — float
263/// equality on `Frac(f64)` follows IEEE 754 semantics (so
264/// `HoldSpec::Frac(0.1 + 0.2) != HoldSpec::Frac(0.3)`, and
265/// `HoldSpec::Frac(f64::NAN) != HoldSpec::Frac(f64::NAN)` —
266/// [`Self::validate`] rejects NaN at intake so the non-reflexive
267/// case is unreachable through validated construction, but the
268/// derive inherits the IEEE 754 contract at the type level). `Eq` /
269/// `Hash` remain impossible because `Frac` carries a float.
270#[derive(Clone, Copy, Debug, PartialEq)]
271pub enum HoldSpec {
272    /// Fraction of the total scenario duration.
273    Frac(f64),
274    /// Fixed duration.
275    Fixed(Duration),
276    /// Repeat the step's ops in a loop at the given interval until the
277    /// remaining scenario time is exhausted.
278    Loop { interval: Duration },
279}
280
281impl HoldSpec {
282    /// Hold for the full scenario duration. Equivalent to
283    /// [`HoldSpec::frac(1.0)`](Self::frac) and resolves to
284    /// `ctx.duration` at scenario-run time.
285    pub const FULL: HoldSpec = HoldSpec::Frac(1.0);
286
287    /// Hold for a fixed wall-clock duration. Sugar for
288    /// `HoldSpec::Fixed(d)` that reads naturally in chain position
289    /// (`Step::new(ops, HoldSpec::fixed(Duration::from_secs(5)))`)
290    /// and surfaces in IDE autocomplete next to [`Self::frac`] and
291    /// [`Self::loop_at`].
292    ///
293    /// For the common `settle + duration * fraction` pattern, prefer
294    /// [`Ctx::settled_hold`](crate::scenario::Ctx::settled_hold) over
295    /// `HoldSpec::fixed(ctx.settle + ctx.duration.mul_f64(frac))`.
296    pub const fn fixed(d: Duration) -> HoldSpec {
297        HoldSpec::Fixed(d)
298    }
299
300    /// Hold for a fraction of `ctx.duration` (the scenario duration
301    /// configured on [`crate::scenario::Ctx`]). Sugar for
302    /// `HoldSpec::Frac(f)`; the resolved wall-clock hold is
303    /// `ctx.duration * f` (e.g. `0.5` = half the scenario
304    /// duration). `f` must be finite and `> 0.0` — see
305    /// [`Self::validate`] for the rejection rules.
306    pub const fn frac(f: f64) -> HoldSpec {
307        HoldSpec::Frac(f)
308    }
309
310    /// Repeat the step's ops at the given interval until the
311    /// remaining scenario time is exhausted. Sugar for
312    /// `HoldSpec::Loop { interval }`; the value is `interval`. Must
313    /// be non-zero — see [`Self::validate`] for the rejection rule.
314    ///
315    /// Named `loop_at` (verb-preposition) rather than `r#loop`
316    /// because the variant name `Loop` collides with the Rust
317    /// keyword `loop` — `loop_at` reads as "loop AT this interval"
318    /// and avoids the raw-identifier escape. Sibling constructors
319    /// [`Self::fixed`] / [`Self::frac`] match their variant names
320    /// directly (no keyword conflict).
321    pub const fn loop_at(interval: Duration) -> HoldSpec {
322        HoldSpec::Loop { interval }
323    }
324
325    /// Reject hold values that are vacuous (no-op step) or would
326    /// panic downstream.
327    ///
328    /// Rules:
329    /// - `Fixed(Duration::ZERO)` — valid for settle steps and
330    ///   op-only steps that apply changes without holding.
331    /// - `Frac(f)` with `!f.is_finite()` (NaN/Inf) — propagates into
332    ///   `Duration::from_secs_f64(f)` which panics.
333    /// - `Frac(f)` with `f <= 0.0` — zero is vacuous, negative
334    ///   panics in `Duration::from_secs_f64`.
335    /// - `Loop { interval: Duration::ZERO }` — busy-polls the
336    ///   deadline loop without yielding; almost always a typo.
337    pub fn validate(&self) -> std::result::Result<(), String> {
338        match self {
339            HoldSpec::Fixed(_) => Ok(()),
340            HoldSpec::Frac(f) if !f.is_finite() => Err(format!(
341                "HoldSpec::Frac({f}) is not finite (NaN/Inf) — would \
342                     panic in Duration::from_secs_f64"
343            )),
344            HoldSpec::Frac(f) if *f <= 0.0 => Err(format!(
345                "HoldSpec::Frac({f}) must be > 0.0; negative values \
346                     panic in Duration::from_secs_f64 and zero is vacuous"
347            )),
348            HoldSpec::Loop { interval } if interval.is_zero() => {
349                Err("HoldSpec::Loop { interval: Duration::ZERO } would \
350                     busy-spin the deadline check without yielding; use a \
351                     non-zero interval"
352                    .into())
353            }
354            _ => Ok(()),
355        }
356    }
357}
358
359impl Op {
360    /// Return a unique bit index for each Op variant (for op_kinds bitmask).
361    ///
362    /// Dispatched via [`OpKind`] — the auto-generated fieldless shadow
363    /// enum from `#[derive(strum::EnumDiscriminants)]` on [`Op`]. The
364    /// indirection is load-bearing: `OpKind` also derives `EnumIter`,
365    /// so `op_kind_bit_indices_are_unique_and_contiguous` can
366    /// exhaustively verify every `OpKind` maps to a distinct,
367    /// contiguous bit index — guarding against a new variant slipping
368    /// in with a duplicated or gap-leaving index.
369    pub(in crate::scenario::ops) fn discriminant(&self) -> u32 {
370        OpKind::from(self).bit_index()
371    }
372}
373
374impl OpKind {
375    /// Unique bit index per variant, used by [`Op::discriminant`] for
376    /// the `op_kinds` bitmask. Contiguous from 0 — the
377    /// `op_kind_bit_indices_are_unique_and_contiguous` test iterates
378    /// every variant via `EnumIter` and pins this.
379    pub(in crate::scenario::ops) fn bit_index(self) -> u32 {
380        match self {
381            OpKind::AddCgroup => 0,
382            OpKind::AddCgroupDef => 1,
383            OpKind::RemoveCgroup => 2,
384            OpKind::SetCpuset => 3,
385            OpKind::ClearCpuset => 4,
386            OpKind::SwapCpusets => 5,
387            OpKind::Spawn => 6,
388            OpKind::StopCgroup => 7,
389            OpKind::SetAffinity => 8,
390            OpKind::MoveAllTasks => 9,
391            OpKind::RunPayload => 10,
392            OpKind::WaitPayload => 11,
393            OpKind::KillPayload => 12,
394            OpKind::FreezeCgroup => 13,
395            OpKind::UnfreezeCgroup => 14,
396            OpKind::CaptureSnapshot => 15,
397            OpKind::WatchSnapshot => 16,
398            OpKind::WriteKernelHot => 17,
399            OpKind::WriteKernelCold => 18,
400            OpKind::ReadKernelHot => 19,
401            OpKind::ReadKernelCold => 20,
402            OpKind::AttachScheduler => 21,
403            OpKind::DetachScheduler => 22,
404            OpKind::RestartScheduler => 23,
405            OpKind::ReplaceScheduler => 24,
406            OpKind::PinBpfMap => 25,
407            OpKind::CaptureCgroupProcs => 26,
408            OpKind::SteerIrq => 27,
409        }
410    }
411}
412
413impl Op {
414    /// Create a new cgroup.
415    pub fn add_cgroup(name: impl Into<Cow<'static, str>>) -> Self {
416        Op::AddCgroup { name: name.into() }
417    }
418
419    /// Create a cgroup mid-step from a full [`CgroupDef`].
420    ///
421    /// Mirrors `Step::with_defs(vec![def], ...)` semantics — applies
422    /// the def's cpuset / cpu / memory / io / pids knobs and spawns
423    /// its workers in one op — but at apply-ops time rather than
424    /// during the step's setup pass. Use when the cgroup's
425    /// declaration needs to depend on state observed by an earlier
426    /// op in the same step (e.g. spawn a per-LLC cgroup after an
427    /// `Op::SetCpuset` narrows the parent's available CPUs). The
428    /// dedup check mirrors `apply_setup`'s rejection of name
429    /// collisions with prior Backdrop or step-local CgroupDef
430    /// declarations.
431    pub fn add_cgroup_def(def: CgroupDef) -> Self {
432        Op::AddCgroupDef { def }
433    }
434
435    /// Remove a cgroup (stops its workers first).
436    pub fn remove_cgroup(cgroup: impl Into<Cow<'static, str>>) -> Self {
437        Op::RemoveCgroup {
438            cgroup: cgroup.into(),
439        }
440    }
441
442    /// Set a cgroup's cpuset mid-step.
443    ///
444    /// **Cgroup must be Backdrop-persistent.** A step-local
445    /// CgroupDef (declared in `step.setup`) tears down at the
446    /// step boundary; rebinding its cpuset in a later step
447    /// races the teardown and writes to a non-existent path
448    /// (ENOENT on `/sys/fs/cgroup/.../cpuset.cpus`). Declare
449    /// the cgroup via `Backdrop::new().push_cgroup(...)` +
450    /// `execute_scenario(ctx, backdrop, steps)` so it lives
451    /// across steps. See `tests/phase_pipeline_e2e.rs::phase_pipeline_per_step_cpuset_differs`
452    /// for the canonical pattern.
453    pub fn set_cpuset(cgroup: impl Into<Cow<'static, str>>, cpus: CpusetSpec) -> Self {
454        Op::SetCpuset {
455            cgroup: cgroup.into(),
456            cpus,
457        }
458    }
459
460    /// Clear a cgroup's cpuset (allow all CPUs). Same
461    /// Backdrop-persistence requirement as [`Self::set_cpuset`].
462    pub fn clear_cpuset(cgroup: impl Into<Cow<'static, str>>) -> Self {
463        Op::ClearCpuset {
464            cgroup: cgroup.into(),
465        }
466    }
467
468    /// Swap cpusets between two cgroups.
469    pub fn swap_cpusets(a: impl Into<Cow<'static, str>>, b: impl Into<Cow<'static, str>>) -> Self {
470        Op::SwapCpusets {
471            a: a.into(),
472            b: b.into(),
473        }
474    }
475
476    /// Spawn workers with the given placement. Canonical constructor
477    /// for [`Op::Spawn`]; [`Self::spawn_workers`] / [`Self::spawn_host`]
478    /// / [`Self::spawn_in_cgroup`] are sugar shortcuts for the two
479    /// common placement choices.
480    pub fn spawn(placement: SpawnPlacement, work: WorkSpec) -> Self {
481        Op::Spawn { placement, work }
482    }
483
484    /// Sugar for [`Self::spawn`]`(`[`SpawnPlacement::cgroup`]`(cgroup), work)`.
485    /// Spawns workers in the named cgroup.
486    pub fn spawn_workers(cgroup: impl Into<Cow<'static, str>>, work: WorkSpec) -> Self {
487        Op::Spawn {
488            placement: SpawnPlacement::Cgroup(cgroup.into()),
489            work,
490        }
491    }
492
493    /// Sugar for [`Self::spawn`]`(`[`SpawnPlacement::cgroup`]`(cgroup),
494    /// WorkSpec { work_type, ..WorkSpec::default() })`. Spawn workers
495    /// in a cgroup with the given [`WorkType`] and every other
496    /// [`WorkSpec`] knob defaulted. Sugar for the common single-knob
497    /// spawn case where the test only cares about `work_type` and is
498    /// happy with `Default::default()` for scheduling policy,
499    /// affinity, mempolicy, etc. Mirrors the
500    /// [`CgroupDef::named(...).work_type(...)`](super::CgroupDef::work_type)
501    /// shape at the Op layer so test authors composing mid-step
502    /// spawns get the same one-liner ergonomics as authors composing
503    /// CgroupDefs upfront.
504    ///
505    /// For non-default knobs (worker count, affinity, …) construct
506    /// a [`WorkSpec`] explicitly and route through
507    /// [`Self::spawn`] — the sugar is intentionally minimal so a
508    /// non-default knob forces the explicit-WorkSpec call site.
509    pub fn spawn_in_cgroup(cgroup: impl Into<Cow<'static, str>>, work_type: WorkType) -> Self {
510        Op::Spawn {
511            placement: SpawnPlacement::Cgroup(cgroup.into()),
512            work: WorkSpec {
513                work_type,
514                ..WorkSpec::default()
515            },
516        }
517    }
518
519    /// Stop all workers in a cgroup.
520    pub fn stop_cgroup(cgroup: impl Into<Cow<'static, str>>) -> Self {
521        Op::StopCgroup {
522            cgroup: cgroup.into(),
523        }
524    }
525
526    /// Set worker affinity in a cgroup.
527    pub fn set_affinity(cgroup: impl Into<Cow<'static, str>>, affinity: AffinityIntent) -> Self {
528        Op::SetAffinity {
529            cgroup: cgroup.into(),
530            affinity,
531        }
532    }
533
534    /// Sugar for [`Self::spawn`]`(`[`SpawnPlacement::runner_cgroup`]`(), work)`.
535    /// Spawns workers in the test runner's own cgroup, outside any
536    /// managed workload cgroup. Use [`Self::spawn`] with
537    /// [`SpawnPlacement::cgroup`] when workers must land in a
538    /// specific named cgroup.
539    ///
540    /// # No `spawn_in_host(work_type)` sugar
541    ///
542    /// The named-cgroup placement has a sibling one-arg sugar
543    /// [`Self::spawn_in_cgroup`]`(cgroup, work_type)` — there is
544    /// deliberately no `spawn_in_host(work_type)` parallel. Real
545    /// runner-cgroup spawns (host contention, off-workload noise)
546    /// almost always need an explicit worker count: the topology
547    /// determines saturation, not a single `work_type`. A 1-arg
548    /// sugar would mislead authors into defaulting `num_workers`
549    /// (currently `Some(ctx.workers_per_cgroup)` via
550    /// `resolve_num_workers`) when an explicit
551    /// `.workers(total_cpus)` (mitosis-style host contention) or
552    /// other tuning is the right call. Construct a [`WorkSpec`]
553    /// explicitly via [`Self::spawn`] (or this constructor) when
554    /// you need non-default knobs.
555    ///
556    /// # Errors
557    ///
558    /// Apply-time errors propagate out of
559    /// [`apply_ops`](crate::scenario::ops):
560    /// - `work.mem_policy.validate()` rejects (e.g. interleave
561    ///   with an empty nodemask)
562    /// - `resolve_num_workers` rejects (`num_workers == Some(0)`)
563    /// - `work.workers_pct` is set (RunnerCgroup placement has no
564    ///   managed cgroup cpuset to scale against)
565    /// - underlying `WorkloadHandle::spawn` fails (clone(2) errno)
566    pub fn spawn_host(work: WorkSpec) -> Self {
567        Op::Spawn {
568            placement: SpawnPlacement::RunnerCgroup,
569            work,
570        }
571    }
572
573    /// Move all tasks from one cgroup to another.
574    pub fn move_all_tasks(
575        from: impl Into<Cow<'static, str>>,
576        to: impl Into<Cow<'static, str>>,
577    ) -> Self {
578        Op::MoveAllTasks {
579            from: from.into(),
580            to: to.into(),
581        }
582    }
583
584    /// Spawn a [`Payload`](crate::test_support::Payload) binary in the
585    /// background. `args` is appended to `payload.default_args`.
586    ///
587    /// # Placement
588    ///
589    /// `cgroup` is `None` — the spawned child inherits the cgroup
590    /// of whatever process invoked `apply_ops` (i.e. the test
591    /// runner's own cgroup, NOT any managed workload cgroup
592    /// declared via [`CgroupDef`] or [`Op::AddCgroup`]). To place
593    /// the child in a managed cgroup, use
594    /// [`run_payload_in_cgroup`](Self::run_payload_in_cgroup).
595    /// Matches the "empty-string key" convention
596    /// [`Op::Spawn`] uses for [`SpawnPlacement::RunnerCgroup`]:
597    /// payloads keyed under `None` are addressable by
598    /// [`Op::wait_payload_in_cgroup`] / [`Op::kill_payload_in_cgroup`]
599    /// with an empty-string `cgroup` argument.
600    ///
601    /// # Args ergonomics
602    ///
603    /// `args` accepts any `IntoIterator` of string-convertible items,
604    /// matching [`std::process::Command::args`] ergonomics. Call
605    /// sites can pass `[]`, `["-c", "echo hi"]`, `vec![...]`, or a
606    /// `Vec<String>` without the `vec!["-c".to_string(), ...]`
607    /// ceremony.
608    pub fn run_payload<I, S>(payload: &'static crate::test_support::Payload, args: I) -> Self
609    where
610        I: IntoIterator<Item = S>,
611        S: Into<String>,
612    {
613        Op::RunPayload {
614            payload,
615            args: args.into_iter().map(Into::into).collect(),
616            cgroup: None,
617        }
618    }
619
620    /// Spawn a [`Payload`](crate::test_support::Payload) in the
621    /// background and place the child in a cgroup (relative to the
622    /// scenario's parent cgroup).
623    ///
624    /// # Placement-before-exec invariant
625    ///
626    /// The cgroup placement is performed via
627    /// [`CgroupOps::place_task_during_handshake`](crate::cgroup::CgroupOps::place_task_during_handshake)
628    /// BEFORE the child process execs — between `clone(2)` /
629    /// `fork(2)` and the `execve` that loads the payload binary.
630    /// Per kernel cgroup-v2 semantics any sched_ext callback the
631    /// scheduler installs fires only after the task is placed in
632    /// its final cgroup; a placement-after-exec sequence would
633    /// let the payload run its first instructions under the
634    /// runner's cgroup, racing the sched_ext callback against
635    /// the spawn syscall. The pre-exec gate gives the scheduler
636    /// a clean first observation of every workload pid.
637    ///
638    /// # Args ergonomics
639    ///
640    /// `args` accepts any `IntoIterator` of string-convertible items;
641    /// see [`Self::run_payload`] for the conversion rule.
642    pub fn run_payload_in_cgroup<I, S>(
643        payload: &'static crate::test_support::Payload,
644        args: I,
645        cgroup: impl Into<Cow<'static, str>>,
646    ) -> Self
647    where
648        I: IntoIterator<Item = S>,
649        S: Into<String>,
650    {
651        Op::RunPayload {
652            payload,
653            args: args.into_iter().map(Into::into).collect(),
654            cgroup: Some(cgroup.into()),
655        }
656    }
657
658    /// Block until the payload named `name` exits, evaluate checks,
659    /// and record metrics. Matches whichever cgroup the payload is
660    /// in when exactly one copy of the name is live; bails when two
661    /// or more copies are live (use
662    /// [`wait_payload_in_cgroup`](Self::wait_payload_in_cgroup) to
663    /// disambiguate).
664    ///
665    /// # Sync contract
666    ///
667    /// Event-driven (waits on the payload's pid via
668    /// [`std::process::Child::wait`]); the framework reaps the
669    /// exit synchronously inside `apply_ops` so the next op runs
670    /// only after the payload has fully terminated and its
671    /// `MetricCheck` results are recorded. NO sleep involved.
672    ///
673    /// # Tmpfs vs persistent-disk
674    ///
675    /// Side-effect files the payload writes to `/tmp/*` live on
676    /// the guest's in-memory tmpfs and are deleted at VM teardown
677    /// (post-`vm.run()`). Post-VM `post_vm` callbacks running on
678    /// the host cannot read them — for host-side asserts, persist
679    /// the file to the sidecar dir via the
680    /// `<sidecar_dir>/<test_name>.<suffix>` convention rather than
681    /// `/tmp`, or do the read INSIDE the scenario via a follow-up
682    /// [`Op::run_payload`] that prints the file contents to
683    /// stdout for the framework's output capture.
684    pub fn wait_payload(name: impl Into<Cow<'static, str>>) -> Self {
685        Op::WaitPayload {
686            name: name.into(),
687            cgroup: None,
688        }
689    }
690
691    /// Block until the payload named `name` that's running inside
692    /// the given `cgroup` exits. Use this form when two or more
693    /// copies of the same payload are live in different cgroups
694    /// and a cgroup-less `wait_payload` would be ambiguous. An
695    /// empty-string `cgroup` matches payloads that inherited their
696    /// parent's placement (spawned via `Op::run_payload(..., cgroup:
697    /// None)`); explicit names match payloads placed via
698    /// [`Op::run_payload_in_cgroup`] or
699    /// [`CgroupDef::workload`](crate::scenario::ops::CgroupDef::workload).
700    pub fn wait_payload_in_cgroup(
701        name: impl Into<Cow<'static, str>>,
702        cgroup: impl Into<Cow<'static, str>>,
703    ) -> Self {
704        Op::WaitPayload {
705            name: name.into(),
706            cgroup: Some(cgroup.into()),
707        }
708    }
709
710    /// SIGKILL the payload named `name`, evaluate checks, and record
711    /// metrics. Matches the unique live copy by name; bails on
712    /// ambiguity. See [`wait_payload`](Self::wait_payload) for the
713    /// full ambiguity rules and
714    /// [`kill_payload_in_cgroup`](Self::kill_payload_in_cgroup)
715    /// for the disambiguating form.
716    pub fn kill_payload(name: impl Into<Cow<'static, str>>) -> Self {
717        Op::KillPayload {
718            name: name.into(),
719            cgroup: None,
720        }
721    }
722
723    /// SIGKILL the payload named `name` that's running inside the
724    /// given `cgroup`. See
725    /// [`wait_payload_in_cgroup`](Self::wait_payload_in_cgroup) for
726    /// the placement-matching contract.
727    pub fn kill_payload_in_cgroup(
728        name: impl Into<Cow<'static, str>>,
729        cgroup: impl Into<Cow<'static, str>>,
730    ) -> Self {
731        Op::KillPayload {
732            name: name.into(),
733            cgroup: Some(cgroup.into()),
734        }
735    }
736
737    /// Freeze every task in a cgroup via `cgroup.freeze`.
738    pub fn freeze_cgroup(cgroup: impl Into<Cow<'static, str>>) -> Self {
739        Op::FreezeCgroup {
740            cgroup: cgroup.into(),
741        }
742    }
743
744    /// Unfreeze every task in a cgroup via `cgroup.freeze`.
745    pub fn unfreeze_cgroup(cgroup: impl Into<Cow<'static, str>>) -> Self {
746        Op::UnfreezeCgroup {
747            cgroup: cgroup.into(),
748        }
749    }
750
751    /// Capture a host-side diagnostic snapshot under `name`. See
752    /// [`Op::CaptureSnapshot`] for the full request/reply protocol and
753    /// no-bridge fallback semantics.
754    pub fn capture_snapshot(name: impl Into<Cow<'static, str>>) -> Self {
755        Op::CaptureSnapshot { name: name.into() }
756    }
757
758    /// Register a write-driven snapshot watch on `symbol`. See
759    /// [`Op::WatchSnapshot`] for the symbol-resolution rules and
760    /// guard rails (max 3 watches per scenario, verbatim vmlinux
761    /// ELF symtab match, 4-byte alignment requirement).
762    pub fn watch_snapshot(symbol: impl Into<Cow<'static, str>>) -> Self {
763        Op::WatchSnapshot {
764            symbol: symbol.into(),
765        }
766    }
767
768    /// Capture the current `cgroup.procs` of `cgroup` under `tag`.
769    /// See [`Op::CaptureCgroupProcs`] for the full per-call contract
770    /// (within-step ordering, PID vs TID grain, empty / unknown
771    /// cgroup behavior, tag uniqueness, drain mechanism).
772    pub fn capture_cgroup_procs(
773        tag: impl Into<Cow<'static, str>>,
774        cgroup: impl Into<Cow<'static, str>>,
775    ) -> Self {
776        Op::CaptureCgroupProcs {
777            tag: tag.into(),
778            cgroup: cgroup.into(),
779        }
780    }
781
782    /// Re-steer the IRQ named by `irq` to Linux processor `cpu` by
783    /// writing `/proc/irq/<N>/smp_affinity_list` in the guest. See
784    /// [`Op::SteerIrq`] for the full contract: the in-guest-write vs
785    /// kernel-poke distinction, the online-CPU pre-check, and the
786    /// system-wide (not cpuset-scoped) affinity semantics. Construct
787    /// the selector with [`IrqSelector::by_number`] or
788    /// [`IrqSelector::by_label`].
789    pub fn steer_irq(irq: IrqSelector, cpu: usize) -> Self {
790        Op::SteerIrq { irq, cpu }
791    }
792
793    /// Live-vCPU write of a single (target, value) pair. Singleton
794    /// convenience that wraps the pair into the
795    /// [`Op::WriteKernelHot`] batch shape. See the variant doc for
796    /// the live-vCPU orchestration contract and the
797    /// caller-side-synchronisation requirement.
798    ///
799    /// **Two consecutive singleton calls produce two separate Ops**
800    /// — not auto-merged at construction time. For dispatching
801    /// multiple hot writes as a single op, use
802    /// [`Self::write_kernel_hot_batch`]. The executor's
803    /// adjacent-op auto-merge (which would collapse N adjacent
804    /// singleton hot writes into one dispatch) is not
805    /// implemented; each `write_kernel_hot` call is its own
806    /// dispatch.
807    pub fn write_kernel_hot(target: KernelTarget, value: KernelValue) -> Self {
808        Op::WriteKernelHot {
809            writes: vec![(target, value)],
810        }
811    }
812
813    /// Live-vCPU batched write. See [`Op::WriteKernelHot`] for the
814    /// live-vCPU orchestration contract. The batch is issued in
815    /// iteration order.
816    pub fn write_kernel_hot_batch(
817        writes: impl IntoIterator<Item = (KernelTarget, KernelValue)>,
818    ) -> Self {
819        Op::WriteKernelHot {
820            writes: writes.into_iter().collect(),
821        }
822    }
823
824    /// Auto-freezing write of a single (target, value) pair.
825    /// Singleton convenience that wraps the pair into the
826    /// [`Op::WriteKernelCold`] batch shape. See the variant doc for
827    /// the rendezvous-and-batched-writes contract and the
828    /// no-inter-CPU-skew guarantee.
829    ///
830    /// **Two consecutive singleton calls fold into one Op via the
831    /// executor's pre-pass.** `apply_ops` auto-merges adjacent
832    /// `Op::WriteKernelCold` ops (singletons OR batches) into one
833    /// merged op that lands in a single freeze rendezvous — no
834    /// inter-CPU skew. [`Self::write_kernel_cold_batch`] is still
835    /// the preferred shape when the batch is known at construction
836    /// time; the auto-merge is a safety net for code that emits
837    /// singletons (e.g. fan-out generators). Reads
838    /// ([`Op::ReadKernelCold`]) act as a hard barrier in the
839    /// pre-pass and do NOT fold with adjacent cold writes.
840    pub fn write_kernel_cold(target: KernelTarget, value: KernelValue) -> Self {
841        Op::WriteKernelCold {
842            writes: vec![(target, value)],
843        }
844    }
845
846    /// Auto-freezing batched write. See [`Op::WriteKernelCold`] for
847    /// the freeze-rendezvous-and-batched-writes contract. All
848    /// writes in the batch land within a single freeze rendezvous —
849    /// no inter-CPU skew from N separate freeze cycles.
850    pub fn write_kernel_cold_batch(
851        writes: impl IntoIterator<Item = (KernelTarget, KernelValue)>,
852    ) -> Self {
853        Op::WriteKernelCold {
854            writes: writes.into_iter().collect(),
855        }
856    }
857
858    /// Live-vCPU read of `target` into the snapshot bridge keyed by
859    /// `tag`, with explicit `width` picking the read family
860    /// ([`KernelValueWidth::u32`] / [`KernelValueWidth::u64`] /
861    /// [`KernelValueWidth::bytes`]). See [`Op::ReadKernelHot`] for
862    /// the live-vCPU orchestration contract and the
863    /// read-vs-guest-write race caveat.
864    ///
865    /// Reads are singleton-only: each read produces one bridge
866    /// entry keyed by `tag`. A batched read (multi-target single op)
867    /// is a future surface — it would need either Vec<(tag, target)>
868    /// with parallel result slots or a HashMap result, both
869    /// distinct contracts from the write batch's "do N writes".
870    /// For now, dispatch N reads as N separate ops.
871    pub fn read_kernel_hot(
872        tag: impl Into<Cow<'static, str>>,
873        target: KernelTarget,
874        width: KernelValueWidth,
875    ) -> Self {
876        Op::ReadKernelHot {
877            tag: tag.into(),
878            target,
879            width,
880        }
881    }
882
883    /// Auto-freezing read of `target` into the snapshot bridge keyed
884    /// by `tag`, with explicit `width` picking the read family.
885    /// See [`Op::ReadKernelCold`] for the rendezvous-coherent-read
886    /// contract and [`Self::read_kernel_hot`] for the
887    /// singleton-only rationale.
888    ///
889    /// Each `read_kernel_cold` triggers its own freeze rendezvous.
890    /// `apply_ops`'s pre-pass folds adjacent `Op::WriteKernelCold`
891    /// ops but does NOT fold reads — per-entry wire tags are
892    /// needed for the multi-read reply-routing contract (queued
893    /// as a wire-format follow-up). Where multiple cold reads are
894    /// needed within the same coherent snapshot, prefer
895    /// [`Op::CaptureSnapshot`] (which already orchestrates a single
896    /// rendezvous for all snapshot reads).
897    pub fn read_kernel_cold(
898        tag: impl Into<Cow<'static, str>>,
899        target: KernelTarget,
900        width: KernelValueWidth,
901    ) -> Self {
902        Op::ReadKernelCold {
903            tag: tag.into(),
904            target,
905            width,
906        }
907    }
908
909    /// Attach a scheduler mid-scenario. See [`Op::AttachScheduler`]
910    /// for semantics including the staged-binary requirement and
911    /// idempotency rules.
912    pub const fn attach_scheduler(scheduler: &'static crate::test_support::Scheduler) -> Self {
913        Op::AttachScheduler { scheduler }
914    }
915
916    /// Detach the currently-running scheduler. See
917    /// [`Op::DetachScheduler`] for SCX-detach observation semantics
918    /// and the no-scheduler-is-no-op rule.
919    pub const fn detach_scheduler() -> Self {
920        Op::DetachScheduler
921    }
922
923    /// Detach and re-attach the currently-running scheduler with the
924    /// same spec. See [`Op::RestartScheduler`].
925    pub const fn restart_scheduler() -> Self {
926        Op::RestartScheduler
927    }
928
929    /// Detach the currently-running scheduler and attach a different
930    /// one. See [`Op::ReplaceScheduler`] for the mid-experiment swap
931    /// use case (run mitosis with one args set, swap to mitosis with
932    /// another args set declared as a distinct `&'static Scheduler`,
933    /// assert per-phase metric delta across the boundary).
934    pub const fn replace_scheduler(scheduler: &'static crate::test_support::Scheduler) -> Self {
935        Op::ReplaceScheduler { scheduler }
936    }
937
938    /// Pin a BPF map by name, holding a refcount on it for the
939    /// scenario lifetime. See [`Op::PinBpfMap`] for the
940    /// motivation (force the same-binary swap-window multi-bss
941    /// case to persist long enough for at least one post-swap
942    /// freeze to observe both bss copies), the ordering
943    /// requirement (pin BEFORE `Op::ReplaceScheduler`), and the
944    /// idempotence contract (same-name re-pin is a no-op; the
945    /// originally-pinned map instance is the one held).
946    pub fn pin_bpf_map(name: impl Into<Cow<'static, str>>) -> Self {
947        Op::PinBpfMap { name: name.into() }
948    }
949}