ktstr/scenario/
backdrop.rs

1//! Persistent scenario state that lives across every Step in a
2//! `#[ktstr_test]` run.
3//!
4//! Tests usually express "a scheduler is under load for N seconds"
5//! as a Step sequence. Some tests also want entities that persist
6//! for the WHOLE run — a long-running binary payload, a synthetic
7//! workload that spans the whole scenario, a cgroup whose identity
8//! is referenced by multiple Steps. Those go in a [`Backdrop`].
9//!
10//! # Step vs Backdrop
11//!
12//! - A [`Step`](super::ops::Step) is bounded: everything it creates
13//!   (cgroups, workload handles, payload handles) is torn down when
14//!   the step finishes. The runtime enforces this automatically —
15//!   no explicit teardown op is required.
16//! - A [`Backdrop`] is persistent: what it sets up lives for the
17//!   entire Step sequence. Its cgroups are created once before the
18//!   first Step and RAII-removed at scenario end; its payloads
19//!   spawn once and are killed (with metric emission) after the
20//!   last Step tears down.
21//!
22//! In the "bursty load + scheduler stress test" pattern:
23//!
24//! - The bursty payload (a persistent fio, a running stress-ng) is
25//!   a `Backdrop::push_payload(...)` entry — it runs THROUGHOUT the
26//!   test, irrespective of which Step is currently applying ops.
27//! - Each Step handles a discrete phase ("settle", "inject
28//!   contention", "measure") with its own CgroupDefs that come and
29//!   go.
30//!
31//! Steps may reference Backdrop-owned cgroups by name through
32//! cgroup-addressing ops (`Op::SetCpuset`, `Op::MoveAllTasks`, etc.)
33//! — name lookups resolve step-local first, then fall through to
34//! the Backdrop. Step-local cgroups must not shadow a Backdrop
35//! cgroup name. Step-local `Op::RemoveCgroup` / `Op::StopCgroup`
36//! targeting a Backdrop cgroup is permitted; later Steps that
37//! reference the removed cgroup by name surface a kernel-layer
38//! `cgroup missing` error rather than getting a Backdrop typo
39//! caught early.
40
41use super::ops::{CgroupDef, Op};
42use crate::test_support::Payload;
43
44/// Persistent state for a Step sequence.
45///
46/// Hold long-running entities here instead of re-declaring them in
47/// every Step. [`execute_scenario`](super::ops::execute_scenario)
48/// owns the Backdrop for the duration of the run, sets up every
49/// declared entity once before the first Step, and tears them down
50/// at the end (success or Err).
51///
52/// # Empty default
53///
54/// Scenarios with no persistent state pass [`Backdrop::new()`],
55/// which is also what the shorthand
56/// [`execute_steps`](super::ops::execute_steps) /
57/// [`execute_defs`](super::ops::execute_defs) wrappers forward to
58/// internally. There is no cost to using the empty default — the
59/// runtime skips the Backdrop setup phase entirely when every vec
60/// is empty.
61///
62/// # `Clone` and cgroup-name collisions
63///
64/// `Backdrop` derives [`Clone`], so a test can copy a base Backdrop
65/// and attach the copies to different scenarios. **Do not pass a
66/// cloned Backdrop into a sibling scenario in the same process /
67/// VM without rewriting the cgroup names first.** Every cgroup in
68/// `cgroups` is created at the same path
69/// (`/sys/fs/cgroup/<parent>/<name>`); two scenarios both calling
70/// `setup` on a Backdrop with the same names silently share the
71/// cgroup's tasks and counters — the second `setup` finds the path
72/// already exists, skips `mkdir`, and attaches its workers alongside
73/// the first scenario's. No `EEXIST` surfaces (the kernel-level
74/// `mkdir(2)` race is absorbed by `std::fs::create_dir_all`), so
75/// diagnose by unexpected `cgroup.procs` task counts or doubled
76/// metric counters rather than a returned error. A typical safe
77/// shape is
78/// `base.clone().rename_cgroups(|n| format!("{n}_{idx}"))` (caller-
79/// provided helper) before attaching to scenario `idx`. The clone
80/// derive is provided for builder-style composition (forking a
81/// base, then conditionally appending entries) where the resulting
82/// Backdrop is attached to ONE scenario — sibling-scenario use
83/// requires the rename pass.
84///
85/// # Example
86///
87/// ```no_run
88/// use ktstr::prelude::*;
89///
90/// #[derive(Payload)]
91/// #[payload(binary = "stress-ng")]
92/// #[default_args("--cpu", "2")]
93/// struct BgLoadPayload;
94///
95/// // Worker-bearing cgroup + empty move target + long-running payload,
96/// // all persistent for the scenario.
97/// let backdrop = Backdrop::new()
98///     .push_cgroup(CgroupDef::named("bg_cell").cpuset(CpusetSpec::disjoint(0, 2)))
99///     .push_op(Op::add_cgroup("bg_overflow"))
100///     .push_payload(&BG_LOAD);
101/// ```
102#[derive(Debug, Clone, Default)]
103pub struct Backdrop {
104    /// Long-lived cgroups created once and removed at scenario end.
105    /// Any Step can reference them by name via `Op::MoveAllTasks`,
106    /// `Op::SetCpuset`, etc. Every [`CgroupDef`] here spawns at
107    /// least one worker (declared [`WorkSpec`](crate::workload::WorkSpec)
108    /// entries, or a single default WorkSpec when `works` is empty).
109    /// Declare empty move-target cgroups via [`Self::ops`] /
110    /// [`Self::push_op`] using [`Op::AddCgroup`] instead.
111    ///
112    /// # Ordering guarantee
113    ///
114    /// Cgroups are created in DECLARATION ORDER — the order they
115    /// appear in this `Vec`. The Backdrop setup phase iterates
116    /// `cgroups` front-to-back and runs each [`CgroupDef`]'s setup
117    /// (`mkdir`, cpuset/sysfs writes, worker spawn) one at a time.
118    /// `push_cgroup(a).push_cgroup(b)` creates `a` first, then `b`.
119    ///
120    /// This matters for any scheduler whose internal IDs are
121    /// assigned in cgroup-creation order — `scx_mitosis`, for
122    /// example, allocates `cell_id`s monotonically on cgroup
123    /// creation (observed via cgroup-fs inotify, not on first
124    /// task attach) and reuses freed IDs LIFO from a free-list
125    /// when prior cells were destroyed. The cgroup declared first
126    /// gets `cell_id = 1`, the second gets `cell_id = 2`, and so
127    /// on for the initial allocation sequence. A test that wants a
128    /// sparse `cell_id` range (e.g. remove the middle cell to leave
129    /// a gap) can rely on the framework-side declaration order:
130    /// declare `cg_a`, `cg_b`, `cg_c` to get `cell_id = 1, 2, 3`,
131    /// then `Op::RemoveCgroup("cg_b")` at a Step boundary leaves
132    /// cells 1 and 3 live with a `cell_id = 2` hole. The next
133    /// single-cgroup allocation after that hole reuses the freed
134    /// `cell_id = 2` before bumping `next_cell_id` further — LIFO
135    /// from the free-list, not lowest-free.
136    ///
137    /// **Multi-delete caveat.** When several cgroups are removed
138    /// in one inotify-batched event (or two `RemoveCgroup` ops fire
139    /// before scx_mitosis services any of them), scx_mitosis
140    /// inserts the freed IDs into the free-list via
141    /// `HashSet::iter()` — hash-bucket order, NOT removal order.
142    /// Subsequent allocations still pop LIFO, but the LIFO is
143    /// against an arbitrarily-permuted insertion order, so
144    /// "remove a, b, c in this order → reuse c, b, a" does NOT
145    /// hold for multi-cell batches. Single-delete patterns
146    /// (one `RemoveCgroup` per Step) reuse the freed ID
147    /// deterministically.
148    ///
149    /// The cell_id assignment itself is the scheduler's
150    /// responsibility, not the framework's. The Backdrop only
151    /// guarantees the cgroup-creation order; the scheduler binary
152    /// observes the resulting creation order and assigns whatever
153    /// internal IDs its policy dictates.
154    pub cgroups: Vec<CgroupDef>,
155    /// Long-lived binary payloads spawned once before the first
156    /// Step. The runtime holds the live handles for the duration of
157    /// the Step sequence and drains them via `.kill()` (preserving
158    /// metric emission) at scenario teardown.
159    pub payloads: Vec<&'static Payload>,
160    /// Raw [`Op`]s applied during Backdrop setup, before any Step
161    /// runs. Run AFTER [`Self::cgroups`] apply_setup and BEFORE
162    /// [`Self::payloads`] spawn, in declaration order. Backdrop
163    /// ops run with full authority — they can target Backdrop
164    /// cgroups with [`Op::RemoveCgroup`] / [`Op::StopCgroup`] /
165    /// [`Op::MoveAllTasks`] where step-local ops would be
166    /// rejected, since the Backdrop owns the cgroups it's setting
167    /// up. Any cgroup / handle / payload these ops create is
168    /// tracked by the Backdrop slot and tears down at scenario
169    /// end. The typical use is [`Op::AddCgroup`] for empty
170    /// move-target cgroups (a [`CgroupDef`] can't express the
171    /// zero-worker case because apply_setup forces a worker
172    /// spawn).
173    pub ops: Vec<Op>,
174}
175
176impl Backdrop {
177    /// Fresh empty Backdrop — no persistent state. Builder entry
178    /// point: chain [`Self::push_cgroup`] / [`Self::push_payload`] /
179    /// [`Self::push_op`] to populate.
180    ///
181    /// Equivalent to [`Default::default`](Self::default), but
182    /// `const fn` so the value is usable in `static`/`const`
183    /// contexts (`Default::default` is not yet const-stable). Prefer
184    /// `Backdrop::new()` at construction sites; `..Default::default()`
185    /// remains available inside non-const struct-update expressions.
186    #[must_use = "dropping a Backdrop discards the scenario layout"]
187    pub const fn new() -> Self {
188        Backdrop {
189            cgroups: Vec::new(),
190            payloads: Vec::new(),
191            ops: Vec::new(),
192        }
193    }
194
195    /// See [`Self::cgroups`] for the ordering guarantee and the
196    /// `cell_id` allocation example.
197    #[must_use = "builder methods consume self; bind the result"]
198    pub fn push_cgroup(mut self, def: CgroupDef) -> Self {
199        self.cgroups.push(def);
200        self
201    }
202
203    /// See [`Self::cgroups`] for the ordering guarantee.
204    #[must_use = "builder methods consume self; bind the result"]
205    pub fn extend_cgroups<I: IntoIterator<Item = CgroupDef>>(mut self, defs: I) -> Self {
206        self.cgroups.extend(defs);
207        self
208    }
209
210    /// Construct from any [`CgroupDef`] iterator (most commonly a
211    /// `Vec<CgroupDef>` built by a test-side helper). Equivalent to
212    /// `Backdrop::new().extend_cgroups(defs)` but reads as a single
213    /// constructor at the use site; the `FromIterator` impl on
214    /// [`Backdrop`] supports the `.collect()` form for the same case.
215    /// Declaration order is preserved per [`Self::cgroups`].
216    #[must_use = "builder methods return Self; bind the result"]
217    pub fn from_cgroups<I: IntoIterator<Item = CgroupDef>>(defs: I) -> Self {
218        Self::new().extend_cgroups(defs)
219    }
220
221    /// Binary-kind payload with no extra args. See [`Self::payloads`]
222    /// for lifecycle. For custom args or cgroup placement use
223    /// [`Self::push_op`] with [`Op::run_payload`] / [`Op::run_payload_in_cgroup`].
224    #[must_use = "builder methods consume self; bind the result"]
225    pub fn push_payload(mut self, payload: &'static Payload) -> Self {
226        self.payloads.push(payload);
227        self
228    }
229
230    /// See [`Self::push_payload`]; use [`Self::extend_ops`] with
231    /// [`Op::run_payload`] entries for per-payload args.
232    #[must_use = "builder methods consume self; bind the result"]
233    pub fn extend_payloads<I: IntoIterator<Item = &'static Payload>>(
234        mut self,
235        payloads: I,
236    ) -> Self {
237        self.payloads.extend(payloads);
238        self
239    }
240
241    /// See [`Self::ops`] for run order. Typical use:
242    /// [`Op::AddCgroup`] for empty move-target cgroups (a
243    /// [`CgroupDef`] always spawns at least one worker).
244    #[must_use = "builder methods consume self; bind the result"]
245    pub fn push_op(mut self, op: Op) -> Self {
246        self.ops.push(op);
247        self
248    }
249
250    /// See [`Self::ops`] for run order.
251    #[must_use = "builder methods consume self; bind the result"]
252    pub fn extend_ops<I: IntoIterator<Item = Op>>(mut self, ops: I) -> Self {
253        self.ops.extend(ops);
254        self
255    }
256
257    /// True when the Backdrop has no persistent entities declared.
258    /// `execute_scenario` checks this to skip the Backdrop setup
259    /// phase entirely — zero overhead for scenarios that do not
260    /// use persistent state.
261    pub fn is_empty(&self) -> bool {
262        self.cgroups.is_empty() && self.payloads.is_empty() && self.ops.is_empty()
263    }
264}
265
266/// `Backdrop::from_iter(cgroups)` / `cgroups.into_iter().collect()`
267/// shortcuts for the common "build a Backdrop from a Vec of cgroup
268/// defs" pattern test fixtures repeat. Equivalent to
269/// [`Backdrop::from_cgroups`]; declaration order is preserved.
270impl FromIterator<CgroupDef> for Backdrop {
271    fn from_iter<I: IntoIterator<Item = CgroupDef>>(defs: I) -> Self {
272        Self::from_cgroups(defs)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    const TEST_PAYLOAD: Payload = Payload::binary("test_bin", "/bin/true");
281
282    /// Distinct-named sibling of `TEST_PAYLOAD` so payload-order
283    /// tests can discriminate position via the name field. Without
284    /// a second const, two-element tests reusing `TEST_PAYLOAD`
285    /// produce `[test_bin, test_bin]` regardless of order — a
286    /// `reverse()` regression would silently pass any name-based
287    /// assertion.
288    const TEST_PAYLOAD_2: Payload = Payload::binary("test_bin_2", "/bin/false");
289
290    /// Additional distinct-named payloads so
291    /// `extend_payloads_preserves_declaration_order_for_many_entries`
292    /// can exercise a 7-distinct-payload pattern matching the
293    /// cgroups sibling test — any pairwise swap of any two indices
294    /// surfaces as a name mismatch at the swapped index.
295    const TEST_PAYLOAD_3: Payload = Payload::binary("test_bin_3", "/bin/false");
296    const TEST_PAYLOAD_4: Payload = Payload::binary("test_bin_4", "/bin/false");
297    const TEST_PAYLOAD_5: Payload = Payload::binary("test_bin_5", "/bin/false");
298    const TEST_PAYLOAD_6: Payload = Payload::binary("test_bin_6", "/bin/false");
299    const TEST_PAYLOAD_7: Payload = Payload::binary("test_bin_7", "/bin/false");
300
301    #[test]
302    fn empty_backdrop_has_no_entities() {
303        let b = Backdrop::new();
304        assert!(b.cgroups.is_empty());
305        assert!(b.payloads.is_empty());
306        assert!(b.ops.is_empty());
307        assert!(b.is_empty());
308    }
309
310    #[test]
311    fn new_returns_empty() {
312        let b = Backdrop::new();
313        assert!(b.is_empty());
314    }
315
316    #[test]
317    fn push_cgroup_populates_cgroups() {
318        let b = Backdrop::new().push_cgroup(CgroupDef::named("cg0"));
319        assert_eq!(b.cgroups.len(), 1);
320        assert_eq!(b.cgroups[0].name.as_ref(), "cg0");
321        assert!(!b.is_empty());
322    }
323
324    #[test]
325    fn extend_cgroups_preserves_input_order() {
326        let b = Backdrop::new().extend_cgroups([
327            CgroupDef::named("cg0"),
328            CgroupDef::named("cg1"),
329            CgroupDef::named("cg2"),
330        ]);
331        assert_eq!(b.cgroups.len(), 3);
332        assert_eq!(b.cgroups[0].name.as_ref(), "cg0");
333        // Middle index pinned so a regression that swaps middle
334        // elements via a sort or partition while leaving first/last
335        // in place gets caught.
336        assert_eq!(b.cgroups[1].name.as_ref(), "cg1");
337        assert_eq!(b.cgroups[2].name.as_ref(), "cg2");
338    }
339
340    /// Declaration order is preserved across a many-entry batch.
341    /// Catches sort/partition/reverse-style regressions that the
342    /// 3-element sibling above wouldn't surface — a swap of indices
343    /// 1 and 2 there is invisible to a {0, len-1} assertion, but
344    /// here every index is checked.
345    ///
346    /// Names are deliberately non-monotonic in ASCII order so a
347    /// regression that stably-sorts the input would produce a
348    /// different sequence and trip the per-index assertion. If
349    /// names were `["cg0", "cg1", ..., "cg6"]` (already sorted),
350    /// a sort_by_name regression would be invisible.
351    #[test]
352    fn extend_cgroups_preserves_declaration_order_for_many_entries() {
353        const NAMES: [&str; 7] = ["cg5", "cg0", "cg3", "cg6", "cg1", "cg4", "cg2"];
354        let b = Backdrop::new().extend_cgroups(NAMES.map(CgroupDef::named));
355        assert_eq!(b.cgroups.len(), NAMES.len());
356        for (i, expected) in NAMES.iter().enumerate() {
357            assert_eq!(
358                b.cgroups[i].name.as_ref(),
359                *expected,
360                "index {i} should be {expected}"
361            );
362        }
363    }
364
365    #[test]
366    fn push_payload_populates_payloads() {
367        let b = Backdrop::new().push_payload(&TEST_PAYLOAD);
368        assert_eq!(b.payloads.len(), 1);
369        assert_eq!(b.payloads[0].name, "test_bin");
370        assert!(!b.is_empty());
371    }
372
373    #[test]
374    fn extend_payloads_preserves_order() {
375        // Use TEST_PAYLOAD_2 in the second slot so the assertion
376        // actually discriminates position — `[&TEST_PAYLOAD,
377        // &TEST_PAYLOAD]` would assert `[test_bin, test_bin]`
378        // regardless of order and let a `reverse()` regression pass.
379        let b = Backdrop::new().extend_payloads([&TEST_PAYLOAD, &TEST_PAYLOAD_2]);
380        assert_eq!(b.payloads.len(), 2);
381        assert_eq!(b.payloads[0].name, "test_bin");
382        assert_eq!(b.payloads[1].name, "test_bin_2");
383    }
384
385    /// Declaration order is preserved across a many-entry payload
386    /// batch. Sibling of `extend_cgroups_preserves_declaration_order_for_many_entries`
387    /// — uses 7 fully-distinct payloads so any pairwise swap (including
388    /// non-adjacent same-name swaps that an interleaved 2-distinct-payload
389    /// pattern would miss) surfaces as a name mismatch at the swapped
390    /// index. Catches arbitrary shuffle/sort/partition/reverse
391    /// regressions across a larger collection than the 2-entry
392    /// `extend_payloads_preserves_order` can surface.
393    ///
394    /// Inputs are deliberately non-monotonic in ASCII order of their
395    /// `name` field so a regression that stably-sorts the input would
396    /// produce a different sequence and trip the per-index assertion.
397    #[test]
398    fn extend_payloads_preserves_declaration_order_for_many_entries() {
399        let inputs = [
400            &TEST_PAYLOAD_5,
401            &TEST_PAYLOAD,
402            &TEST_PAYLOAD_3,
403            &TEST_PAYLOAD_7,
404            &TEST_PAYLOAD_2,
405            &TEST_PAYLOAD_6,
406            &TEST_PAYLOAD_4,
407        ];
408        let expected = [
409            "test_bin_5",
410            "test_bin",
411            "test_bin_3",
412            "test_bin_7",
413            "test_bin_2",
414            "test_bin_6",
415            "test_bin_4",
416        ];
417        let b = Backdrop::new().extend_payloads(inputs);
418        assert_eq!(b.payloads.len(), expected.len());
419        for (i, name) in expected.iter().enumerate() {
420            assert_eq!(b.payloads[i].name, *name, "index {i} should be {name}");
421        }
422    }
423
424    #[test]
425    fn push_then_extend_preserves_order() {
426        // Use distinct payload consts so the assertion can verify
427        // that the `push_payload` entry comes BEFORE the
428        // `extend_payloads` entries — a regression that prepends
429        // (instead of appends) would pass a count-only check.
430        let b = Backdrop::new()
431            .push_payload(&TEST_PAYLOAD)
432            .extend_payloads([&TEST_PAYLOAD_2]);
433        assert_eq!(b.payloads.len(), 2);
434        assert_eq!(b.payloads[0].name, "test_bin");
435        assert_eq!(b.payloads[1].name, "test_bin_2");
436    }
437
438    #[test]
439    fn chain_builds_in_order() {
440        let b = Backdrop::new()
441            .push_cgroup(CgroupDef::named("cg_a"))
442            .push_payload(&TEST_PAYLOAD)
443            .push_cgroup(CgroupDef::named("cg_b"));
444        assert_eq!(b.cgroups.len(), 2);
445        assert_eq!(b.cgroups[0].name.as_ref(), "cg_a");
446        assert_eq!(b.cgroups[1].name.as_ref(), "cg_b");
447        assert_eq!(b.payloads.len(), 1);
448        assert!(!b.is_empty());
449    }
450
451    #[test]
452    fn default_impl_matches_new() {
453        let d: Backdrop = Default::default();
454        assert!(d.is_empty());
455        assert_eq!(d.cgroups.len(), Backdrop::new().cgroups.len());
456        assert_eq!(d.payloads.len(), Backdrop::new().payloads.len());
457        assert_eq!(d.ops.len(), Backdrop::new().ops.len());
458    }
459
460    /// Compile-time pin: [`Backdrop::new`] must remain `const fn` so
461    /// downstream consumers can use it in `static`/`const` item
462    /// initializers. A regression that drops the `const` keyword (or
463    /// adds a non-const operation to the body) breaks this `const _`
464    /// item at build time rather than silently slipping past
465    /// [`default_impl_matches_new`].
466    const _BACKDROP_NEW_IS_CONST_EVALUABLE: Backdrop = Backdrop::new();
467
468    #[test]
469    fn push_op_populates_ops() {
470        let b = Backdrop::new().push_op(Op::add_cgroup("empty_target"));
471        assert_eq!(b.ops.len(), 1);
472        assert!(matches!(&b.ops[0], Op::AddCgroup { name } if name.as_ref() == "empty_target"));
473        assert!(!b.is_empty());
474    }
475
476    #[test]
477    fn extend_ops_preserves_order() {
478        let b =
479            Backdrop::new().extend_ops(vec![Op::add_cgroup("cg_1"), Op::add_cgroup("cg_1/sub")]);
480        assert_eq!(b.ops.len(), 2);
481        assert!(matches!(&b.ops[0], Op::AddCgroup { name } if name.as_ref() == "cg_1"));
482        assert!(matches!(&b.ops[1], Op::AddCgroup { name } if name.as_ref() == "cg_1/sub"));
483    }
484
485    #[test]
486    fn chain_push_op_interleaves_with_other_builders() {
487        let b = Backdrop::new()
488            .push_cgroup(CgroupDef::named("cg_workers"))
489            .push_op(Op::add_cgroup("cg_empty"))
490            .push_payload(&TEST_PAYLOAD);
491        assert_eq!(b.cgroups.len(), 1);
492        assert_eq!(b.ops.len(), 1);
493        assert_eq!(b.payloads.len(), 1);
494        assert!(!b.is_empty());
495    }
496
497    /// `Backdrop::clone()` must produce an independent value: mutating
498    /// the clone leaves the original untouched. The default derived
499    /// `Clone` on a struct of `Vec<T>` fields produces deep copies,
500    /// but a future refactor that swaps any field to an `Rc`-shared
501    /// or `Cow`-shared container would silently turn the clone into
502    /// an alias — and the cgroup-name-collision footgun the type
503    /// docs warn about would expand into a "mutate one Backdrop,
504    /// corrupt the other" surprise. Pin independence per field
505    /// (cgroups, payloads, ops) against [`Backdrop::new`],
506    /// AND verify the cloned vec received the pushed value (not just
507    /// "1 element"). A regression that cloned `original.cgroups` as
508    /// `vec![CgroupDef::named("wrong")]` and then pushed the
509    /// expected value would yield len=2, which a length-only
510    /// assertion misses.
511    #[test]
512    fn clone_is_independent_per_field() {
513        let original = Backdrop::new();
514        let mut cloned = original.clone();
515        cloned.cgroups.push(CgroupDef::named("cg_added_to_clone"));
516        cloned.payloads.push(&TEST_PAYLOAD);
517        cloned.ops.push(Op::add_cgroup("cg_op_on_clone"));
518        assert!(
519            original.cgroups.is_empty(),
520            "original.cgroups must stay empty after clone mutation"
521        );
522        assert!(
523            original.payloads.is_empty(),
524            "original.payloads must stay empty after clone mutation"
525        );
526        assert!(
527            original.ops.is_empty(),
528            "original.ops must stay empty after clone mutation"
529        );
530        // Verify clone has exactly the pushed values — catches a
531        // "started with a non-empty clone" bug that length=1 alone
532        // would miss.
533        assert_eq!(cloned.cgroups.len(), 1, "clone cgroups: unexpected count");
534        assert_eq!(
535            cloned.cgroups[0].name.as_ref(),
536            "cg_added_to_clone",
537            "clone cgroups: pushed value not present at index 0"
538        );
539        assert_eq!(cloned.payloads.len(), 1, "clone payloads: unexpected count");
540        assert!(
541            std::ptr::eq(cloned.payloads[0], &TEST_PAYLOAD),
542            "clone payloads: pushed pointer not present at index 0"
543        );
544        assert_eq!(cloned.ops.len(), 1, "clone ops: unexpected count");
545        assert!(
546            matches!(&cloned.ops[0], Op::AddCgroup { name } if name.as_ref() == "cg_op_on_clone"),
547            "clone ops: pushed Op not present at index 0"
548        );
549    }
550
551    /// Pin: `Backdrop::from_cgroups(vec)` preserves declaration
552    /// order (per [`Backdrop::cgroups`]'s ordering guarantee) and
553    /// leaves payloads/ops empty.
554    #[test]
555    fn from_cgroups_preserves_order_leaves_other_fields_empty() {
556        let b = Backdrop::from_cgroups([
557            CgroupDef::named("cg_a"),
558            CgroupDef::named("cg_b"),
559            CgroupDef::named("cg_c"),
560        ]);
561        assert_eq!(b.cgroups.len(), 3);
562        assert_eq!(b.cgroups[0].name.as_ref(), "cg_a");
563        assert_eq!(b.cgroups[1].name.as_ref(), "cg_b");
564        assert_eq!(b.cgroups[2].name.as_ref(), "cg_c");
565        assert!(b.payloads.is_empty());
566        assert!(b.ops.is_empty());
567    }
568
569    /// Pin: `Vec<CgroupDef>.into_iter().collect::<Backdrop>()` builds
570    /// the same Backdrop as `Backdrop::from_cgroups` — the
571    /// FromIterator impl is the standard-library-style entry point
572    /// for the same construction.
573    #[test]
574    fn from_iterator_matches_from_cgroups() {
575        let defs = vec![CgroupDef::named("cg_0"), CgroupDef::named("cg_1")];
576        let from_constructor = Backdrop::from_cgroups(defs.clone());
577        let from_iter: Backdrop = defs.into_iter().collect();
578        assert_eq!(from_iter.cgroups.len(), from_constructor.cgroups.len());
579        assert_eq!(
580            from_iter.cgroups[0].name.as_ref(),
581            from_constructor.cgroups[0].name.as_ref()
582        );
583        assert_eq!(
584            from_iter.cgroups[1].name.as_ref(),
585            from_constructor.cgroups[1].name.as_ref()
586        );
587    }
588
589    /// Pin: `Backdrop::from_cgroups(std::iter::empty())` builds an
590    /// empty Backdrop equivalent to `Backdrop::new()`.
591    #[test]
592    fn from_cgroups_empty_input_yields_empty_backdrop() {
593        let b = Backdrop::from_cgroups(std::iter::empty());
594        assert!(b.is_empty());
595    }
596
597    /// Lock-step pin: [`Backdrop::default`] must agree with
598    /// [`Backdrop::new`] field-by-field. Both produce a no-state
599    /// builder seed; a regression where Default seeds a non-empty
600    /// field would silently introduce a phantom cgroup/payload/op
601    /// into every spread-default callsite.
602    #[test]
603    fn default_matches_new() {
604        let from_new = Backdrop::new();
605        let from_trait: Backdrop = Default::default();
606        assert_eq!(
607            from_trait.cgroups.len(),
608            from_new.cgroups.len(),
609            "cgroups Vec drift"
610        );
611        assert_eq!(
612            from_trait.payloads.len(),
613            from_new.payloads.len(),
614            "payloads Vec drift"
615        );
616        assert_eq!(from_trait.ops.len(), from_new.ops.len(), "ops Vec drift");
617        assert!(from_new.cgroups.is_empty());
618        assert!(from_new.payloads.is_empty());
619        assert!(from_new.ops.is_empty());
620    }
621}