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}