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}