ktstr 0.6.0

Test harness for Linux process schedulers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
//! CPU affinity intent + resolution for worker tasks.
//!
//! [`AffinityIntent`] expresses the test author's request before
//! topology resolution; [`ResolvedAffinity`] is the post-resolution
//! shape the spawn pipeline consumes. [`resolve_affinity`] walks the
//! `ResolvedAffinity` to a concrete CPU set; [`set_thread_affinity`]
//! issues the `sched_setaffinity` syscall; [`sched_getcpu`] is the
//! inverse query (which CPU is the current task on right now).
//!
//! Re-exported from the parent module via `pub use affinity::*` so
//! `crate::workload::AffinityIntent` etc. stay valid.

use std::collections::BTreeSet;

use anyhow::{Context, Result};

/// Scenario-level affinity intent for a group of workers.
///
/// Resolved to a concrete [`ResolvedAffinity`] at runtime based on the
/// cgroup's effective cpuset and the VM's topology. When attached to
/// a [`WorkSpec`](crate::workload::WorkSpec), determines per-worker `sched_setaffinity` masks.
///
/// Resolution uses [`resolve_affinity_for_cgroup()`](crate::scenario::resolve_affinity_for_cgroup).
///
/// # Naming pattern (Intent vs Resolved)
///
/// [`AffinityIntent`] and [`ResolvedAffinity`] form a pre/post-resolution
/// pair. Variant names line up where the same shape exists on both
/// sides; payload differences encode the intent → concrete-CPU-set
/// distinction:
///
/// | [`AffinityIntent`]                       | [`ResolvedAffinity`]              |
/// |------------------------------------------|-----------------------------------|
/// | `Inherit` (no payload)                   | `None`                            |
/// | `Exact(BTreeSet<usize>)`                 | `Fixed(BTreeSet<usize>)`          |
/// | `RandomSubset { from, count }`           | `Random { from, count }`          |
/// | `SingleCpu` (no payload)                 | `SingleCpu(usize)`                |
/// | `LlcAligned` / `CrossCgroup`             | `Fixed(...)` (resolver expands)   |
/// | `SmtSiblingPair` (no payload)            | `Fixed({sibling_a, sibling_b})`   |
///
/// Constructor helpers: [`AffinityIntent::exact`] takes any
/// `IntoIterator<Item = usize>` for the `Exact` set;
/// [`AffinityIntent::random_subset`] takes the same iterator shape
/// for the `RandomSubset` pool plus a sample-count argument.
///
/// The `SingleCpu` pair specifically: [`AffinityIntent::SingleCpu`]
/// expresses "pin to one CPU; resolver picks which based on cgroup
/// state and worker index", and [`ResolvedAffinity::SingleCpu`]
/// records the concrete CPU id chosen. Reusing the variant name keeps
/// the pre/post mapping lexically obvious — payload presence
/// distinguishes intent from resolution without renaming the variant.
///
/// [`AffinityIntent::RandomSubset`] carries the resolved pool
/// (`from`) and sample size (`count`) — sampling itself is deferred
/// to spawn time so each worker gets an independent draw. The
/// scenario engine's `resolve_affinity_for_cgroup` materialises the
/// pool from cgroup cpuset / topology before constructing this
/// variant; spawn-time `resolve_affinity` samples per-worker.
/// Construct directly via [`AffinityIntent::random_subset`].
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AffinityIntent {
    /// No affinity constraint -- inherit from parent cgroup.
    #[default]
    Inherit,
    /// Pin each worker to a random subset of `from`, sampling `count`
    /// CPUs per worker. Sampling is deferred to spawn time so each
    /// worker gets an independent draw — mirrors
    /// [`ResolvedAffinity::Random`] semantics. Construct with the
    /// resolved pool already materialised; the scenario engine pre-
    /// resolves topology-aware "pick from cgroup state" intent
    /// before building this variant.
    RandomSubset { from: BTreeSet<usize>, count: usize },
    /// Pin to the CPUs in the worker's LLC.
    LlcAligned,
    /// Pin to all CPUs (crosses cgroup boundaries).
    CrossCgroup,
    /// Pin to a single CPU.
    SingleCpu,
    /// Pin to an exact set of CPUs.
    Exact(BTreeSet<usize>),
    /// Pin all workers in the group to the two SMT siblings of one
    /// physical core. Tests how the scheduler handles two
    /// compute-bound tasks placed on SMT siblings — both threads
    /// contend for the core's shared front-end / execution
    /// resources, exposing scheduler decisions about co-running
    /// vs. spreading compute load across cores.
    ///
    /// Designed for `WorkType::SmtSiblingSpin` and other
    /// `worker_group_size = 2` variants
    /// (`WorkType::FutexPingPong`, `WorkType::AsymmetricWaker`,
    /// `WorkType::SignalStorm`, etc.) where both workers in a
    /// group are intended to run on a sibling pair. The variant
    /// has no payload — the resolver picks an SMT-sibling pair
    /// from the cgroup's effective cpuset (or the full topology
    /// when no cpuset is active).
    ///
    /// Resolution is performed by the scenario engine's
    /// `resolve_affinity_for_cgroup` (topology-aware, not
    /// available at the bare `WorkloadHandle::spawn` gate). The
    /// resolver searches the cpuset for a physical core with at
    /// least two thread siblings present and resolves to
    /// [`ResolvedAffinity::Fixed`] containing those two CPU IDs.
    /// All workers in the group get pinned to that 2-CPU set;
    /// when `num_workers == 2` the kernel runs one worker on each
    /// sibling, which is the contention pattern this intent
    /// targets.
    ///
    /// Returns an error from the resolver — NOT a silent
    /// fallback — when no SMT-sibling pair is available
    /// (`threads_per_core == 1`, or the cpuset isolates each
    /// sibling onto a different CPU set). Callers must handle
    /// the error; running `WorkType::SmtSiblingSpin` without
    /// SMT siblings would produce a misleading result.
    SmtSiblingPair,
}

impl AffinityIntent {
    /// Construct an `Exact` affinity from any iterator of CPU indices.
    ///
    /// Accepts arrays, ranges, `Vec`, `BTreeSet`, or any `IntoIterator<Item = usize>`.
    pub fn exact(cpus: impl IntoIterator<Item = usize>) -> Self {
        AffinityIntent::Exact(cpus.into_iter().collect())
    }

    /// Construct a `RandomSubset` from a pool iterator and a sample
    /// size. Mirrors the [`Self::exact`] constructor's iterator
    /// flexibility — accepts arrays, `Vec`, `BTreeSet`, ranges, or
    /// any `IntoIterator<Item = usize>` for the pool.
    ///
    /// Sampling is deferred to spawn time; each worker gets an
    /// independent `count`-sized draw from `from`. `count > from.len()`
    /// is clamped to `from.len()` at sample time (topology fact, not
    /// caller error). `count == 0` and empty `from` are rejected at
    /// the spawn-time affinity gate with an actionable diagnostic —
    /// use [`AffinityIntent::Inherit`] for no affinity constraint.
    pub fn random_subset(from: impl IntoIterator<Item = usize>, count: usize) -> Self {
        AffinityIntent::RandomSubset {
            from: from.into_iter().collect(),
            count,
        }
    }
}

/// Resolved CPU affinity for a worker process.
///
/// Created from [`AffinityIntent`] at runtime based on topology and
/// cpuset assignments. Variant names track [`AffinityIntent`] where the
/// same shape exists pre/post-resolution; payload presence
/// distinguishes intent from concrete CPU id(s). See the
/// [`AffinityIntent`] type doc for the full pre/post mapping table.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedAffinity {
    /// No affinity constraint.
    #[default]
    None,
    /// Pin to a specific set of CPUs.
    Fixed(BTreeSet<usize>),
    /// Pin to `count` randomly-chosen CPUs from `from`.
    ///
    /// - `count` must be `> 0`; zero is rejected at resolve time
    ///   (previously it coerced silently to 1 and masked caller bugs).
    /// - `count > from.len()` is clamped to `from.len()` — asking for
    ///   more CPUs than the pool contains is a topology fact, not a
    ///   caller error.
    /// - `from` empty with `count > 0` is a caller bug: `resolve_affinity`
    ///   bails (an unsatisfiable sample request would otherwise produce an
    ///   empty `sched_setaffinity` mask that the kernel rejects with
    ///   `EINVAL`). The resolution step that produces this variant — see
    ///   [`crate::scenario::resolve_affinity_for_cgroup`] — bails on
    ///   empty pools before construction; no silent fallback. Direct
    ///   constructor callers (e.g. test fixtures) must do the same: use
    ///   [`ResolvedAffinity::None`] for "no affinity constraint", never
    ///   `Random { from: empty, count: > 0 }`.
    Random { from: BTreeSet<usize>, count: usize },
    /// Pin to a single CPU.
    SingleCpu(usize),
}

impl ResolvedAffinity {
    /// Construct a [`ResolvedAffinity::Fixed`] from any iterator over
    /// CPU ids. Mirrors [`AffinityIntent::exact`].
    pub fn fixed(cpus: impl IntoIterator<Item = usize>) -> Self {
        ResolvedAffinity::Fixed(cpus.into_iter().collect())
    }

    /// Construct a [`ResolvedAffinity::Random`] from a pool iterator
    /// and a sample count. Mirrors [`AffinityIntent::random_subset`].
    pub fn random(from: impl IntoIterator<Item = usize>, count: usize) -> Self {
        ResolvedAffinity::Random {
            from: from.into_iter().collect(),
            count,
        }
    }

    /// Construct a [`ResolvedAffinity::SingleCpu`].
    pub const fn single_cpu(cpu: usize) -> Self {
        ResolvedAffinity::SingleCpu(cpu)
    }
}

/// Resolve a [`ResolvedAffinity`] into the concrete CPU set the
/// spawn pipeline writes into the worker's `sched_setaffinity` mask.
///
/// `Random` samples `count` CPUs from `from` per call (each worker
/// gets an independent draw at spawn time). Empty `from` is a caller
/// bug and bails — the upstream resolver
/// [`crate::scenario::resolve_affinity_for_cgroup`] itself bails
/// (rather than degrading to [`ResolvedAffinity::None`]) on every
/// path that would produce an empty pool — empty cpuset intersection
/// against `RandomSubset.from`, `count == 0`, or any other
/// unsatisfiable shape. Reaching this fn with an empty `Random.from`
/// therefore indicates a caller that bypassed the resolver. Invalid
/// input must fail loudly, never silently degrade to "no affinity
/// applied". `count == 0` likewise bails.
pub(crate) fn resolve_affinity(mode: &ResolvedAffinity) -> Result<Option<BTreeSet<usize>>> {
    match mode {
        ResolvedAffinity::None => Ok(None),
        ResolvedAffinity::Fixed(cpus) => Ok(Some(cpus.clone())),
        ResolvedAffinity::SingleCpu(cpu) => Ok(Some([*cpu].into_iter().collect())),
        ResolvedAffinity::Random { from, count } => {
            use rand::seq::IndexedRandom;
            if *count == 0 {
                anyhow::bail!(
                    "ResolvedAffinity::Random.count must be > 0; a zero count \
                     previously silently coerced to 1, masking caller bugs"
                );
            }
            if from.is_empty() {
                anyhow::bail!(
                    "ResolvedAffinity::Random.from is empty with count={count}; \
                     a worker cannot be pinned to an empty CPU pool. The \
                     resolution step that produced this Random must reject \
                     the empty set up-front (e.g. via the bail paths in \
                     `crate::scenario::resolve_affinity_for_cgroup`) — \
                     forwarding an unsatisfiable sample request would \
                     silently drop the affinity constraint",
                    count = count,
                );
            }
            let pool: Vec<usize> = from.iter().copied().collect();
            // Clamp count down to the pool size (user asked for more
            // CPUs than exist). Silent clamp is fine here: the pool
            // upper bound is a topology fact, not a caller bug.
            let count = (*count).min(pool.len());
            Ok(Some(
                pool.sample(&mut rand::rng(), count).copied().collect(),
            ))
        }
    }
}

/// Return the CPU the calling task is currently running on.
///
/// Falls back to `0` on syscall failure (rare; would mean
/// `getcpu(2)` is unavailable, which is not the case on any
/// supported kernel). Wraps [`nix::sched::sched_getcpu`].
pub(crate) fn sched_getcpu() -> usize {
    nix::sched::sched_getcpu().unwrap_or(0)
}

/// Set per-thread CPU affinity via `sched_setaffinity(2)`.
///
/// `pid` must be `> 0` — `pid <= 0` has broadcast semantics at the
/// syscall level (target the calling task or every task in a tgid
/// depending on layer) and is rejected up-front so no caller passes
/// an unchecked `0` through.
pub fn set_thread_affinity(pid: libc::pid_t, cpus: &BTreeSet<usize>) -> Result<()> {
    use nix::sched::{CpuSet, sched_setaffinity};
    use nix::unistd::{Pid, SysconfVar, sysconf};
    // See `set_sched_policy` for the rationale — pid <= 0 has
    // broadcast semantics at the syscall and must not be passed
    // through unchecked.
    if pid <= 0 {
        anyhow::bail!("sched_setaffinity: invalid pid {pid} (must be > 0)");
    }
    // Snapshot the host's online-CPU count and the cpu_set bitmap
    // width before the loop so the diagnostic on overflow carries
    // both numbers without re-syscalling per offending CPU. Render
    // sysconf failure as "unavailable" rather than 0 to disambiguate
    // a degenerate sysconf result from a legitimately zero count.
    let online_cpus_str: std::borrow::Cow<'static, str> =
        match sysconf(SysconfVar::_NPROCESSORS_ONLN).ok().flatten() {
            Some(n) => format!("{n}").into(),
            None => "unavailable".into(),
        };
    let cpuset_bitmap_width: usize = libc::CPU_SETSIZE as usize;
    let mut cpu_set = CpuSet::new();
    for &cpu in cpus {
        cpu_set.set(cpu).with_context(|| {
            format!(
                "CPU {cpu} out of range: cpu_set bitmap holds CPU IDs \
                 0..{cpuset_bitmap_width} (libc CPU_SETSIZE) and host \
                 reports {online_cpus_str} online CPUs (sysconf \
                 _SC_NPROCESSORS_ONLN). Either the cpuset spec was \
                 resolved against a stale topology or the bitmap cap \
                 needs raising on this build."
            )
        })?;
    }
    sched_setaffinity(Pid::from_raw(pid), &cpu_set)
        .with_context(|| format!("sched_setaffinity pid={pid}"))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeSet;

    #[test]
    fn resolve_affinity_none() {
        let r = resolve_affinity(&ResolvedAffinity::None).unwrap();
        assert!(r.is_none());
    }
    #[test]
    fn resolve_affinity_fixed() {
        let cpus: BTreeSet<usize> = [0, 1, 2].into_iter().collect();
        let r = resolve_affinity(&ResolvedAffinity::Fixed(cpus.clone())).unwrap();
        assert_eq!(r, Some(cpus));
    }
    #[test]
    fn resolve_affinity_single_cpu() {
        let r = resolve_affinity(&ResolvedAffinity::SingleCpu(5)).unwrap();
        assert_eq!(r, Some([5].into_iter().collect()));
    }
    /// `ResolvedAffinity` derives `Debug`; the `SingleCpu` variant
    /// must render its variant name and the embedded CPU id so
    /// failure-dump and tracing output are diagnosable. Pins the
    /// derive against accidental removal.
    #[test]
    fn resolved_affinity_single_cpu_debug_format() {
        let dbg = format!("{:?}", ResolvedAffinity::SingleCpu(7));
        assert!(
            dbg.contains("SingleCpu"),
            "Debug output must name the variant, got: {dbg}"
        );
        assert!(
            dbg.contains('7'),
            "Debug output must include the CPU id payload, got: {dbg}"
        );
    }
    #[test]
    fn resolve_affinity_random() {
        let from: BTreeSet<usize> = (0..8).collect();
        let r = resolve_affinity(&ResolvedAffinity::Random { from, count: 3 }).unwrap();
        let cpus = r.unwrap();
        assert_eq!(cpus.len(), 3);
        assert!(cpus.iter().all(|c| *c < 8));
    }
    #[test]
    fn resolve_affinity_random_clamps_count() {
        let from: BTreeSet<usize> = [0, 1].into_iter().collect();
        let r = resolve_affinity(&ResolvedAffinity::Random { from, count: 10 }).unwrap();
        assert_eq!(r.unwrap().len(), 2);
    }
    #[test]
    fn resolve_affinity_random_single_cpu_pool() {
        let from: BTreeSet<usize> = [7].into_iter().collect();
        let r = resolve_affinity(&ResolvedAffinity::Random { from, count: 1 }).unwrap();
        assert_eq!(r.unwrap(), [7].into_iter().collect());
    }
    #[test]
    fn affinity_mode_debug_shows_cpus() {
        let a = ResolvedAffinity::Fixed([0, 1, 7].into_iter().collect());
        let s = format!("{:?}", a);
        assert!(s.contains("0"), "must show CPU 0");
        assert!(s.contains("1"), "must show CPU 1");
        assert!(s.contains("7"), "must show CPU 7");
        // Different CPU sets produce different output.
        let b = ResolvedAffinity::Fixed([3, 4].into_iter().collect());
        let s2 = format!("{:?}", b);
        assert!(s2.contains("3"), "must show CPU 3");
        assert_ne!(
            s, s2,
            "different CPU sets must produce different debug output"
        );
    }
    #[test]
    fn affinity_mode_clone_preserves_cpus() {
        let cpus: BTreeSet<usize> = [2, 5, 7].into_iter().collect();
        let a = ResolvedAffinity::Random {
            from: cpus.clone(),
            count: 2,
        };
        let b = a.clone();
        match b {
            ResolvedAffinity::Random { from, count } => {
                assert_eq!(from, cpus, "cloned from set must match original");
                assert_eq!(count, 2, "cloned count must match original");
            }
            _ => panic!("clone must preserve variant"),
        }
    }
    // -- resolve_affinity edge cases --

    #[test]
    fn resolve_affinity_random_zero_count_rejected() {
        // Regression: count=0 previously coerced silently to 1, masking
        // caller bugs. Now it must return an Err.
        let from: BTreeSet<usize> = (0..4).collect();
        let err = resolve_affinity(&ResolvedAffinity::Random { from, count: 0 }).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("count") && msg.contains("> 0"),
            "error must name the field: {msg}"
        );
    }
    #[test]
    fn resolve_affinity_random_empty_pool_bails() {
        // Empty Random.from with count > 0 is unsatisfiable: a worker
        // cannot be pinned to an empty CPU pool, and the prior
        // silent-degrade-to-Ok(None) was a silent-drop bug. The bail
        // forces the bug to surface and points the caller at the
        // upstream resolver as the place where empty pools should be
        // rejected up-front.
        let from: BTreeSet<usize> = BTreeSet::new();
        let err = resolve_affinity(&ResolvedAffinity::Random { from, count: 1 }).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("empty") && msg.contains("count=1"),
            "diagnostic must name the empty pool and the count: got {msg}",
        );
        // Also pin the actionable suggestion — names the upstream
        // resolver as the place callers should reject the empty set
        // so a caller hitting this error learns where to plug the hole.
        assert!(
            msg.contains("resolve_affinity_for_cgroup"),
            "diagnostic must point to the upstream resolver so callers \
             learn where the empty pool should have been rejected: got {msg}",
        );
    }

    #[test]
    fn sched_getcpu_valid() {
        let cpu = sched_getcpu();
        let max = std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1);
        assert!(cpu < max, "cpu {cpu} >= max {max}");
    }

    #[test]
    fn set_thread_affinity_cpu_zero() {
        let pid: libc::pid_t = unsafe { libc::getpid() };
        let cpus: BTreeSet<usize> = [0].into_iter().collect();
        let result = set_thread_affinity(pid, &cpus);
        assert!(result.is_ok(), "pinning to CPU 0 should succeed");
    }

    /// GAP 7: pin that the three constructors produce the same
    /// values a direct variant construction yields. A regression
    /// where `fixed(iter)` started normalising the input would
    /// silently shift downstream semantics.
    #[test]
    fn resolved_affinity_constructors_match_direct_variants() {
        let from_ctor = ResolvedAffinity::fixed([0_usize, 1, 2]);
        let from_variant = ResolvedAffinity::Fixed([0_usize, 1, 2].into_iter().collect());
        assert_eq!(from_ctor, from_variant);

        let from_ctor = ResolvedAffinity::random([0_usize, 1, 2, 3], 2);
        let from_variant = ResolvedAffinity::Random {
            from: [0_usize, 1, 2, 3].into_iter().collect(),
            count: 2,
        };
        assert_eq!(from_ctor, from_variant);

        let from_ctor = ResolvedAffinity::single_cpu(5);
        let from_variant = ResolvedAffinity::SingleCpu(5);
        assert_eq!(from_ctor, from_variant);
    }

    // Compile-time pin: `ResolvedAffinity::single_cpu` is `pub const
    // fn`. A regression that drops `const` (e.g. switches to a body
    // requiring runtime allocation) would silently break the
    // const-context use case. The `const _` binding fails to
    // type-check if `single_cpu` is no longer const-evaluable.
    const _: ResolvedAffinity = ResolvedAffinity::single_cpu(7);

    /// GAP 8: pin that `ResolvedAffinity::default()` is `None`
    /// and that every variant roundtrips through serde unchanged,
    /// including empty-payload edge cases for `Fixed` and `Random`.
    /// Serde drift on any variant breaks failure-dump replay and
    /// captured-workload reproduction — the persisted JSON would
    /// deserialize into a different variant or lose payload data.
    /// A regression that landed `#[serde(skip_serializing_if =
    /// "BTreeSet::is_empty")]` on the inner field would silently
    /// drop the variant tag on the empty case; pinning empty
    /// payloads catches that.
    #[test]
    fn resolved_affinity_default_is_none_and_serde_roundtrip_per_variant() {
        let d: ResolvedAffinity = Default::default();
        assert_eq!(d, ResolvedAffinity::None);

        let variants = [
            ResolvedAffinity::None,
            ResolvedAffinity::Fixed([0_usize, 1, 5].into_iter().collect()),
            ResolvedAffinity::Fixed(BTreeSet::new()),
            ResolvedAffinity::Random {
                from: [0_usize, 1, 2, 3, 4].into_iter().collect(),
                count: 2,
            },
            ResolvedAffinity::Random {
                from: BTreeSet::new(),
                count: 0,
            },
            ResolvedAffinity::SingleCpu(7),
        ];
        for original in &variants {
            let bytes = serde_json::to_vec(original).expect("serialize");
            let restored: ResolvedAffinity = serde_json::from_slice(&bytes).expect("deserialize");
            assert_eq!(restored, *original, "roundtrip drift for {original:?}");
        }
    }
}