Skip to main content

scx_nitosis/
cell_manager.rs

1// Copyright (c) Meta Platforms, Inc. and affiliates.
2
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6//! Cell manager for userspace-driven cell creation.
7//!
8//! This module implements the `--cell-parent-cgroup` mode where cells are created
9//! for direct child cgroups of a specified parent. Uses inotify to watch for
10//! cgroup creation/destruction and manages cell ID allocation.
11
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
13use std::fs::DirEntry;
14use std::os::unix::fs::MetadataExt;
15use std::os::unix::io::{AsFd, BorrowedFd};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use anyhow::{Context, Result, bail};
20use inotify::{Inotify, WatchMask};
21use scx_utils::Cpumask;
22use tracing::{debug, info};
23
24/// Strip the cgroup mount prefix from a stored cell path, yielding the
25/// root-relative cgroup path (e.g. `/sys/fs/cgroup/a/b` -> `/a/b`).
26fn cgroup_root_relative(path: &Path) -> String {
27    path.strip_prefix("/sys/fs/cgroup")
28        .map(|rel| format!("/{}", rel.to_string_lossy()))
29        .unwrap_or_else(|_| path.to_string_lossy().into_owned())
30}
31
32/// Information about a cell created for a cgroup
33#[derive(Debug)]
34pub struct CellInfo {
35    pub cell_id: u32,
36    pub cgroup_path: Option<PathBuf>,
37    pub cgid: Option<u64>,
38    /// Optional cpuset mask if the cgroup has cpuset.cpus configured
39    pub cpuset: Option<Cpumask>,
40}
41
42/// Minimum primary-CPU constraints for a partitioning recipient.
43///
44/// `protected` is a hard donor floor: satisfying another recipient's requested
45/// minimum must not reduce this recipient below it. `requested` is a
46/// best-effort floor reserved before weighted allocation; it may be capped when
47/// satisfying it would violate another recipient's protected floor.
48#[derive(Debug, Clone, Copy, Default)]
49struct CpuMinimum {
50    protected: usize,
51    requested: usize,
52}
53
54/// Generic allocation input for a CPU partitioning recipient.
55///
56/// `allowed` is the hard eligibility mask. `claimed=None` means the recipient
57/// is unpinned: it has no preferential ownership and normally participates in
58/// the unclaimed pool. Keeping eligibility separate from claims allows an
59/// unpinned recipient to request a minimum from anywhere in its allowed mask.
60#[derive(Debug, Clone)]
61struct CpuRecipient {
62    id: u32,
63    weight: f64,
64    allowed: Cpumask,
65    claimed: Option<Cpumask>,
66    minimum: CpuMinimum,
67}
68
69/// Result of CPU assignment computation, containing both primary and optional borrowable masks.
70#[derive(Debug)]
71pub struct CpuAssignment {
72    pub id: u32,
73    pub primary: Cpumask,
74    pub borrowable: Option<Cpumask>,
75}
76
77/// Generic CPU allocator over a fixed domain of CPUs.
78struct CpuManager<'a> {
79    domain: &'a Cpumask,
80    /// Optional CPU -> topology partition mapping used only to rank CPUs when
81    /// a requested minimum must displace preferential claims.
82    cpu_to_partition: Option<&'a HashMap<usize, usize>>,
83}
84
85impl<'a> CpuManager<'a> {
86    #[cfg(test)]
87    fn new(domain: &'a Cpumask) -> Self {
88        Self {
89            domain,
90            cpu_to_partition: None,
91        }
92    }
93
94    fn with_partitions(domain: &'a Cpumask, cpu_to_partition: &'a HashMap<usize, usize>) -> Self {
95        Self {
96            domain,
97            cpu_to_partition: Some(cpu_to_partition),
98        }
99    }
100
101    /// Compute the global target CPU count for each recipient.
102    ///
103    /// Each recipient gets a floor of 1 CPU, with the remainder distributed
104    /// proportionally by weight. Returns only counts, not actual CPU assignments.
105    fn compute_targets(
106        total_cpus: usize,
107        recipients: &[(u32, f64)],
108    ) -> Result<HashMap<u32, usize>> {
109        if recipients.is_empty() {
110            bail!("compute_targets called with no cells");
111        }
112        if total_cpus < recipients.len() {
113            bail!(
114                "Not enough CPUs ({}) for {} cells (need at least 1 each)",
115                total_cpus,
116                recipients.len()
117            );
118        }
119
120        let total_weight: f64 = recipients.iter().map(|(_, w)| w).sum();
121        let num_recipients = recipients.len();
122
123        if total_weight <= 0.0 {
124            // Equal division fallback
125            let per = total_cpus / num_recipients;
126            let remainder = total_cpus % num_recipients;
127            return Ok(recipients
128                .iter()
129                .enumerate()
130                .map(|(i, (id, _))| {
131                    let extra = if i < remainder { 1 } else { 0 };
132                    (*id, per + extra)
133                })
134                .collect());
135        }
136
137        let distributable = total_cpus - num_recipients;
138        let mut assigned = num_recipients;
139        let mut raw: Vec<(u32, f64, usize)> = recipients
140            .iter()
141            .map(|(id, weight)| {
142                let frac = weight / total_weight * distributable as f64;
143                let floored = frac.floor() as usize;
144                assigned += floored;
145                (*id, frac - frac.floor(), 1 + floored)
146            })
147            .collect();
148
149        let mut remainder = total_cpus - assigned;
150        raw.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
151        for entry in raw.iter_mut() {
152            if remainder == 0 {
153                break;
154            }
155            entry.2 += 1;
156            remainder -= 1;
157        }
158
159        Ok(raw.iter().map(|(id, _, count)| (*id, *count)).collect())
160    }
161
162    /// Distribute CPUs among recipients proportionally by weight.
163    ///
164    /// Recipients with weight 0 receive 0 CPUs. If all weights are 0, falls back to
165    /// equal division. As a post-processing step, if any positive-weight recipient got
166    /// 0 CPUs, 1 CPU is stolen from the recipient with the highest allocation (that has
167    /// > 1) to prevent starvation.
168    fn distribute_cpus_proportional(
169        cpus: &[usize],
170        recipients: &[(u32, f64)],
171    ) -> Result<HashMap<u32, Vec<usize>>> {
172        if cpus.is_empty() {
173            bail!("distribute_cpus_proportional called with no CPUs");
174        }
175        if recipients.is_empty() {
176            bail!("distribute_cpus_proportional called with no recipients");
177        }
178
179        let total_weight: f64 = recipients.iter().map(|(_, w)| w).sum();
180        let n = cpus.len();
181
182        let mut allocs: Vec<(u32, usize)> = if total_weight <= 0.0 {
183            // Equal division fallback
184            let per = n / recipients.len();
185            let remainder = n % recipients.len();
186            recipients
187                .iter()
188                .enumerate()
189                .map(|(i, (id, _))| {
190                    let extra = if i < remainder { 1 } else { 0 };
191                    (*id, per + extra)
192                })
193                .collect()
194        } else {
195            // Standard proportional distribution
196            let mut assigned = 0usize;
197            let mut raw: Vec<(u32, f64, usize, bool)> = recipients
198                .iter()
199                .map(|(id, weight)| {
200                    let frac = weight / total_weight * n as f64;
201                    let floored = frac.floor() as usize;
202                    assigned += floored;
203                    (*id, frac - frac.floor(), floored, *weight > 0.0)
204                })
205                .collect();
206
207            let mut remainder = n - assigned;
208            raw.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
209            for entry in raw.iter_mut() {
210                if remainder == 0 {
211                    break;
212                }
213                entry.2 += 1;
214                remainder -= 1;
215            }
216
217            // Post-processing floor guarantee: if any positive-weight recipient got 0
218            // CPUs, steal 1 from the recipient with the highest allocation (> 1).
219            // This prevents death spirals where cells with low but non-zero demand
220            // get starved of CPUs entirely.
221            loop {
222                let Some(starved_idx) = raw
223                    .iter()
224                    .position(|(_, _, count, pos_weight)| *pos_weight && *count == 0)
225                else {
226                    break;
227                };
228                let Some(donor_idx) = raw
229                    .iter()
230                    .enumerate()
231                    .filter(|(_, (_, _, count, _))| *count > 1)
232                    .max_by_key(|(_, (_, _, count, _))| *count)
233                    .map(|(i, _)| i)
234                else {
235                    break; // No donor with > 1 CPU available
236                };
237                raw[donor_idx].2 -= 1;
238                raw[starved_idx].2 += 1;
239            }
240
241            raw.iter().map(|(id, _, count, _)| (*id, *count)).collect()
242        };
243
244        // Assign actual CPU numbers
245        let mut cpu_iter = cpus.iter().copied();
246        allocs.sort_by_key(|(id, _)| *id);
247        let mut result: HashMap<u32, Vec<usize>> = HashMap::new();
248        for (id, count) in allocs {
249            let cpus_for_recipient: Vec<usize> = cpu_iter.by_ref().take(count).collect();
250            if cpus_for_recipient.len() != count {
251                bail!(
252                    "BUG: distribute_cpus_proportional: cell {} expected {} CPUs but got {}",
253                    id,
254                    count,
255                    cpus_for_recipient.len()
256                );
257            }
258            if !cpus_for_recipient.is_empty() {
259                result.insert(id, cpus_for_recipient);
260            }
261        }
262
263        Ok(result)
264    }
265
266    /// Reserve best-effort requested minima before weighted allocation.
267    ///
268    /// Prefer CPUs without a claim. If those are insufficient, displace claims
269    /// only where every claimant remains above its protected minimum. The
270    /// optional topology partition map is a tie-breaker after exclusive CPUs:
271    /// prefer taking from the partition split across the most claimants.
272    fn reserve_requested_minimums(
273        &self,
274        recipients: &[CpuRecipient],
275        contention: &HashMap<usize, Vec<u32>>,
276    ) -> Result<HashMap<u32, Cpumask>> {
277        let by_id: HashMap<u32, &CpuRecipient> = recipients
278            .iter()
279            .map(|recipient| (recipient.id, recipient))
280            .collect();
281        let mut assignments = HashMap::new();
282        let mut reserved = Cpumask::new();
283
284        let mut requesters: Vec<&CpuRecipient> = recipients
285            .iter()
286            .filter(|recipient| recipient.minimum.requested > 0)
287            .collect();
288        requesters.sort_by_key(|recipient| recipient.id);
289
290        let mut partition_claimants: HashMap<usize, HashSet<u32>> = HashMap::new();
291        if let Some(cpu_to_partition) = self.cpu_to_partition {
292            for (cpu, claimants) in contention {
293                if let Some(&partition) = cpu_to_partition.get(cpu) {
294                    partition_claimants
295                        .entry(partition)
296                        .or_default()
297                        .extend(claimants);
298                }
299            }
300        }
301        let partition_pressure = |cpu: usize| {
302            self.cpu_to_partition
303                .and_then(|cpu_to_partition| cpu_to_partition.get(&cpu))
304                .and_then(|partition| partition_claimants.get(partition))
305                .map_or(0, HashSet::len)
306        };
307
308        let mut claimed_cpus: HashMap<u32, Vec<usize>> = HashMap::new();
309        for (&cpu, claimants) in contention {
310            for &id in claimants {
311                claimed_cpus.entry(id).or_default().push(cpu);
312            }
313        }
314
315        for recipient in requesters {
316            let mut recipient_reserved = Cpumask::new();
317            let mut unclaimed: Vec<usize> = self
318                .domain
319                .iter()
320                .filter(|&cpu| {
321                    recipient.allowed.test_cpu(cpu)
322                        && !reserved.test_cpu(cpu)
323                        && !contention.contains_key(&cpu)
324                })
325                .collect();
326            unclaimed.sort_unstable();
327
328            let mut taken = 0usize;
329            for cpu in unclaimed.into_iter().take(recipient.minimum.requested) {
330                recipient_reserved.set_cpu(cpu).ok();
331                reserved.set_cpu(cpu).ok();
332                taken += 1;
333            }
334
335            let mut taken_from: HashMap<u32, usize> =
336                claimed_cpus.keys().map(|&id| (id, 0)).collect();
337            while taken < recipient.minimum.requested {
338                // A contested CPU cannot prove that a claimant will survive
339                // later contention, so only exclusive claims count toward its
340                // protected floor.
341                let remaining: HashMap<u32, usize> = claimed_cpus
342                    .iter()
343                    .map(|(&id, cpus)| {
344                        (
345                            id,
346                            cpus.iter()
347                                .filter(|&&cpu| {
348                                    !reserved.test_cpu(cpu) && contention[&cpu].len() == 1
349                                })
350                                .count(),
351                        )
352                    })
353                    .collect();
354
355                let reservable = |cpu: usize| {
356                    recipient.allowed.test_cpu(cpu)
357                        && !reserved.test_cpu(cpu)
358                        && contention.get(&cpu).is_some_and(|claimants| {
359                            claimants.iter().all(|id| {
360                                let protected = by_id
361                                    .get(id)
362                                    .map_or(0, |recipient| recipient.minimum.protected);
363                                remaining.get(id).copied().unwrap_or(0) > protected
364                            })
365                        })
366                };
367
368                let donor = claimed_cpus
369                    .iter()
370                    .filter(|(_, cpus)| cpus.iter().any(|&cpu| reservable(cpu)))
371                    .map(|(&id, _)| id)
372                    .min_by(|&a, &b| {
373                        taken_from[&a]
374                            .cmp(&taken_from[&b])
375                            .then(remaining[&b].cmp(&remaining[&a]))
376                            .then(a.cmp(&b))
377                    });
378                let Some(donor) = donor else {
379                    break;
380                };
381
382                let cpu = claimed_cpus[&donor]
383                    .iter()
384                    .copied()
385                    .filter(|&cpu| reservable(cpu))
386                    .min_by(|&a, &b| {
387                        let contested = |cpu: usize| contention.get(&cpu).map_or(0, Vec::len) > 1;
388                        contested(a)
389                            .cmp(&contested(b))
390                            .then_with(|| partition_pressure(b).cmp(&partition_pressure(a)))
391                            .then(a.cmp(&b))
392                    })
393                    .expect("donor has a reservable CPU");
394                recipient_reserved.set_cpu(cpu).ok();
395                reserved.set_cpu(cpu).ok();
396                *taken_from.entry(donor).or_insert(0) += 1;
397                taken += 1;
398            }
399
400            if recipient_reserved.weight() > 0 {
401                assignments.insert(recipient.id, recipient_reserved);
402            }
403        }
404
405        Ok(assignments)
406    }
407
408    /// Compute CPU assignments over `domain` for a generic set of recipients.
409    ///
410    /// `allowed` is hard eligibility while `claimed` expresses preferential
411    /// ownership. Recipients without claims normally receive from the unclaimed
412    /// pool, but may reserve a requested minimum from anywhere they are allowed
413    /// without violating another recipient's protected minimum.
414    ///
415    /// When claimed masks overlap, contested CPUs are divided proportionally among
416    /// claimants. Unclaimed CPUs go to unpinned recipients.
417    ///
418    /// If `compute_borrowable` is true, each assignment includes a borrowable
419    /// cpumask consisting of `domain - primary`, intersected with `allowed` if
420    /// present.
421    fn compute_assignments(
422        &self,
423        recipients: &[CpuRecipient],
424        compute_borrowable: bool,
425    ) -> Result<Vec<CpuAssignment>> {
426        let domain = self.domain;
427
428        if recipients.is_empty() {
429            bail!("compute_cpu_assignments called with no recipients");
430        }
431
432        let mut seen_ids = HashSet::new();
433        for recipient in recipients {
434            if recipient.weight < 0.0 {
435                bail!(
436                    "Recipient {} has negative weight {}",
437                    recipient.id,
438                    recipient.weight
439                );
440            }
441            if !seen_ids.insert(recipient.id) {
442                bail!("Duplicate recipient id {}", recipient.id);
443            }
444            if let Some(claimed) = &recipient.claimed {
445                let claimed_outside_allowed = claimed.and(&recipient.allowed.not()).and(domain);
446                if claimed_outside_allowed.weight() > 0 {
447                    bail!(
448                        "Recipient {} claims CPUs outside its allowed mask: {}",
449                        recipient.id,
450                        claimed_outside_allowed.to_cpulist()
451                    );
452                }
453            }
454        }
455
456        // Phase 1: Build contention map - for each CPU, track which recipients claim it
457        let mut contention: HashMap<usize, Vec<u32>> = HashMap::new();
458        for recipient in recipients {
459            if let Some(ref claimed) = recipient.claimed {
460                for cpu in claimed.iter() {
461                    if domain.test_cpu(cpu) {
462                        contention.entry(cpu).or_default().push(recipient.id);
463                    }
464                }
465            }
466        }
467
468        let mut recipient_cpus = self.reserve_requested_minimums(recipients, &contention)?;
469        let mut reserved = Cpumask::new();
470        for cpus in recipient_cpus.values() {
471            reserved = reserved.or(cpus);
472        }
473
474        // Phase 2: Categorize CPUs and build initial assignments
475        // - Exclusive: claimed by exactly 1 recipient -> assigned directly
476        // - Contested: claimed by 2+ recipients -> distributed by weight
477        // - Unclaimed: no preferred claim -> shared among unpinned recipients
478        let mut contested_cpus: Vec<usize> = Vec::new();
479        let mut unclaimed_cpus: Vec<usize> = Vec::new();
480
481        for cpu in domain.iter() {
482            if reserved.test_cpu(cpu) {
483                continue;
484            }
485            match contention.get(&cpu) {
486                None => unclaimed_cpus.push(cpu),
487                Some(claimants) if claimants.len() == 1 => {
488                    let id = claimants[0];
489                    recipient_cpus
490                        .entry(id)
491                        .or_insert_with(Cpumask::new)
492                        .set_cpu(cpu)
493                        .ok();
494                }
495                Some(_) => contested_cpus.push(cpu),
496            }
497        }
498
499        // Seed the running assignment count from requested minima and exclusive
500        // claims, then satisfy protected minima from contested CPUs before
501        // target-weighted distribution. Otherwise, a claimant which already has
502        // an exclusive CPU can win the only contested CPU from a claimant at
503        // zero merely due to recipient-ID ordering.
504        let mut assigned_count: HashMap<u32, usize> = recipient_cpus
505            .iter()
506            .map(|(&id, mask)| (id, mask.weight()))
507            .collect();
508        contested_cpus.sort_unstable();
509        let contested_choices: HashMap<u32, usize> = recipients
510            .iter()
511            .map(|recipient| {
512                let choices = contested_cpus
513                    .iter()
514                    .filter(|cpu| {
515                        contention
516                            .get(cpu)
517                            .is_some_and(|claimants| claimants.contains(&recipient.id))
518                    })
519                    .count();
520                (recipient.id, choices)
521            })
522            .collect();
523        let mut weighted_contested_cpus = Vec::new();
524        for cpu in contested_cpus {
525            let claimants = contention
526                .get(&cpu)
527                .expect("contested CPU missing contention entry");
528            let protected_recipient = claimants
529                .iter()
530                .filter_map(|id| {
531                    let recipient = recipients
532                        .iter()
533                        .find(|recipient| recipient.id == *id)
534                        .expect("claimant missing recipient");
535                    let assigned = assigned_count.get(id).copied().unwrap_or(0);
536                    (assigned < recipient.minimum.protected)
537                        .then(|| (*id, recipient.minimum.protected - assigned))
538                })
539                // Prefer the recipient with fewer alternative contested CPUs,
540                // then the larger protected deficit, then the lower ID.
541                .min_by(|(a_id, a_deficit), (b_id, b_deficit)| {
542                    contested_choices[a_id]
543                        .cmp(&contested_choices[b_id])
544                        .then(b_deficit.cmp(a_deficit))
545                        .then(a_id.cmp(b_id))
546                })
547                .map(|(id, _)| id);
548
549            if let Some(id) = protected_recipient {
550                recipient_cpus
551                    .entry(id)
552                    .or_insert_with(Cpumask::new)
553                    .set_cpu(cpu)
554                    .ok();
555                *assigned_count.entry(id).or_insert(0) += 1;
556            } else {
557                weighted_contested_cpus.push(cpu);
558            }
559        }
560        let contested_cpus = weighted_contested_cpus;
561
562        // Compute global targets for the remaining weighted allocation.
563        let total_cpu_count = domain.weight();
564        let mut all_recipients_with_weights: Vec<(u32, f64)> =
565            recipients.iter().map(|r| (r.id, r.weight)).collect();
566        all_recipients_with_weights.sort_by_key(|(id, _)| *id);
567
568        let targets = Self::compute_targets(total_cpu_count, &all_recipients_with_weights)?;
569
570        // Phase 3: Distribute contested CPUs among claimants using deficit weights
571        let mut contested_groups: HashMap<Vec<u32>, Vec<usize>> = HashMap::new();
572        for cpu in contested_cpus {
573            if let Some(claimants) = contention.get(&cpu) {
574                let mut sorted_claimants = claimants.clone();
575                sorted_claimants.sort();
576                contested_groups
577                    .entry(sorted_claimants)
578                    .or_default()
579                    .push(cpu);
580            }
581        }
582
583        // Freeze deficit weights before processing any group. Each group is an
584        // independent allocation decision, so a recipient's weight should not depend
585        // on HashMap iteration order (i.e., which other groups were processed
586        // first). Using the initial deficit (target - exclusive_count) as weight
587        // for all groups makes the result deterministic.
588        let initial_deficit: HashMap<u32, f64> = targets
589            .iter()
590            .map(|(&id, &target)| {
591                // Recipients with no exclusive CPUs have no entry yet; 0 is correct.
592                let already = assigned_count.get(&id).copied().unwrap_or(0);
593                let deficit = if target > already {
594                    (target - already) as f64
595                } else {
596                    0.0
597                };
598                (id, deficit)
599            })
600            .collect();
601
602        for (claimants, cpus) in contested_groups {
603            let mut recipients_with_deficit: Vec<(u32, f64)> = Vec::new();
604            let mut all_zero = true;
605            for &id in &claimants {
606                let deficit = *initial_deficit.get(&id).ok_or_else(|| {
607                    anyhow::anyhow!(
608                        "BUG: recipient {} in contention map but missing from targets",
609                        id
610                    )
611                })?;
612                if deficit > 0.0 {
613                    all_zero = false;
614                }
615                recipients_with_deficit.push((id, deficit));
616            }
617
618            // If all claimants already meet/exceed their target, fall back to equal weights
619            if all_zero {
620                recipients_with_deficit = claimants.iter().map(|&id| (id, 1.0)).collect();
621            }
622
623            let distribution = Self::distribute_cpus_proportional(&cpus, &recipients_with_deficit)?;
624            for (id, assigned_cpus) in distribution {
625                let count = assigned_cpus.len();
626                for cpu in assigned_cpus {
627                    recipient_cpus
628                        .entry(id)
629                        .or_insert_with(Cpumask::new)
630                        .set_cpu(cpu)
631                        .ok();
632                }
633                *assigned_count.entry(id).or_insert(0) += count;
634            }
635        }
636
637        // Phase 4: Distribute unclaimed CPUs among unpinned recipients using deficit weights
638        if !unclaimed_cpus.is_empty() {
639            let mut unpinned_recipients: Vec<(u32, f64)> = Vec::new();
640            let mut all_zero = true;
641
642            for recipient in recipients {
643                if recipient.claimed.is_some() {
644                    continue; // pinned recipients don't receive unclaimed CPUs
645                }
646                let target = *targets.get(&recipient.id).ok_or_else(|| {
647                    anyhow::anyhow!(
648                        "BUG: recipient {} is unpinned but missing from targets",
649                        recipient.id
650                    )
651                })?;
652                // Recipients with no exclusive CPUs have no entry yet; 0 is correct.
653                let already = assigned_count.get(&recipient.id).copied().unwrap_or(0);
654                let deficit = if target > already {
655                    (target - already) as f64
656                } else {
657                    0.0
658                };
659                if deficit > 0.0 {
660                    all_zero = false;
661                }
662                unpinned_recipients.push((recipient.id, deficit));
663            }
664            unpinned_recipients.sort_by_key(|(id, _)| *id);
665
666            // If all recipients already meet/exceed their target, fall back to equal weights
667            if all_zero {
668                unpinned_recipients = unpinned_recipients
669                    .iter()
670                    .map(|(id, _)| (*id, 1.0))
671                    .collect();
672            }
673
674            let distribution =
675                Self::distribute_cpus_proportional(&unclaimed_cpus, &unpinned_recipients)?;
676            for (id, assigned_cpus) in distribution {
677                let count = assigned_cpus.len();
678                for cpu in assigned_cpus {
679                    recipient_cpus
680                        .entry(id)
681                        .or_insert_with(Cpumask::new)
682                        .set_cpu(cpu)
683                        .ok();
684                }
685                *assigned_count.entry(id).or_insert(0) += count;
686            }
687        }
688
689        // Phase 5: Verify the legacy non-empty invariant and every explicit
690        // protected minimum.
691        for recipient in recipients {
692            let assigned = recipient_cpus.get(&recipient.id).map_or(0, Cpumask::weight);
693            let required = recipient.minimum.protected.max(1);
694            if assigned < required {
695                bail!(
696                    "Recipient {} has {} CPUs assigned, below required minimum {} \
697                     (nr_cpus={}, num_recipients={})",
698                    recipient.id,
699                    assigned,
700                    required,
701                    domain.weight(),
702                    recipients.len()
703                );
704            }
705        }
706
707        let allowed_by_id: HashMap<u32, Cpumask> = recipients
708            .iter()
709            .map(|recipient| (recipient.id, recipient.allowed.clone()))
710            .collect();
711
712        // Phase 6: Build CpuAssignment results, optionally computing borrowable masks
713        Ok(recipient_cpus
714            .into_iter()
715            .map(|(id, primary)| {
716                let borrowable = if compute_borrowable {
717                    let allowed = allowed_by_id
718                        .get(&id)
719                        .expect("recipient assignment missing allowed mask");
720                    Some(domain.and(&primary.not()).and(allowed))
721                } else {
722                    None
723                };
724                CpuAssignment {
725                    id,
726                    primary,
727                    borrowable,
728                }
729            })
730            .collect())
731    }
732}
733
734fn read_dir_sorted(path: &Path) -> Result<Vec<DirEntry>> {
735    let readdir = std::fs::read_dir(path)
736        .with_context(|| format!("Failed to read directory: {}", path.display()))?;
737
738    let mut entries: Vec<_> = readdir
739        .map(|entry| {
740            entry.with_context(|| format!("Failed to read directory entry in: {}", path.display()))
741        })
742        .collect::<Result<Vec<_>>>()?;
743    entries.sort_by_key(|entry| entry.path());
744
745    Ok(entries)
746}
747
748/// Manages cells for direct child cgroups of a specified parent
749pub struct CellManager {
750    cell_parent_path: PathBuf,
751    inotify: Inotify,
752    /// Maps cgroup ID to cell info
753    cells: HashMap<u64, CellInfo>,
754    /// Maps cell ID to cgroup ID (for reverse lookup)
755    cell_id_to_cgid: HashMap<u32, u64>,
756    /// Freed cell IDs available for reuse
757    free_cell_ids: BTreeSet<u32>,
758    /// Cell IDs of destroyed cgroups whose sub-scheduler has not detached yet.
759    /// An ID stays out of reuse until then, so the cell's recorded owner never
760    /// changes while a sub-scheduler is attached to it. See
761    /// release_detached_cells().
762    detaching_cells: BTreeSet<u32>,
763    next_cell_id: u32,
764    max_cells: u32,
765    /// Cpumask of all CPUs in the system (from topology)
766    all_cpus: Cpumask,
767    /// Cgroup directory names to exclude from cell creation
768    exclude_names: HashSet<String>,
769    /// Number of CPUs to hold out for cell 0 before child cpusets are
770    /// applied. 0 disables the holdout.
771    cell0_min_cpus: usize,
772    /// CPU -> LLC id. Steers holdout selection toward the LLC split across the
773    /// most cells once unclaimed CPUs run out; empty falls back to lowest CPU
774    /// number.
775    cpu_to_llc: HashMap<usize, usize>,
776    /// Set true (and never reset) the first time the holdout has to take a CPU
777    /// already claimed by a workload cell rather than only unclaimed CPUs.
778    /// Surfaced via stats so a host with unexpected performance can be checked
779    /// for whether the holdout took CPUs away from a workload.
780    enforced_holdout: AtomicBool,
781}
782
783impl CellManager {
784    pub fn new(
785        cell_parent_path: &str,
786        max_cells: u32,
787        all_cpus: Cpumask,
788        exclude: HashSet<String>,
789        cell0_min_cpus: usize,
790        cpu_to_llc: HashMap<usize, usize>,
791    ) -> Result<Self> {
792        let path = PathBuf::from(format!("/sys/fs/cgroup{}", cell_parent_path));
793        if !path.exists() {
794            bail!("Cell parent cgroup path does not exist: {}", path.display());
795        }
796        Self::new_with_path_opts(
797            path,
798            max_cells,
799            all_cpus,
800            exclude,
801            cell0_min_cpus,
802            cpu_to_llc,
803        )
804    }
805
806    /// Test-only 4-argument constructor with the holdout defaulted off
807    /// (`cell0_min_cpus = 0`, empty `cpu_to_llc`). Tests that exercise the
808    /// holdout call [`Self::new_with_path_opts`] directly with those arguments.
809    #[cfg(test)]
810    fn new_with_path(
811        path: PathBuf,
812        max_cells: u32,
813        all_cpus: Cpumask,
814        exclude: HashSet<String>,
815    ) -> Result<Self> {
816        Self::new_with_path_opts(path, max_cells, all_cpus, exclude, 0, HashMap::new())
817    }
818
819    fn new_with_path_opts(
820        path: PathBuf,
821        max_cells: u32,
822        all_cpus: Cpumask,
823        exclude: HashSet<String>,
824        cell0_min_cpus: usize,
825        cpu_to_llc: HashMap<usize, usize>,
826    ) -> Result<Self> {
827        let inotify = Inotify::init().context("Failed to initialize inotify")?;
828        inotify
829            .watches()
830            .add(&path, WatchMask::CREATE | WatchMask::DELETE)
831            .context("Failed to add inotify watch")?;
832
833        let mut mgr = Self {
834            cell_parent_path: path.clone(),
835            inotify,
836            cells: HashMap::new(),
837            cell_id_to_cgid: HashMap::new(),
838            free_cell_ids: BTreeSet::new(),
839            detaching_cells: BTreeSet::new(),
840            next_cell_id: 1, // Cell 0 is reserved for root
841            max_cells,
842            all_cpus,
843            exclude_names: exclude,
844            cell0_min_cpus,
845            cpu_to_llc,
846            enforced_holdout: AtomicBool::new(false),
847        };
848
849        // Insert cell 0 as a permanent entry. cgid 0 is a safe sentinel —
850        // real cgroup inode numbers are always > 0.
851        mgr.cells.insert(
852            0,
853            CellInfo {
854                cell_id: 0,
855                cgroup_path: None,
856                cgid: None,
857                cpuset: None,
858            },
859        );
860        mgr.cell_id_to_cgid.insert(0, 0);
861
862        // Scan for existing children at startup
863        mgr.scan_existing_children()
864            .context("Failed to scan existing child cgroups at startup")?;
865        Ok(mgr)
866    }
867
868    /// True once the holdout has had to take a CPU already claimed by a workload
869    /// cell (sticky from scheduler start). Surfaced via stats.
870    pub fn enforced_holdout(&self) -> bool {
871        self.enforced_holdout.load(Ordering::Relaxed)
872    }
873
874    fn should_exclude(&self, path: &Path) -> bool {
875        path.file_name()
876            .and_then(|n| n.to_str())
877            .map(|name| self.exclude_names.contains(name))
878            .unwrap_or(false)
879    }
880
881    fn scan_existing_children(&mut self) -> Result<Vec<(u64, u32)>> {
882        let mut assignments = Vec::new();
883
884        let entries = read_dir_sorted(&self.cell_parent_path)
885            .context("Failed to read cell parent directory")?;
886
887        for entry in entries {
888            let file_type = entry.file_type().with_context(|| {
889                format!("Failed to get file type for: {}", entry.path().display())
890            })?;
891            if file_type.is_dir() {
892                let path = entry.path();
893                if self.should_exclude(&path) {
894                    continue;
895                }
896                let cgid = path
897                    .metadata()
898                    .with_context(|| {
899                        format!("reading inode of cgroup directory {}", path.display())
900                    })?
901                    .ino();
902                let (cgid, cell_id) =
903                    self.create_cell_for_cgroup(&path, cgid).with_context(|| {
904                        format!("Failed to create cell for cgroup: {}", path.display())
905                    })?;
906                assignments.push((cgid, cell_id));
907            }
908        }
909        Ok(assignments)
910    }
911
912    /// Process pending inotify events. Returns list of (cgid, cell_id) for new cells
913    /// and list of cell_ids that were destroyed.
914    ///
915    /// Rather than processing individual events, we simply check if any events occurred
916    /// and then rescan the directory to reconcile state. This is simpler and handles
917    /// edge cases like inotify queue overflow gracefully.
918    ///
919    /// `read_records` returns the cell records as read_cell_sub_cgids() does.
920    /// It runs after the directory listing, so that a sub-scheduler which
921    /// attached to a cgroup removed before the listing still counts as holding
922    /// its cell.
923    #[allow(clippy::type_complexity)]
924    pub fn process_events(
925        &mut self,
926        read_records: impl FnOnce() -> Result<Vec<u64>>,
927    ) -> Result<(Vec<(u64, u32)>, Vec<u32>)> {
928        let mut buffer = [0; 1024];
929        let mut has_events = false;
930
931        // Drain all pending events
932        loop {
933            match self.inotify.read_events(&mut buffer) {
934                Ok(events) => {
935                    if events.into_iter().next().is_some() {
936                        has_events = true;
937                    } else {
938                        break;
939                    }
940                }
941                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
942                Err(e) => {
943                    return Err(e).context("Failed to read inotify events");
944                }
945            }
946        }
947
948        if !has_events {
949            return Ok((Vec::new(), Vec::new()));
950        }
951
952        // Rescan directory and reconcile with our tracked state
953        let current_entries = self.scan_cells()?;
954        let sub_cgids = read_records()?;
955        self.reconcile_scanned(current_entries, &sub_cgids)
956    }
957
958    /// Reconcile our tracked cells with the actual cgroup directory contents.
959    /// Returns (new_cells, destroyed_cells).
960    #[cfg(test)]
961    fn reconcile_cells(&mut self, sub_cgids: &[u64]) -> Result<(Vec<(u64, u32)>, Vec<u32>)> {
962        let current_entries = self.scan_cells()?;
963        self.reconcile_scanned(current_entries, sub_cgids)
964    }
965
966    /// Snapshot current child cgroups by path and inode.
967    fn scan_cells(&self) -> Result<BTreeMap<PathBuf, u64>> {
968        // Reconcile by identity, not path alone, so path reuse doesn't keep
969        // the old cell and create a second one for the new inode.
970        let mut current_entries: BTreeMap<PathBuf, u64> = BTreeMap::new();
971        let entries = read_dir_sorted(&self.cell_parent_path)?;
972        for entry in entries {
973            let file_type = entry.file_type().with_context(|| {
974                format!("Failed to get file type for: {}", entry.path().display())
975            })?;
976            if file_type.is_dir() {
977                let path = entry.path();
978                if self.should_exclude(&path) {
979                    continue;
980                }
981
982                let metadata = match entry.metadata() {
983                    Ok(metadata) => metadata,
984                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
985                        // Directory disappeared after readdir(); retry on the
986                        // next reconcile instead of failing this scan.
987                        continue;
988                    }
989                    Err(e) => {
990                        return Err(e).with_context(|| {
991                            format!("reading inode of cgroup directory {}", path.display())
992                        });
993                    }
994                };
995
996                current_entries.insert(path, metadata.ino());
997            }
998        }
999        Ok(current_entries)
1000    }
1001
1002    /// Reconcile the tracked cells with a directory snapshot. `sub_cgids` is
1003    /// the cell records as read_cell_sub_cgids() returns them. Returns
1004    /// (new_cells, destroyed_cells).
1005    #[allow(clippy::type_complexity)]
1006    fn reconcile_scanned(
1007        &mut self,
1008        current_entries: BTreeMap<PathBuf, u64>,
1009        sub_cgids: &[u64],
1010    ) -> Result<(Vec<(u64, u32)>, Vec<u32>)> {
1011        let mut new_cells = Vec::new();
1012
1013        // Remove cells for cgroups that no longer exist
1014        let mut destroyed_cells: BTreeSet<u32> = BTreeSet::new();
1015        self.cells.retain(|&cgid, info| {
1016            if info.cell_id == 0 {
1017                return true; // Cell 0 is permanent
1018            }
1019            // Non-zero cells always have a cgroup_path
1020            let cgroup_path = info
1021                .cgroup_path
1022                .as_ref()
1023                .expect("BUG: non-zero cell missing cgroup_path");
1024            // Same path with a different inode means the old cgroup was
1025            // replaced and this tracked cell must be dropped.
1026            if current_entries.get(cgroup_path) == Some(&cgid) {
1027                true
1028            } else {
1029                info!(
1030                    "Destroyed cell {} for cgroup {} (cgid={})",
1031                    info.cell_id,
1032                    cgroup_path.display(),
1033                    cgid
1034                );
1035                destroyed_cells.insert(info.cell_id);
1036                false
1037            }
1038        });
1039
1040        // A destroyed cell's ID is reused at once unless a sub-scheduler still
1041        // holds the cell. Then it waits in detaching_cells for the detach.
1042        self.cell_id_to_cgid
1043            .retain(|cell_id, _| !destroyed_cells.contains(cell_id));
1044        for &cell_id in &destroyed_cells {
1045            if sub_cgids[cell_id as usize] == 0 {
1046                self.free_cell_ids.insert(cell_id);
1047            } else {
1048                self.detaching_cells.insert(cell_id);
1049            }
1050        }
1051
1052        // Find new cgroups that we don't have cells for
1053        for (path, cgid) in current_entries {
1054            if self.cells.contains_key(&cgid) {
1055                continue; // Already have a cell for this cgroup
1056            }
1057            let (cgid, cell_id) = self
1058                .create_cell_for_cgroup(&path, cgid)
1059                .with_context(|| format!("Failed to create cell for cgroup: {}", path.display()))?;
1060            new_cells.push((cgid, cell_id));
1061        }
1062
1063        Ok((new_cells, destroyed_cells.into_iter().collect()))
1064    }
1065
1066    fn create_cell_for_cgroup(&mut self, path: &Path, cgid: u64) -> Result<(u64, u32)> {
1067        let cell_id = self.allocate_cell_id().context("allocating cell ID")?;
1068
1069        let cpuset = Self::read_cpuset(path)
1070            .with_context(|| format!("reading cpuset for cgroup {}", path.display()))?;
1071        if let Some(ref mask) = cpuset {
1072            let cpuset_path = path.join("cpuset.cpus");
1073            debug!(
1074                "Cell {} has cpuset: {} (from {})",
1075                cell_id,
1076                mask.to_cpulist(),
1077                cpuset_path.display()
1078            );
1079        }
1080
1081        self.cells.insert(
1082            cgid,
1083            CellInfo {
1084                cell_id,
1085                cgroup_path: Some(path.to_path_buf()),
1086                cgid: Some(cgid),
1087                cpuset,
1088            },
1089        );
1090        self.cell_id_to_cgid.insert(cell_id, cgid);
1091
1092        info!(
1093            "Created cell {} for cgroup {} (cgid={})",
1094            cell_id,
1095            path.display(),
1096            cgid
1097        );
1098
1099        Ok((cgid, cell_id))
1100    }
1101
1102    /// Read cpuset.cpus from a cgroup path. Returns None if empty or unavailable.
1103    fn read_cpuset(cgroup_path: &Path) -> Result<Option<Cpumask>> {
1104        let cpuset_path = cgroup_path.join("cpuset.cpus");
1105        match std::fs::read_to_string(&cpuset_path) {
1106            Ok(content) => {
1107                let content = content.trim();
1108                if content.is_empty() {
1109                    Ok(None)
1110                } else {
1111                    let mask = Cpumask::from_cpulist(content).with_context(|| {
1112                        format!(
1113                            "Failed to parse cpuset '{}' from {}",
1114                            content,
1115                            cpuset_path.display()
1116                        )
1117                    })?;
1118                    Ok(Some(mask))
1119                }
1120            }
1121            // File doesn't exist - cpuset controller is not enabled for this cgroup
1122            Err(_) => Ok(None),
1123        }
1124    }
1125
1126    /// Free the destroyed cells whose sub-scheduler has detached since.
1127    /// `sub_cgids` is the cell records as read_cell_sub_cgids() returns them.
1128    /// Returns how many cells were freed.
1129    pub fn release_detached_cells(&mut self, sub_cgids: &[u64]) -> usize {
1130        let released: Vec<u32> = self
1131            .detaching_cells
1132            .iter()
1133            .copied()
1134            .filter(|&id| sub_cgids[id as usize] == 0)
1135            .collect();
1136        for &id in &released {
1137            self.detaching_cells.remove(&id);
1138            self.free_cell_ids.insert(id);
1139        }
1140        released.len()
1141    }
1142
1143    fn allocate_cell_id(&mut self) -> Result<u32> {
1144        // Prefer reusing freed IDs to keep cell ID space compact
1145        if let Some(id) = self.free_cell_ids.pop_first() {
1146            return Ok(id);
1147        }
1148
1149        if self.next_cell_id >= self.max_cells {
1150            bail!("Cell ID space exhausted (max_cells={})", self.max_cells);
1151        }
1152
1153        let id = self.next_cell_id;
1154        self.next_cell_id += 1;
1155        Ok(id)
1156    }
1157
1158    /// Compute CPU assignments for all cells.
1159    ///
1160    /// When cpusets overlap, contested CPUs are divided proportionally among claimants.
1161    /// Unclaimed CPUs go to cell 0 and any unpinned cells (cells without cpusets).
1162    ///
1163    /// If `compute_borrowable` is true, each assignment includes a borrowable cpumask
1164    /// (all system CPUs minus the cell's own, intersected with cpuset if present).
1165    /// Without demand data, borrowable masks are uncapped.
1166    ///
1167    /// Returns a Vec of CpuAssignment, or an error if any cell would
1168    /// receive zero CPUs (which indicates too many cells for available CPUs).
1169    pub fn compute_cpu_assignments(&self, compute_borrowable: bool) -> Result<Vec<CpuAssignment>> {
1170        // Use equal weights for all cells (no demand data)
1171        self.compute_cpu_assignments_inner(None, compute_borrowable)
1172    }
1173
1174    /// Compute CPU assignments weighted by per-cell demand.
1175    ///
1176    /// `cell_demands` maps cell_id -> smoothed_util_pct. All active cells must be
1177    /// present in the map; missing entries or negative weights are errors.
1178    ///
1179    /// If `compute_borrowable` is true, each assignment includes a borrowable cpumask
1180    /// (all system CPUs minus the cell's own, intersected with cpuset if present).
1181    pub fn compute_demand_cpu_assignments(
1182        &self,
1183        cell_demands: &HashMap<u32, f64>,
1184        compute_borrowable: bool,
1185    ) -> Result<Vec<CpuAssignment>> {
1186        self.compute_cpu_assignments_inner(Some(cell_demands), compute_borrowable)
1187    }
1188
1189    /// Internal implementation shared by equal-weight and demand-weighted assignment.
1190    fn compute_cpu_assignments_inner(
1191        &self,
1192        cell_demands: Option<&HashMap<u32, f64>>,
1193        compute_borrowable: bool,
1194    ) -> Result<Vec<CpuAssignment>> {
1195        let recipients: Vec<CpuRecipient> = self
1196            .cells
1197            .values()
1198            .map(|info| {
1199                let weight = match cell_demands {
1200                    Some(demands) => {
1201                        let weight = *demands
1202                            .get(&info.cell_id)
1203                            .ok_or_else(|| {
1204                                anyhow::anyhow!("Cell {} is missing from demands map", info.cell_id)
1205                            })
1206                            .context("building cell demand weights map")?;
1207                        if weight < 0.0 {
1208                            bail!(
1209                                "Cell {} has negative demand weight {}",
1210                                info.cell_id,
1211                                weight
1212                            );
1213                        }
1214                        weight
1215                    }
1216                    None => 1.0,
1217                };
1218                Ok(CpuRecipient {
1219                    id: info.cell_id,
1220                    weight,
1221                    allowed: info.cpuset.clone().unwrap_or_else(|| self.all_cpus.clone()),
1222                    claimed: info.cpuset.clone(),
1223                    minimum: if info.cell_id == 0 {
1224                        CpuMinimum {
1225                            protected: 0,
1226                            requested: self.cell0_min_cpus,
1227                        }
1228                    } else {
1229                        CpuMinimum {
1230                            protected: 1,
1231                            requested: 0,
1232                        }
1233                    },
1234                })
1235            })
1236            .collect::<Result<Vec<_>>>()?;
1237
1238        let assignments = CpuManager::with_partitions(&self.all_cpus, &self.cpu_to_llc)
1239            .compute_assignments(&recipients, compute_borrowable)?;
1240
1241        // The sticky statistic records when satisfying cell 0's requested
1242        // minimum displaced any CPU preferentially claimed by a workload cell.
1243        if self.cell0_min_cpus > 0 {
1244            let mut workload_claims = Cpumask::new();
1245            for recipient in recipients.iter().filter(|recipient| recipient.id != 0) {
1246                if let Some(claimed) = &recipient.claimed {
1247                    workload_claims = workload_claims.or(claimed);
1248                }
1249            }
1250            if assignments
1251                .iter()
1252                .find(|assignment| assignment.id == 0)
1253                .is_some_and(|assignment| assignment.primary.and(&workload_claims).weight() > 0)
1254            {
1255                self.enforced_holdout.store(true, Ordering::Relaxed);
1256            }
1257        }
1258
1259        Ok(assignments)
1260    }
1261
1262    /// Returns all cell assignments as (cgid, cell_id) pairs.
1263    /// Used to configure BPF with cgroup-to-cell mappings.
1264    pub fn get_cell_assignments(&self) -> Vec<(u64, u32)> {
1265        let mut assignments: Vec<_> = self
1266            .cells
1267            .values()
1268            .filter(|info| info.cell_id != 0)
1269            .map(|info| {
1270                (
1271                    info.cgid.expect("BUG: non-zero cell missing cgid"),
1272                    info.cell_id,
1273                )
1274            })
1275            .collect();
1276        assignments.sort_by_key(|(_cgid, cell_id)| *cell_id);
1277        assignments
1278    }
1279
1280    /// Format the cell configuration as a compact string for logging.
1281    /// Example output: "[0: 0-7] [1(container-a): 8-15] [2(container-b): 16-23]"
1282    pub fn format_cell_config(&self, cpu_assignments: &[CpuAssignment]) -> String {
1283        let mut sorted: Vec<_> = cpu_assignments.iter().collect();
1284        sorted.sort_by_key(|a| a.id);
1285
1286        let mut parts = Vec::new();
1287        for assignment in sorted {
1288            let cpulist = assignment.primary.to_cpulist();
1289            if assignment.id == 0 {
1290                parts.push(format!("[0: {}]", cpulist));
1291            } else {
1292                // Find cgroup name for this cell
1293                let name = self
1294                    .cells
1295                    .values()
1296                    .find(|info| info.cell_id == assignment.id)
1297                    .and_then(|info| {
1298                        info.cgroup_path
1299                            .as_ref()
1300                            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
1301                    })
1302                    .unwrap_or_else(|| "?".to_string());
1303                parts.push(format!("[{}({}): {}]", assignment.id, name, cpulist));
1304            }
1305        }
1306        parts.join(" ")
1307    }
1308
1309    /// Return the cgroup path for a cell, relative to the cgroup root as it
1310    /// appears in `/proc/<pid>/cgroup` (e.g. `/test.slice/foobar`), so consumers
1311    /// can map a cell id to its cgroup. Cell 0 (the root cell) is `/`.
1312    pub fn cgroup_path_for_cell(&self, cell_id: u32) -> String {
1313        self.cell_id_to_cgid
1314            .get(&cell_id)
1315            .and_then(|cgid| self.cells.get(cgid))
1316            .and_then(|info| info.cgroup_path.as_deref())
1317            .map(cgroup_root_relative)
1318            .unwrap_or_else(|| "/".to_string())
1319    }
1320
1321    /// Re-read cpuset.cpus for all cells and update stored cpusets.
1322    /// Returns true if any cell's cpuset changed.
1323    pub fn refresh_cpusets(&mut self) -> Result<bool> {
1324        let mut changed = false;
1325        for info in self.cells.values_mut() {
1326            let Some(ref cgroup_path) = info.cgroup_path else {
1327                continue; // cell 0 has no cgroup
1328            };
1329            let new_cpuset = Self::read_cpuset(cgroup_path)
1330                .with_context(|| format!("reading cpuset for cgroup {}", cgroup_path.display()))?;
1331            if new_cpuset != info.cpuset {
1332                info!(
1333                    "Cell {} cpuset changed: {:?} -> {:?} ({})",
1334                    info.cell_id,
1335                    info.cpuset.as_ref().map(|m| m.to_cpulist()),
1336                    new_cpuset.as_ref().map(|m| m.to_cpulist()),
1337                    cgroup_path.display(),
1338                );
1339                info.cpuset = new_cpuset;
1340                changed = true;
1341            }
1342        }
1343        Ok(changed)
1344    }
1345}
1346
1347impl AsFd for CellManager {
1348    fn as_fd(&self) -> BorrowedFd<'_> {
1349        self.inotify.as_fd()
1350    }
1351}
1352
1353#[cfg(test)]
1354impl CellManager {
1355    /// Returns the number of cells created for cgroups.
1356    /// Does not include cell 0 (the implicit root cell).
1357    fn cell_count(&self) -> usize {
1358        self.cells.values().filter(|c| c.cell_id != 0).count()
1359    }
1360
1361    /// Get all cell IDs for cells created for cgroups.
1362    /// Does not include cell 0 (the implicit root cell).
1363    fn get_cell_ids(&self) -> Vec<u32> {
1364        self.cells
1365            .values()
1366            .filter(|c| c.cell_id != 0)
1367            .map(|c| c.cell_id)
1368            .collect()
1369    }
1370
1371    /// Find a cell by cgroup directory name.
1372    /// Only searches cells created for cgroups, not cell 0.
1373    fn find_cell_by_name(&self, name: &str) -> Option<&CellInfo> {
1374        self.cells.values().filter(|c| c.cell_id != 0).find(|c| {
1375            c.cgroup_path
1376                .as_ref()
1377                .and_then(|p| p.file_name())
1378                .map(|n| n.to_str() == Some(name))
1379                .unwrap_or(false)
1380        })
1381    }
1382}
1383
1384#[cfg(test)]
1385#[allow(clippy::unwrap_used)]
1386mod tests {
1387    use super::*;
1388    use tempfile::TempDir;
1389
1390    /// The cell records as the BPF side reports them with no sub-scheduler
1391    /// attached anywhere, sized for the test managers' max_cells.
1392    const NO_SUBS: [u64; 256] = [0; 256];
1393
1394    fn cpumask_for_range(nr_cpus: usize) -> Cpumask {
1395        scx_utils::set_cpumask_test_width(nr_cpus);
1396        let mut mask = Cpumask::new();
1397        for cpu in 0..nr_cpus {
1398            mask.set_cpu(cpu).unwrap();
1399        }
1400        mask
1401    }
1402
1403    // ==================== cgroup path mapping tests ====================
1404
1405    #[test]
1406    fn test_cgroup_path_for_cell() {
1407        let tmp = TempDir::new().unwrap();
1408        let child = tmp.path().join("container-a");
1409        std::fs::create_dir(&child).unwrap();
1410        let mgr = CellManager::new_with_path(
1411            tmp.path().to_path_buf(),
1412            256,
1413            cpumask_for_range(16),
1414            HashSet::new(),
1415        )
1416        .unwrap();
1417
1418        // Cell 0 is the root cell -> "/".
1419        assert_eq!(mgr.cgroup_path_for_cell(0), "/");
1420
1421        // A created cell maps to its cgroup. The TempDir is not under the cgroup
1422        // mount, so the path is returned as-is (the strip falls back to the
1423        // absolute path); the mount-strip itself is covered below.
1424        let cell_id = mgr.find_cell_by_name("container-a").unwrap().cell_id;
1425        assert_eq!(
1426            mgr.cgroup_path_for_cell(cell_id),
1427            child.to_string_lossy().into_owned()
1428        );
1429
1430        // A real (mount-absolute) cgroup path is reported relative to the root.
1431        assert_eq!(
1432            cgroup_root_relative(Path::new("/sys/fs/cgroup/test.slice/foobar")),
1433            "/test.slice/foobar"
1434        );
1435    }
1436
1437    fn cpumask_from_cpulist(nr_cpus: usize, cpulist: &str) -> Cpumask {
1438        scx_utils::set_cpumask_test_width(nr_cpus);
1439        Cpumask::from_cpulist(cpulist).unwrap()
1440    }
1441
1442    fn find_assignment(assignments: &[CpuAssignment], id: u32) -> &CpuAssignment {
1443        assignments.iter().find(|a| a.id == id).unwrap()
1444    }
1445
1446    // ==================== Cell scanning and creation tests ====================
1447
1448    #[test]
1449    fn test_scan_empty_directory() {
1450        let tmp = TempDir::new().unwrap();
1451        let mgr = CellManager::new_with_path(
1452            tmp.path().to_path_buf(),
1453            256,
1454            cpumask_for_range(16),
1455            HashSet::new(),
1456        )
1457        .unwrap();
1458
1459        assert_eq!(mgr.cell_count(), 0);
1460    }
1461
1462    #[test]
1463    fn test_scan_existing_subdirectories() {
1464        let tmp = TempDir::new().unwrap();
1465
1466        // Create some "cgroup" directories
1467        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
1468        std::fs::create_dir(tmp.path().join("container-b")).unwrap();
1469
1470        let mgr = CellManager::new_with_path(
1471            tmp.path().to_path_buf(),
1472            256,
1473            cpumask_for_range(16),
1474            HashSet::new(),
1475        )
1476        .unwrap();
1477
1478        assert_eq!(mgr.cell_count(), 2);
1479
1480        // Verify cells were assigned IDs 1 and 2
1481        let cell_ids = mgr.get_cell_ids();
1482        assert!(cell_ids.contains(&1));
1483        assert!(cell_ids.contains(&2));
1484    }
1485
1486    #[test]
1487    fn test_reconcile_detects_new_directories() {
1488        let tmp = TempDir::new().unwrap();
1489
1490        // Start with one directory
1491        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
1492        let mut mgr = CellManager::new_with_path(
1493            tmp.path().to_path_buf(),
1494            256,
1495            cpumask_for_range(16),
1496            HashSet::new(),
1497        )
1498        .unwrap();
1499        assert_eq!(mgr.cell_count(), 1);
1500
1501        // Add another directory
1502        std::fs::create_dir(tmp.path().join("container-b")).unwrap();
1503
1504        // Reconcile should detect it
1505        let (new_cells, destroyed_cells) = mgr.reconcile_cells(&NO_SUBS).unwrap();
1506        assert_eq!(new_cells.len(), 1);
1507        assert_eq!(destroyed_cells.len(), 0);
1508        assert_eq!(mgr.cell_count(), 2);
1509    }
1510
1511    #[test]
1512    fn test_reconcile_detects_removed_directories() {
1513        let tmp = TempDir::new().unwrap();
1514
1515        // Start with two directories
1516        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
1517        std::fs::create_dir(tmp.path().join("container-b")).unwrap();
1518        let mut mgr = CellManager::new_with_path(
1519            tmp.path().to_path_buf(),
1520            256,
1521            cpumask_for_range(16),
1522            HashSet::new(),
1523        )
1524        .unwrap();
1525        assert_eq!(mgr.cell_count(), 2);
1526
1527        // Remove one directory
1528        std::fs::remove_dir(tmp.path().join("container-b")).unwrap();
1529
1530        // Reconcile should detect it
1531        let (new_cells, destroyed_cells) = mgr.reconcile_cells(&NO_SUBS).unwrap();
1532        assert_eq!(new_cells.len(), 0);
1533        assert_eq!(destroyed_cells.len(), 1);
1534        assert_eq!(mgr.cell_count(), 1);
1535    }
1536
1537    #[test]
1538    fn test_reconcile_replaces_reused_path_with_new_inode() {
1539        let tmp = TempDir::new().unwrap();
1540        let parked = TempDir::new().unwrap();
1541
1542        let original_path = tmp.path().join("container-a");
1543        std::fs::create_dir(&original_path).unwrap();
1544
1545        let mut mgr = CellManager::new_with_path(
1546            tmp.path().to_path_buf(),
1547            256,
1548            cpumask_for_range(16),
1549            HashSet::new(),
1550        )
1551        .unwrap();
1552        assert_eq!(mgr.cell_count(), 1);
1553
1554        let old_info = mgr.find_cell_by_name("container-a").unwrap();
1555        let old_cell_id = old_info.cell_id;
1556        let old_cgid = old_info.cgid.unwrap();
1557
1558        // Move the original cgroup out of the watched directory so the inode
1559        // stays alive while a new cgroup is created at the same path.
1560        std::fs::rename(&original_path, parked.path().join("container-a-old")).unwrap();
1561        std::fs::create_dir(&original_path).unwrap();
1562
1563        let (new_cells, destroyed_cells) = mgr.reconcile_cells(&NO_SUBS).unwrap();
1564
1565        assert_eq!(new_cells.len(), 1);
1566        assert_eq!(destroyed_cells, vec![old_cell_id]);
1567        assert_eq!(mgr.cell_count(), 1);
1568
1569        // with no sub-scheduler attached, the destroyed ID is reused at once
1570        let new_info = mgr.find_cell_by_name("container-a").unwrap();
1571        assert_eq!(new_info.cell_id, old_cell_id);
1572        assert_ne!(new_info.cgid.unwrap(), old_cgid);
1573    }
1574
1575    #[test]
1576    fn test_cell_id_reuse_after_destruction() {
1577        let tmp = TempDir::new().unwrap();
1578
1579        // Create directories
1580        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
1581        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
1582        std::fs::create_dir(tmp.path().join("cell3")).unwrap();
1583
1584        let mut mgr = CellManager::new_with_path(
1585            tmp.path().to_path_buf(),
1586            256,
1587            cpumask_for_range(16),
1588            HashSet::new(),
1589        )
1590        .unwrap();
1591
1592        // Find cell2's ID
1593        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
1594        let cell2_id = cell2_info.cell_id;
1595
1596        // Remove cell2
1597        std::fs::remove_dir(tmp.path().join("cell2")).unwrap();
1598        mgr.reconcile_cells(&NO_SUBS).unwrap();
1599
1600        // Add a new directory - should reuse cell2's ID
1601        std::fs::create_dir(tmp.path().join("cell4")).unwrap();
1602        mgr.reconcile_cells(&NO_SUBS).unwrap();
1603
1604        let cell4_info = mgr.find_cell_by_name("cell4").unwrap();
1605        assert_eq!(cell4_info.cell_id, cell2_id);
1606    }
1607
1608    #[test]
1609    fn test_held_cell_id_waits_for_detach() {
1610        let tmp = TempDir::new().unwrap();
1611
1612        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
1613        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
1614
1615        let mut mgr = CellManager::new_with_path(
1616            tmp.path().to_path_buf(),
1617            256,
1618            cpumask_for_range(16),
1619            HashSet::new(),
1620        )
1621        .unwrap();
1622        let cell2_id = mgr.find_cell_by_name("cell2").unwrap().cell_id;
1623
1624        // cell2 goes away while a sub-scheduler holds its cell
1625        let mut held = NO_SUBS;
1626        held[cell2_id as usize] = 42;
1627        std::fs::remove_dir(tmp.path().join("cell2")).unwrap();
1628        mgr.reconcile_cells(&held).unwrap();
1629
1630        // the held ID is not reused
1631        std::fs::create_dir(tmp.path().join("cell3")).unwrap();
1632        mgr.reconcile_cells(&held).unwrap();
1633        assert_ne!(mgr.find_cell_by_name("cell3").unwrap().cell_id, cell2_id);
1634
1635        // nothing is freed while the record stays, one cell is once it clears
1636        assert_eq!(mgr.release_detached_cells(&held), 0);
1637        assert_eq!(mgr.release_detached_cells(&NO_SUBS), 1);
1638        std::fs::create_dir(tmp.path().join("cell4")).unwrap();
1639        mgr.reconcile_cells(&NO_SUBS).unwrap();
1640        assert_eq!(mgr.find_cell_by_name("cell4").unwrap().cell_id, cell2_id);
1641    }
1642
1643    // ==================== compute_cpu_assignments tests ====================
1644
1645    #[test]
1646    fn test_cpu_assignments_no_cells() {
1647        let tmp = TempDir::new().unwrap();
1648        let mgr = CellManager::new_with_path(
1649            tmp.path().to_path_buf(),
1650            256,
1651            cpumask_for_range(16),
1652            HashSet::new(),
1653        )
1654        .unwrap();
1655
1656        let assignments = mgr.compute_cpu_assignments(false).unwrap();
1657
1658        // Only cell 0 with all CPUs
1659        assert_eq!(assignments.len(), 1);
1660        assert_eq!(assignments[0].id, 0);
1661        assert_eq!(assignments[0].primary.weight(), 16);
1662    }
1663
1664    #[test]
1665    fn test_cpu_assignments_proportional() {
1666        let tmp = TempDir::new().unwrap();
1667        std::fs::create_dir(tmp.path().join("container")).unwrap();
1668
1669        let mgr = CellManager::new_with_path(
1670            tmp.path().to_path_buf(),
1671            256,
1672            cpumask_for_range(16),
1673            HashSet::new(),
1674        )
1675        .unwrap();
1676        let assignments = mgr.compute_cpu_assignments(false).unwrap();
1677
1678        // 16 CPUs / 2 cells = 8 each
1679        assert_eq!(assignments.len(), 2);
1680
1681        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
1682        let cell1 = assignments.iter().find(|a| a.id == 1).unwrap();
1683
1684        assert_eq!(cell0.primary.weight(), 8);
1685        assert_eq!(cell1.primary.weight(), 8);
1686    }
1687
1688    #[test]
1689    fn test_cpu_assignments_remainder_to_cell0() {
1690        let tmp = TempDir::new().unwrap();
1691        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
1692        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
1693
1694        let mgr = CellManager::new_with_path(
1695            tmp.path().to_path_buf(),
1696            256,
1697            cpumask_for_range(10),
1698            HashSet::new(),
1699        )
1700        .unwrap();
1701        let assignments = mgr.compute_cpu_assignments(false).unwrap();
1702
1703        // 10 CPUs / 3 cells = 3 each + 1 remainder to cell 0
1704        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
1705        assert_eq!(cell0.primary.weight(), 4); // 3 + 1 remainder
1706    }
1707
1708    #[test]
1709    fn test_cpu_assignments_too_many_cells() {
1710        let tmp = TempDir::new().unwrap();
1711
1712        // Create more cells than CPUs
1713        for i in 1..=5 {
1714            std::fs::create_dir(tmp.path().join(format!("cell{}", i))).unwrap();
1715        }
1716
1717        // Only 4 CPUs but 6 cells (cell 0 + 5 user cells)
1718        let mgr = CellManager::new_with_path(
1719            tmp.path().to_path_buf(),
1720            256,
1721            cpumask_for_range(4),
1722            HashSet::new(),
1723        )
1724        .unwrap();
1725        let result = mgr.compute_cpu_assignments(false);
1726
1727        assert!(result.is_err());
1728        let err_msg = format!("{:#}", result.unwrap_err());
1729        assert!(
1730            err_msg.contains("Not enough CPUs"),
1731            "Expected 'Not enough CPUs' error, got: {}",
1732            err_msg
1733        );
1734    }
1735
1736    #[test]
1737    fn test_cpu_assignments_with_cpusets() {
1738        let tmp = TempDir::new().unwrap();
1739
1740        // Create cgroup directories with cpuset files
1741        let cell1_path = tmp.path().join("cell1");
1742        std::fs::create_dir(&cell1_path).unwrap();
1743        std::fs::write(cell1_path.join("cpuset.cpus"), "0-3\n").unwrap();
1744
1745        let cell2_path = tmp.path().join("cell2");
1746        std::fs::create_dir(&cell2_path).unwrap();
1747        std::fs::write(cell2_path.join("cpuset.cpus"), "8-11\n").unwrap();
1748
1749        let mgr = CellManager::new_with_path(
1750            tmp.path().to_path_buf(),
1751            256,
1752            cpumask_for_range(16),
1753            HashSet::new(),
1754        )
1755        .unwrap();
1756        let assignments = mgr.compute_cpu_assignments(false).unwrap();
1757
1758        // Should have 3 assignments: cell1, cell2, and cell0
1759        assert_eq!(assignments.len(), 3);
1760
1761        // Find each cell's assignment using find_cell_by_name
1762        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
1763        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
1764
1765        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
1766        let cell1 = assignments
1767            .iter()
1768            .find(|a| a.id == cell1_info.cell_id)
1769            .unwrap();
1770        let cell2 = assignments
1771            .iter()
1772            .find(|a| a.id == cell2_info.cell_id)
1773            .unwrap();
1774
1775        // cell1 gets CPUs 0-3
1776        assert_eq!(cell1.primary.weight(), 4);
1777        for cpu in 0..4 {
1778            assert!(cell1.primary.test_cpu(cpu));
1779        }
1780
1781        // cell2 gets CPUs 8-11
1782        assert_eq!(cell2.primary.weight(), 4);
1783        for cpu in 8..12 {
1784            assert!(cell2.primary.test_cpu(cpu));
1785        }
1786
1787        // cell0 gets remaining CPUs: 4-7, 12-15
1788        assert_eq!(cell0.primary.weight(), 8);
1789        for cpu in 4..8 {
1790            assert!(cell0.primary.test_cpu(cpu));
1791        }
1792        for cpu in 12..16 {
1793            assert!(cell0.primary.test_cpu(cpu));
1794        }
1795    }
1796
1797    #[test]
1798    fn test_cpu_assignments_cpusets_cover_all_cpus() {
1799        let tmp = TempDir::new().unwrap();
1800
1801        // Cell 1 and cell 2's cpusets together claim every CPU. Cell 0
1802        // (the catch-all for unclaimed CPUs) would otherwise get nothing,
1803        // which is an error when cell0_min_cpus is 0 (the default).
1804        let cell1_path = tmp.path().join("cell1");
1805        std::fs::create_dir(&cell1_path).unwrap();
1806        std::fs::write(cell1_path.join("cpuset.cpus"), "0-7\n").unwrap();
1807
1808        let cell2_path = tmp.path().join("cell2");
1809        std::fs::create_dir(&cell2_path).unwrap();
1810        std::fs::write(cell2_path.join("cpuset.cpus"), "8-15\n").unwrap();
1811
1812        let mgr = CellManager::new_with_path(
1813            tmp.path().to_path_buf(),
1814            256,
1815            cpumask_for_range(16),
1816            HashSet::new(),
1817        )
1818        .unwrap();
1819        let result = mgr.compute_cpu_assignments(false);
1820
1821        // Should error because cell 0 has no CPUs
1822        assert!(result.is_err());
1823        let err = result.unwrap_err();
1824        let err_msg = format!("{:#}", err);
1825        assert!(
1826            err_msg.contains("Recipient 0 has 0 CPUs assigned, below required minimum 1"),
1827            "Expected recipient minimum error, got: {}",
1828            err_msg
1829        );
1830    }
1831
1832    #[test]
1833    fn test_cpu_assignments_cpusets_cover_all_cpus_with_holdout() {
1834        let tmp = TempDir::new().unwrap();
1835
1836        // Same as test_cpu_assignments_cpusets_cover_all_cpus, but with
1837        // cell0_min_cpus=1: the holdout reserves one CPU for cell 0 before
1838        // assignment. Every CPU is claimed, so the holdout falls back to the
1839        // lowest-numbered claimed CPU (CPU 0, from cell 1).
1840        let cell1_path = tmp.path().join("cell1");
1841        std::fs::create_dir(&cell1_path).unwrap();
1842        std::fs::write(cell1_path.join("cpuset.cpus"), "0-7\n").unwrap();
1843
1844        let cell2_path = tmp.path().join("cell2");
1845        std::fs::create_dir(&cell2_path).unwrap();
1846        std::fs::write(cell2_path.join("cpuset.cpus"), "8-15\n").unwrap();
1847
1848        let mgr = CellManager::new_with_path_opts(
1849            tmp.path().to_path_buf(),
1850            256,
1851            cpumask_for_range(16),
1852            HashSet::new(),
1853            1,
1854            HashMap::new(),
1855        )
1856        .unwrap();
1857        let assignments = mgr
1858            .compute_cpu_assignments(false)
1859            .expect("holdout should populate cell 0");
1860
1861        let cell0 = assignments
1862            .iter()
1863            .find(|a| a.id == 0)
1864            .expect("cell 0 present");
1865        assert_eq!(cell0.primary.weight(), 1);
1866
1867        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
1868        assert_eq!(total, 16);
1869
1870        // Every cell has at least one CPU; cell 1 yielded its lowest CPU to
1871        // the holdout.
1872        for assignment in &assignments {
1873            assert!(
1874                assignment.primary.weight() >= 1,
1875                "cell {} starved after holdout: {:?}",
1876                assignment.id,
1877                assignment.primary
1878            );
1879        }
1880        let donor_weights: Vec<usize> = assignments
1881            .iter()
1882            .filter(|a| a.id != 0)
1883            .map(|a| a.primary.weight())
1884            .collect();
1885        assert!(donor_weights.contains(&7));
1886        assert!(donor_weights.contains(&8));
1887    }
1888
1889    #[test]
1890    fn test_cpu_assignments_holdout_takes_from_largest_cell() {
1891        let tmp = TempDir::new().unwrap();
1892
1893        // 16 CPUs. cell1 claims 8 (0-7), cell2 and cell3 four each. With cpusets
1894        // covering every CPU and cell0_min_cpus=1, the holdout steals from the
1895        // largest cell first -> cell1, its lowest CPU -> CPU 0. (cpu_to_llc is
1896        // set but does not change the pick here: it is only the inner tie-break
1897        // among a donor's CPUs, and cell1's all share one LLC.)
1898        let cell1_path = tmp.path().join("cell1");
1899        std::fs::create_dir(&cell1_path).unwrap();
1900        std::fs::write(cell1_path.join("cpuset.cpus"), "0-7\n").unwrap();
1901
1902        let cell2_path = tmp.path().join("cell2");
1903        std::fs::create_dir(&cell2_path).unwrap();
1904        std::fs::write(cell2_path.join("cpuset.cpus"), "8-11\n").unwrap();
1905
1906        let cell3_path = tmp.path().join("cell3");
1907        std::fs::create_dir(&cell3_path).unwrap();
1908        std::fs::write(cell3_path.join("cpuset.cpus"), "12-15\n").unwrap();
1909
1910        let cpu_to_llc: HashMap<usize, usize> = (0..16usize).map(|cpu| (cpu, cpu / 8)).collect();
1911
1912        let mgr = CellManager::new_with_path_opts(
1913            tmp.path().to_path_buf(),
1914            256,
1915            cpumask_for_range(16),
1916            HashSet::new(),
1917            1,
1918            cpu_to_llc,
1919        )
1920        .unwrap();
1921        let assignments = mgr
1922            .compute_cpu_assignments(false)
1923            .expect("holdout should populate cell 0");
1924
1925        let cell0 = assignments
1926            .iter()
1927            .find(|a| a.id == 0)
1928            .expect("cell 0 present");
1929        assert_eq!(cell0.primary.weight(), 1);
1930        assert!(
1931            cell0.primary.test_cpu(0),
1932            "holdout should take CPU 0 (lowest CPU of the largest cell), got {:?}",
1933            cell0.primary
1934        );
1935        assert!(
1936            mgr.enforced_holdout(),
1937            "stealing a claimed CPU must set enforced_holdout"
1938        );
1939
1940        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
1941        assert_eq!(total, 16);
1942
1943        // The largest cell yields exactly one CPU to the holdout (8 -> 7); the
1944        // two smaller cells are untouched.
1945        let mut child_weights: Vec<usize> = assignments
1946            .iter()
1947            .filter(|a| a.id != 0)
1948            .map(|a| a.primary.weight())
1949            .collect();
1950        child_weights.sort_unstable();
1951        assert_eq!(child_weights, vec![4, 4, 7]);
1952
1953        // No child cell still holds the held-out CPU.
1954        for a in assignments.iter().filter(|a| a.id != 0) {
1955            assert!(
1956                !a.primary.test_cpu(0),
1957                "cell {} still holds held-out CPU 0",
1958                a.id
1959            );
1960        }
1961    }
1962
1963    #[test]
1964    fn test_cpu_assignments_holdout_steals_evenly_across_cells() {
1965        let tmp = TempDir::new().unwrap();
1966
1967        // Three equal cells with disjoint cpusets covering all 24 CPUs, one LLC
1968        // (no LLC steering), cell0_min_cpus=6. Every CPU is claimed, so the
1969        // holdout must steal six. An even steal takes two from each cell, leaving
1970        // each at six — not six from a single cell (which a global lowest-CPU
1971        // prefix would do, draining cell1 to two while cell2/cell3 keep eight).
1972        for (name, range) in [("cell1", "0-7"), ("cell2", "8-15"), ("cell3", "16-23")] {
1973            let p = tmp.path().join(name);
1974            std::fs::create_dir(&p).unwrap();
1975            std::fs::write(p.join("cpuset.cpus"), format!("{range}\n")).unwrap();
1976        }
1977
1978        let mgr = CellManager::new_with_path_opts(
1979            tmp.path().to_path_buf(),
1980            256,
1981            cpumask_for_range(24),
1982            HashSet::new(),
1983            6,
1984            HashMap::new(),
1985        )
1986        .unwrap();
1987        let assignments = mgr
1988            .compute_cpu_assignments(false)
1989            .expect("holdout should populate cell 0");
1990
1991        let cell0 = assignments
1992            .iter()
1993            .find(|a| a.id == 0)
1994            .expect("cell 0 present");
1995        assert_eq!(
1996            cell0.primary.weight(),
1997            6,
1998            "cell 0 holds the six reserved CPUs"
1999        );
2000
2001        // The steal is spread evenly: each of the three equal cells yields
2002        // exactly two, ending at six.
2003        let mut donor_weights: Vec<usize> = assignments
2004            .iter()
2005            .filter(|a| a.id != 0)
2006            .map(|a| a.primary.weight())
2007            .collect();
2008        donor_weights.sort_unstable();
2009        assert_eq!(
2010            donor_weights,
2011            vec![6, 6, 6],
2012            "holdout should steal evenly (two from each cell), got {donor_weights:?}"
2013        );
2014
2015        // Concretely, cell 0 holds two CPUs from each cell's range.
2016        let count_in =
2017            |lo: usize, hi: usize| (lo..hi).filter(|&c| cell0.primary.test_cpu(c)).count();
2018        assert_eq!(count_in(0, 8), 2, "two CPUs taken from cell1");
2019        assert_eq!(count_in(8, 16), 2, "two CPUs taken from cell2");
2020        assert_eq!(count_in(16, 24), 2, "two CPUs taken from cell3");
2021    }
2022
2023    #[test]
2024    fn test_cpu_assignments_holdout_never_starves_a_child() {
2025        let tmp = TempDir::new().unwrap();
2026
2027        // 4 CPUs fully claimed by two 2-CPU children, cell0_min_cpus=3 -- more
2028        // than the 2 CPUs that can be reserved while each child keeps one. Without
2029        // a per-donor floor the steal drains a child to zero and re-triggers the
2030        // Phase 5 "no CPUs assigned" bail this holdout exists to prevent. The floor
2031        // caps the reservation so every child keeps >=1 CPU and cell 0 receives as
2032        // many as it safely can (2, not the requested 3).
2033        let cell1_path = tmp.path().join("cell1");
2034        std::fs::create_dir(&cell1_path).unwrap();
2035        std::fs::write(cell1_path.join("cpuset.cpus"), "0-1\n").unwrap();
2036
2037        let cell2_path = tmp.path().join("cell2");
2038        std::fs::create_dir(&cell2_path).unwrap();
2039        std::fs::write(cell2_path.join("cpuset.cpus"), "2-3\n").unwrap();
2040
2041        let mgr = CellManager::new_with_path_opts(
2042            tmp.path().to_path_buf(),
2043            256,
2044            cpumask_for_range(4),
2045            HashSet::new(),
2046            3,
2047            HashMap::new(),
2048        )
2049        .unwrap();
2050        let assignments = mgr
2051            .compute_cpu_assignments(false)
2052            .expect("holdout must not starve a child cell");
2053
2054        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
2055        assert_eq!(total, 4);
2056
2057        // Every child keeps at least one CPU -- no Phase 5 bail.
2058        for a in assignments.iter().filter(|a| a.id != 0) {
2059            assert!(
2060                a.primary.weight() >= 1,
2061                "cell {} starved by the holdout: {:?}",
2062                a.id,
2063                a.primary
2064            );
2065        }
2066
2067        // Cell 0 received the floor-capped count (2), not the requested 3.
2068        let cell0 = assignments
2069            .iter()
2070            .find(|a| a.id == 0)
2071            .expect("cell 0 present");
2072        assert_eq!(
2073            cell0.primary.weight(),
2074            2,
2075            "holdout should cap at total - num_children, got {:?}",
2076            cell0.primary
2077        );
2078    }
2079
2080    #[test]
2081    fn test_cpu_assignments_holdout_overlapping_cpusets_no_starvation() {
2082        let tmp = TempDir::new().unwrap();
2083
2084        // Overlapping cpusets: cell1 claims only CPU 0; cell2 claims CPUs 0-1, so
2085        // CPU 0 is contested. 5 CPUs total (2,3,4 unclaimed). With cell0_min_cpus=4
2086        // the holdout must dip past the unclaimed CPUs into a claimed one. It must
2087        // NOT take cell2's exclusive CPU 1: that would leave cell2 holding only the
2088        // contested CPU 0, which Phase 3 awards to cell1, zeroing cell2 -> the
2089        // Phase-5 bail. The exclusive-only floor refuses to steal a cell's last
2090        // exclusive CPU, so cell 0 gets only the 3 unclaimed CPUs (capped below 4)
2091        // and every cell keeps >=1.
2092        let cell1_path = tmp.path().join("cell1");
2093        std::fs::create_dir(&cell1_path).unwrap();
2094        std::fs::write(cell1_path.join("cpuset.cpus"), "0\n").unwrap();
2095
2096        let cell2_path = tmp.path().join("cell2");
2097        std::fs::create_dir(&cell2_path).unwrap();
2098        std::fs::write(cell2_path.join("cpuset.cpus"), "0-1\n").unwrap();
2099
2100        let mgr = CellManager::new_with_path_opts(
2101            tmp.path().to_path_buf(),
2102            256,
2103            cpumask_for_range(5),
2104            HashSet::new(),
2105            4,
2106            HashMap::new(),
2107        )
2108        .unwrap();
2109        let assignments = mgr
2110            .compute_cpu_assignments(false)
2111            .expect("holdout must not starve a cell that shares a contested CPU");
2112        // The steal loop runs (3 unclaimed < 4 requested) but no claimed CPU is
2113        // safely reservable, so it breaks without taking one -- enforced_holdout
2114        // tracks an actual steal, not loop entry.
2115        assert!(
2116            !mgr.enforced_holdout(),
2117            "entering the steal loop without taking a claimed CPU must not set \
2118             enforced_holdout"
2119        );
2120
2121        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
2122        assert_eq!(total, 5);
2123
2124        // Every child keeps at least one CPU -- no Phase 5 bail.
2125        for a in assignments.iter().filter(|a| a.id != 0) {
2126            assert!(
2127                a.primary.weight() >= 1,
2128                "cell {} starved by the holdout: {:?}",
2129                a.id,
2130                a.primary
2131            );
2132        }
2133
2134        // The holdout could not safely steal a claimed CPU, so cell 0 holds only
2135        // the 3 unclaimed CPUs -- capped below the requested 4.
2136        let cell0 = assignments
2137            .iter()
2138            .find(|a| a.id == 0)
2139            .expect("cell 0 present");
2140        assert_eq!(
2141            cell0.primary.weight(),
2142            3,
2143            "holdout should cap at the 3 unclaimed CPUs, got {:?}",
2144            cell0.primary
2145        );
2146    }
2147
2148    #[test]
2149    fn test_cpu_assignments_single_cpuset() {
2150        let tmp = TempDir::new().unwrap();
2151
2152        // Only one cell with a cpuset
2153        let cell1_path = tmp.path().join("cell1");
2154        std::fs::create_dir(&cell1_path).unwrap();
2155        std::fs::write(cell1_path.join("cpuset.cpus"), "0,2,4,6\n").unwrap();
2156
2157        let mgr = CellManager::new_with_path(
2158            tmp.path().to_path_buf(),
2159            256,
2160            cpumask_for_range(8),
2161            HashSet::new(),
2162        )
2163        .unwrap();
2164        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2165
2166        assert_eq!(assignments.len(), 2);
2167
2168        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2169        let cell1 = assignments.iter().find(|a| a.id != 0).unwrap();
2170
2171        // cell1 gets even CPUs
2172        assert_eq!(cell1.primary.weight(), 4);
2173        for cpu in [0, 2, 4, 6] {
2174            assert!(cell1.primary.test_cpu(cpu));
2175        }
2176
2177        // cell0 gets odd CPUs
2178        assert_eq!(cell0.primary.weight(), 4);
2179        for cpu in [1, 3, 5, 7] {
2180            assert!(cell0.primary.test_cpu(cpu));
2181        }
2182    }
2183
2184    #[test]
2185    fn test_cpuset_parsing_from_file() {
2186        let tmp = TempDir::new().unwrap();
2187
2188        // Test various cpuset formats
2189        let cell_path = tmp.path().join("cell1");
2190        std::fs::create_dir(&cell_path).unwrap();
2191        std::fs::write(cell_path.join("cpuset.cpus"), "0-3,8-11,16\n").unwrap();
2192
2193        let mgr = CellManager::new_with_path(
2194            tmp.path().to_path_buf(),
2195            256,
2196            cpumask_for_range(32),
2197            HashSet::new(),
2198        )
2199        .unwrap();
2200
2201        // Find the cell and verify its cpuset was parsed correctly
2202        let cell_info = mgr.find_cell_by_name("cell1").unwrap();
2203        let cpuset = cell_info.cpuset.as_ref().unwrap();
2204
2205        assert_eq!(cpuset.weight(), 9); // 4 + 4 + 1
2206        for cpu in 0..4 {
2207            assert!(cpuset.test_cpu(cpu));
2208        }
2209        for cpu in 8..12 {
2210            assert!(cpuset.test_cpu(cpu));
2211        }
2212        assert!(cpuset.test_cpu(16));
2213    }
2214
2215    #[test]
2216    fn test_cpu_assignments_mixed_cpuset_and_no_cpuset() {
2217        let tmp = TempDir::new().unwrap();
2218
2219        // cell1 has a cpuset
2220        let cell1_path = tmp.path().join("cell1");
2221        std::fs::create_dir(&cell1_path).unwrap();
2222        std::fs::write(cell1_path.join("cpuset.cpus"), "0-3\n").unwrap();
2223
2224        // cell2 has NO cpuset (no cpuset.cpus file)
2225        let cell2_path = tmp.path().join("cell2");
2226        std::fs::create_dir(&cell2_path).unwrap();
2227
2228        let mgr = CellManager::new_with_path(
2229            tmp.path().to_path_buf(),
2230            256,
2231            cpumask_for_range(16),
2232            HashSet::new(),
2233        )
2234        .unwrap();
2235
2236        // Verify cell1 has cpuset, cell2 doesn't
2237        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
2238        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
2239        assert!(cell1_info.cpuset.is_some());
2240        assert!(cell2_info.cpuset.is_none());
2241
2242        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2243
2244        // cell1 (pinned) gets its cpuset: 0-3 (4 CPUs)
2245        // Targets (equal weight, 3 cells, 16 CPUs): cell0=6, cell1=5, cell2=5
2246        // cell1 has 4 exclusive. Deficit = 1 (but can't participate in unclaimed)
2247        // 12 unclaimed CPUs split by deficit: cell0 deficit=6, cell2 deficit=5
2248        // Result: cell0=7, cell1=4, cell2=5
2249        assert_eq!(assignments.len(), 3);
2250
2251        // cell1 gets its cpuset (0-3)
2252        let cell1_assignment = assignments
2253            .iter()
2254            .find(|a| a.id == cell1_info.cell_id)
2255            .unwrap();
2256        assert_eq!(cell1_assignment.primary.weight(), 4);
2257
2258        // cell0 gets 7 CPUs (deficit-proportional share of unclaimed)
2259        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2260        assert_eq!(cell0.primary.weight(), 7);
2261
2262        // cell2 (unpinned) gets 5 CPUs
2263        let cell2_assignment = assignments
2264            .iter()
2265            .find(|a| a.id == cell2_info.cell_id)
2266            .unwrap();
2267        assert_eq!(cell2_assignment.primary.weight(), 5);
2268    }
2269
2270    // ==================== Overlapping cpuset tests ====================
2271
2272    #[test]
2273    fn test_cpu_assignments_partial_overlap() {
2274        let tmp = TempDir::new().unwrap();
2275
2276        // Cell A (cpuset 0-7) and Cell B (cpuset 4-11) - overlap on 4-7
2277        let cell_a_path = tmp.path().join("cell_a");
2278        std::fs::create_dir(&cell_a_path).unwrap();
2279        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-7\n").unwrap();
2280
2281        let cell_b_path = tmp.path().join("cell_b");
2282        std::fs::create_dir(&cell_b_path).unwrap();
2283        std::fs::write(cell_b_path.join("cpuset.cpus"), "4-11\n").unwrap();
2284
2285        let mgr = CellManager::new_with_path(
2286            tmp.path().to_path_buf(),
2287            256,
2288            cpumask_for_range(16),
2289            HashSet::new(),
2290        )
2291        .unwrap();
2292        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2293
2294        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
2295        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
2296
2297        let cell_a = assignments
2298            .iter()
2299            .find(|a| a.id == cell_a_info.cell_id)
2300            .unwrap();
2301        let cell_b = assignments
2302            .iter()
2303            .find(|a| a.id == cell_b_info.cell_id)
2304            .unwrap();
2305        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2306
2307        // Cell A gets exclusive 0-3 (4 CPUs) + half of contested 4-7 (2 CPUs) = 6 CPUs
2308        // Cell B gets half of contested 4-7 (2 CPUs) + exclusive 8-11 (4 CPUs) = 6 CPUs
2309        // Cell 0 gets unclaimed 12-15 (4 CPUs)
2310        assert_eq!(cell_a.primary.weight(), 6);
2311        assert_eq!(cell_b.primary.weight(), 6);
2312        assert_eq!(cell0.primary.weight(), 4);
2313
2314        // Verify exclusive CPUs went to correct cells
2315        for cpu in 0..4 {
2316            assert!(
2317                cell_a.primary.test_cpu(cpu),
2318                "CPU {} should be in cell_a",
2319                cpu
2320            );
2321        }
2322        for cpu in 8..12 {
2323            assert!(
2324                cell_b.primary.test_cpu(cpu),
2325                "CPU {} should be in cell_b",
2326                cpu
2327            );
2328        }
2329        for cpu in 12..16 {
2330            assert!(
2331                cell0.primary.test_cpu(cpu),
2332                "CPU {} should be in cell0",
2333                cpu
2334            );
2335        }
2336
2337        // Verify contested CPUs 4-7 are split - each cell gets exactly 2
2338        let cell_a_contested: Vec<_> = (4..8).filter(|&cpu| cell_a.primary.test_cpu(cpu)).collect();
2339        let cell_b_contested: Vec<_> = (4..8).filter(|&cpu| cell_b.primary.test_cpu(cpu)).collect();
2340        assert_eq!(cell_a_contested.len(), 2);
2341        assert_eq!(cell_b_contested.len(), 2);
2342
2343        // No CPU should be assigned to multiple cells
2344        for cpu in 0..16 {
2345            let mut count = 0;
2346            if cell_a.primary.test_cpu(cpu) {
2347                count += 1;
2348            }
2349            if cell_b.primary.test_cpu(cpu) {
2350                count += 1;
2351            }
2352            if cell0.primary.test_cpu(cpu) {
2353                count += 1;
2354            }
2355            assert!(count <= 1, "CPU {} is assigned to {} cells", cpu, count);
2356        }
2357    }
2358
2359    #[test]
2360    fn test_cpu_assignments_three_way_overlap() {
2361        let tmp = TempDir::new().unwrap();
2362
2363        // All three cells claim CPUs 0-5
2364        let cell_a_path = tmp.path().join("cell_a");
2365        std::fs::create_dir(&cell_a_path).unwrap();
2366        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-5\n").unwrap();
2367
2368        let cell_b_path = tmp.path().join("cell_b");
2369        std::fs::create_dir(&cell_b_path).unwrap();
2370        std::fs::write(cell_b_path.join("cpuset.cpus"), "0-5\n").unwrap();
2371
2372        let cell_c_path = tmp.path().join("cell_c");
2373        std::fs::create_dir(&cell_c_path).unwrap();
2374        std::fs::write(cell_c_path.join("cpuset.cpus"), "0-5\n").unwrap();
2375
2376        let mgr = CellManager::new_with_path(
2377            tmp.path().to_path_buf(),
2378            256,
2379            cpumask_for_range(12),
2380            HashSet::new(),
2381        )
2382        .unwrap();
2383        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2384
2385        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
2386        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
2387        let cell_c_info = mgr.find_cell_by_name("cell_c").unwrap();
2388
2389        let cell_a = assignments
2390            .iter()
2391            .find(|a| a.id == cell_a_info.cell_id)
2392            .unwrap();
2393        let cell_b = assignments
2394            .iter()
2395            .find(|a| a.id == cell_b_info.cell_id)
2396            .unwrap();
2397        let cell_c = assignments
2398            .iter()
2399            .find(|a| a.id == cell_c_info.cell_id)
2400            .unwrap();
2401        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2402
2403        // 6 contested CPUs / 3 cells = 2 each
2404        assert_eq!(cell_a.primary.weight(), 2);
2405        assert_eq!(cell_b.primary.weight(), 2);
2406        assert_eq!(cell_c.primary.weight(), 2);
2407
2408        // Cell 0 gets unclaimed 6-11 (6 CPUs)
2409        assert_eq!(cell0.primary.weight(), 6);
2410        for cpu in 6..12 {
2411            assert!(cell0.primary.test_cpu(cpu));
2412        }
2413
2414        // Verify total contested CPUs assigned = 6 (no duplicates)
2415        let total_contested: usize = (0..6)
2416            .filter(|&cpu| {
2417                cell_a.primary.test_cpu(cpu)
2418                    || cell_b.primary.test_cpu(cpu)
2419                    || cell_c.primary.test_cpu(cpu)
2420            })
2421            .count();
2422        assert_eq!(total_contested, 6);
2423    }
2424
2425    #[test]
2426    fn test_cpu_assignments_odd_contested_count() {
2427        let tmp = TempDir::new().unwrap();
2428
2429        // Two cells contesting 3 CPUs (odd number - can't split evenly)
2430        let cell_a_path = tmp.path().join("cell_a");
2431        std::fs::create_dir(&cell_a_path).unwrap();
2432        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-2\n").unwrap();
2433
2434        let cell_b_path = tmp.path().join("cell_b");
2435        std::fs::create_dir(&cell_b_path).unwrap();
2436        std::fs::write(cell_b_path.join("cpuset.cpus"), "0-2\n").unwrap();
2437
2438        let mgr = CellManager::new_with_path(
2439            tmp.path().to_path_buf(),
2440            256,
2441            cpumask_for_range(8),
2442            HashSet::new(),
2443        )
2444        .unwrap();
2445        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2446
2447        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
2448        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
2449
2450        let cell_a = assignments
2451            .iter()
2452            .find(|a| a.id == cell_a_info.cell_id)
2453            .unwrap();
2454        let cell_b = assignments
2455            .iter()
2456            .find(|a| a.id == cell_b_info.cell_id)
2457            .unwrap();
2458
2459        // 3 CPUs / 2 cells = 1 each + 1 remainder
2460        // One cell gets 2, the other gets 1
2461        let total = cell_a.primary.weight() + cell_b.primary.weight();
2462        assert_eq!(total, 3);
2463        assert!(cell_a.primary.weight() >= 1 && cell_a.primary.weight() <= 2);
2464        assert!(cell_b.primary.weight() >= 1 && cell_b.primary.weight() <= 2);
2465
2466        // No overlap in assignments
2467        for cpu in 0..3 {
2468            let a_has = cell_a.primary.test_cpu(cpu);
2469            let b_has = cell_b.primary.test_cpu(cpu);
2470            assert!(!(a_has && b_has), "CPU {} assigned to both cells", cpu);
2471        }
2472    }
2473
2474    #[test]
2475    fn test_cpu_assignments_complete_overlap() {
2476        let tmp = TempDir::new().unwrap();
2477
2478        // Two cells with identical cpusets
2479        let cell_a_path = tmp.path().join("cell_a");
2480        std::fs::create_dir(&cell_a_path).unwrap();
2481        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-7\n").unwrap();
2482
2483        let cell_b_path = tmp.path().join("cell_b");
2484        std::fs::create_dir(&cell_b_path).unwrap();
2485        std::fs::write(cell_b_path.join("cpuset.cpus"), "0-7\n").unwrap();
2486
2487        let mgr = CellManager::new_with_path(
2488            tmp.path().to_path_buf(),
2489            256,
2490            cpumask_for_range(16),
2491            HashSet::new(),
2492        )
2493        .unwrap();
2494        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2495
2496        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
2497        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
2498
2499        let cell_a = assignments
2500            .iter()
2501            .find(|a| a.id == cell_a_info.cell_id)
2502            .unwrap();
2503        let cell_b = assignments
2504            .iter()
2505            .find(|a| a.id == cell_b_info.cell_id)
2506            .unwrap();
2507        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2508
2509        // 8 contested CPUs / 2 cells = 4 each
2510        assert_eq!(cell_a.primary.weight(), 4);
2511        assert_eq!(cell_b.primary.weight(), 4);
2512
2513        // Cell 0 gets unclaimed 8-15 (8 CPUs)
2514        assert_eq!(cell0.primary.weight(), 8);
2515        for cpu in 8..16 {
2516            assert!(cell0.primary.test_cpu(cpu));
2517        }
2518
2519        // Verify no overlap between cell_a and cell_b
2520        for cpu in 0..8 {
2521            let a_has = cell_a.primary.test_cpu(cpu);
2522            let b_has = cell_b.primary.test_cpu(cpu);
2523            assert!(!(a_has && b_has), "CPU {} assigned to both cells", cpu);
2524        }
2525    }
2526
2527    #[test]
2528    fn test_cpu_assignments_no_overlap() {
2529        // This verifies existing non-overlapping behavior still works
2530        let tmp = TempDir::new().unwrap();
2531
2532        let cell_a_path = tmp.path().join("cell_a");
2533        std::fs::create_dir(&cell_a_path).unwrap();
2534        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-3\n").unwrap();
2535
2536        let cell_b_path = tmp.path().join("cell_b");
2537        std::fs::create_dir(&cell_b_path).unwrap();
2538        std::fs::write(cell_b_path.join("cpuset.cpus"), "4-7\n").unwrap();
2539
2540        let mgr = CellManager::new_with_path(
2541            tmp.path().to_path_buf(),
2542            256,
2543            cpumask_for_range(16),
2544            HashSet::new(),
2545        )
2546        .unwrap();
2547        let assignments = mgr.compute_cpu_assignments(false).unwrap();
2548
2549        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
2550        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
2551
2552        let cell_a = assignments
2553            .iter()
2554            .find(|a| a.id == cell_a_info.cell_id)
2555            .unwrap();
2556        let cell_b = assignments
2557            .iter()
2558            .find(|a| a.id == cell_b_info.cell_id)
2559            .unwrap();
2560        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2561
2562        // No overlap - each cell gets its exact cpuset
2563        assert_eq!(cell_a.primary.weight(), 4);
2564        for cpu in 0..4 {
2565            assert!(cell_a.primary.test_cpu(cpu));
2566        }
2567
2568        assert_eq!(cell_b.primary.weight(), 4);
2569        for cpu in 4..8 {
2570            assert!(cell_b.primary.test_cpu(cpu));
2571        }
2572
2573        // Cell 0 gets remaining 8-15
2574        assert_eq!(cell0.primary.weight(), 8);
2575        for cpu in 8..16 {
2576            assert!(cell0.primary.test_cpu(cpu));
2577        }
2578    }
2579
2580    // ==================== format_cell_config tests ====================
2581
2582    #[test]
2583    fn test_format_cell_config_only_cell0() {
2584        let tmp = TempDir::new().unwrap();
2585        let mgr = CellManager::new_with_path(
2586            tmp.path().to_path_buf(),
2587            256,
2588            cpumask_for_range(8),
2589            HashSet::new(),
2590        )
2591        .unwrap();
2592
2593        let mut mask = Cpumask::new();
2594        for cpu in 0..8 {
2595            mask.set_cpu(cpu).unwrap();
2596        }
2597
2598        let assignments = vec![CpuAssignment {
2599            id: 0,
2600            primary: mask,
2601            borrowable: None,
2602        }];
2603        let result = mgr.format_cell_config(&assignments);
2604
2605        assert_eq!(result, "[0: 0-7]");
2606    }
2607
2608    #[test]
2609    fn test_format_cell_config_with_cells() {
2610        let tmp = TempDir::new().unwrap();
2611        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
2612
2613        let mgr = CellManager::new_with_path(
2614            tmp.path().to_path_buf(),
2615            256,
2616            cpumask_for_range(16),
2617            HashSet::new(),
2618        )
2619        .unwrap();
2620
2621        let mut mask0 = Cpumask::new();
2622        for cpu in 0..8 {
2623            mask0.set_cpu(cpu).unwrap();
2624        }
2625
2626        let mut mask1 = Cpumask::new();
2627        for cpu in 8..16 {
2628            mask1.set_cpu(cpu).unwrap();
2629        }
2630
2631        let assignments = vec![
2632            CpuAssignment {
2633                id: 0,
2634                primary: mask0,
2635                borrowable: None,
2636            },
2637            CpuAssignment {
2638                id: 1,
2639                primary: mask1,
2640                borrowable: None,
2641            },
2642        ];
2643        let result = mgr.format_cell_config(&assignments);
2644
2645        assert_eq!(result, "[0: 0-7] [1(container-a): 8-15]");
2646    }
2647
2648    // ==================== Cell ID exhaustion tests ====================
2649
2650    #[test]
2651    fn test_cell_id_exhaustion() {
2652        let tmp = TempDir::new().unwrap();
2653
2654        // Create a manager with max_cells=3 (can allocate cell IDs 1 and 2)
2655        // Cell 0 is reserved, so we can create 2 cells before exhaustion
2656        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
2657        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
2658
2659        let mut mgr = CellManager::new_with_path(
2660            tmp.path().to_path_buf(),
2661            3,
2662            cpumask_for_range(16),
2663            HashSet::new(),
2664        )
2665        .unwrap();
2666        assert_eq!(mgr.cell_count(), 2); // cell1 + cell2
2667
2668        // Adding a third cell should fail due to exhaustion
2669        std::fs::create_dir(tmp.path().join("cell3")).unwrap();
2670        let result = mgr.reconcile_cells(&NO_SUBS);
2671
2672        assert!(result.is_err());
2673        let err = result.unwrap_err();
2674        let err_chain = format!("{:#}", err);
2675        assert!(
2676            err_chain.contains("Cell ID space exhausted"),
2677            "Expected exhaustion error, got: {}",
2678            err_chain
2679        );
2680    }
2681
2682    #[test]
2683    fn test_cell_id_reuse_prevents_exhaustion() {
2684        let tmp = TempDir::new().unwrap();
2685
2686        // Create a manager with max_cells=3
2687        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
2688        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
2689
2690        let mut mgr = CellManager::new_with_path(
2691            tmp.path().to_path_buf(),
2692            3,
2693            cpumask_for_range(16),
2694            HashSet::new(),
2695        )
2696        .unwrap();
2697        assert_eq!(mgr.cell_count(), 2);
2698
2699        // Remove cell1 to free up its ID
2700        std::fs::remove_dir(tmp.path().join("cell1")).unwrap();
2701        mgr.reconcile_cells(&NO_SUBS).unwrap();
2702        assert_eq!(mgr.cell_count(), 1);
2703
2704        // Now adding cell3 should succeed by reusing the freed ID
2705        std::fs::create_dir(tmp.path().join("cell3")).unwrap();
2706        let result = mgr.reconcile_cells(&NO_SUBS);
2707        assert!(result.is_ok());
2708        assert_eq!(mgr.cell_count(), 2);
2709    }
2710
2711    // ==================== Exclusion tests ====================
2712
2713    #[test]
2714    fn test_scan_excludes_named_cgroups() {
2715        let tmp = TempDir::new().unwrap();
2716
2717        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
2718        std::fs::create_dir(tmp.path().join("systemd-workaround.service")).unwrap();
2719        std::fs::create_dir(tmp.path().join("container-b")).unwrap();
2720
2721        let exclude = HashSet::from(["systemd-workaround.service".to_string()]);
2722        let mgr = CellManager::new_with_path(
2723            tmp.path().to_path_buf(),
2724            256,
2725            cpumask_for_range(16),
2726            exclude,
2727        )
2728        .unwrap();
2729
2730        // Only 2 cells — the excluded cgroup is not a cell
2731        assert_eq!(mgr.cell_count(), 2);
2732        assert!(mgr.find_cell_by_name("container-a").is_some());
2733        assert!(mgr.find_cell_by_name("container-b").is_some());
2734        assert!(
2735            mgr.find_cell_by_name("systemd-workaround.service")
2736                .is_none()
2737        );
2738    }
2739
2740    #[test]
2741    fn test_reconcile_excludes_named_cgroups() {
2742        let tmp = TempDir::new().unwrap();
2743
2744        std::fs::create_dir(tmp.path().join("container-a")).unwrap();
2745
2746        let exclude = HashSet::from(["ignored-service".to_string()]);
2747        let mut mgr = CellManager::new_with_path(
2748            tmp.path().to_path_buf(),
2749            256,
2750            cpumask_for_range(16),
2751            exclude,
2752        )
2753        .unwrap();
2754        assert_eq!(mgr.cell_count(), 1);
2755
2756        // Add an excluded cgroup — should not become a cell
2757        std::fs::create_dir(tmp.path().join("ignored-service")).unwrap();
2758        let (new_cells, destroyed_cells) = mgr.reconcile_cells(&NO_SUBS).unwrap();
2759        assert_eq!(new_cells.len(), 0);
2760        assert_eq!(destroyed_cells.len(), 0);
2761        assert_eq!(mgr.cell_count(), 1);
2762
2763        // Add a non-excluded cgroup — should become a cell
2764        std::fs::create_dir(tmp.path().join("container-b")).unwrap();
2765        let (new_cells, destroyed_cells) = mgr.reconcile_cells(&NO_SUBS).unwrap();
2766        assert_eq!(new_cells.len(), 1);
2767        assert_eq!(destroyed_cells.len(), 0);
2768        assert_eq!(mgr.cell_count(), 2);
2769    }
2770
2771    // ==================== Borrowable cpumask tests ====================
2772
2773    #[test]
2774    fn test_borrowable_cpumasks_basic() {
2775        let tmp = TempDir::new().unwrap();
2776
2777        // Create 2 cells without cpusets
2778        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
2779        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
2780
2781        let mgr = CellManager::new_with_path(
2782            tmp.path().to_path_buf(),
2783            256,
2784            cpumask_for_range(16),
2785            HashSet::new(),
2786        )
2787        .unwrap();
2788        let assignments = mgr.compute_cpu_assignments(true).unwrap();
2789
2790        // Each cell should be able to borrow CPUs from other cells
2791        for assignment in &assignments {
2792            let borrow_mask = assignment.borrowable.as_ref().unwrap();
2793            // borrowable should have no overlap with primary
2794            let overlap = borrow_mask.and(&assignment.primary);
2795            assert_eq!(
2796                overlap.weight(),
2797                0,
2798                "Cell {} borrowable overlaps with primary",
2799                assignment.id
2800            );
2801            // borrowable + primary should cover all CPUs
2802            let union = borrow_mask.or(&assignment.primary);
2803            assert_eq!(
2804                union.weight(),
2805                16,
2806                "Cell {} union doesn't cover all CPUs",
2807                assignment.id
2808            );
2809        }
2810    }
2811
2812    #[test]
2813    fn test_borrowable_cpumasks_no_overlap() {
2814        let tmp = TempDir::new().unwrap();
2815
2816        let cell1_path = tmp.path().join("cell1");
2817        std::fs::create_dir(&cell1_path).unwrap();
2818        std::fs::write(cell1_path.join("cpuset.cpus"), "0-3\n").unwrap();
2819
2820        let cell2_path = tmp.path().join("cell2");
2821        std::fs::create_dir(&cell2_path).unwrap();
2822        std::fs::write(cell2_path.join("cpuset.cpus"), "8-11\n").unwrap();
2823
2824        let mgr = CellManager::new_with_path(
2825            tmp.path().to_path_buf(),
2826            256,
2827            cpumask_for_range(16),
2828            HashSet::new(),
2829        )
2830        .unwrap();
2831        let assignments = mgr.compute_cpu_assignments(true).unwrap();
2832
2833        // Verify no cell's borrowable mask overlaps with its own primary
2834        for assignment in &assignments {
2835            let borrow_mask = assignment.borrowable.as_ref().unwrap();
2836            let overlap = borrow_mask.and(&assignment.primary);
2837            assert_eq!(
2838                overlap.weight(),
2839                0,
2840                "Cell {} borrowable overlaps with primary",
2841                assignment.id
2842            );
2843        }
2844    }
2845
2846    #[test]
2847    fn test_borrowable_cpumasks_respects_cpuset() {
2848        let tmp = TempDir::new().unwrap();
2849
2850        // Cell 1 has cpuset 0-7, Cell 2 has cpuset 8-15
2851        let cell1_path = tmp.path().join("cell1");
2852        std::fs::create_dir(&cell1_path).unwrap();
2853        std::fs::write(cell1_path.join("cpuset.cpus"), "0-7\n").unwrap();
2854
2855        let cell2_path = tmp.path().join("cell2");
2856        std::fs::create_dir(&cell2_path).unwrap();
2857        std::fs::write(cell2_path.join("cpuset.cpus"), "8-15\n").unwrap();
2858
2859        let mgr = CellManager::new_with_path(
2860            tmp.path().to_path_buf(),
2861            256,
2862            cpumask_for_range(32),
2863            HashSet::new(),
2864        )
2865        .unwrap();
2866        let assignments = mgr.compute_cpu_assignments(true).unwrap();
2867
2868        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
2869        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
2870
2871        // Cell 1's borrowable should be restricted to its cpuset (0-7),
2872        // minus its own CPUs. Since cell1 gets some of 0-7 as primary,
2873        // the borrowable within 0-7 is whatever it doesn't own.
2874        let cell1_assignment = assignments
2875            .iter()
2876            .find(|a| a.id == cell1_info.cell_id)
2877            .unwrap();
2878        let cell1_borrow = cell1_assignment.borrowable.as_ref().unwrap();
2879        // Cell 1's borrowable should NOT include CPUs outside its cpuset (0-7)
2880        for cpu in 8..32 {
2881            assert!(
2882                !cell1_borrow.test_cpu(cpu),
2883                "Cell 1 borrowable should not include CPU {} (outside cpuset)",
2884                cpu
2885            );
2886        }
2887
2888        // Cell 2's borrowable should be restricted to its cpuset (8-15)
2889        let cell2_assignment = assignments
2890            .iter()
2891            .find(|a| a.id == cell2_info.cell_id)
2892            .unwrap();
2893        let cell2_borrow = cell2_assignment.borrowable.as_ref().unwrap();
2894        for cpu in 0..8 {
2895            assert!(
2896                !cell2_borrow.test_cpu(cpu),
2897                "Cell 2 borrowable should not include CPU {} (outside cpuset)",
2898                cpu
2899            );
2900        }
2901        for cpu in 16..32 {
2902            assert!(
2903                !cell2_borrow.test_cpu(cpu),
2904                "Cell 2 borrowable should not include CPU {} (outside cpuset)",
2905                cpu
2906            );
2907        }
2908    }
2909
2910    // ==================== compute_demand_cpu_assignments tests ====================
2911
2912    #[test]
2913    fn test_demand_cpu_assignments_all_idle() {
2914        let tmp = TempDir::new().unwrap();
2915        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
2916        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
2917
2918        let mgr = CellManager::new_with_path(
2919            tmp.path().to_path_buf(),
2920            256,
2921            cpumask_for_range(12),
2922            HashSet::new(),
2923        )
2924        .unwrap();
2925
2926        // All cells idle (0 demand) -> falls back to equal division
2927        let demands: HashMap<u32, f64> = [(0, 0.0), (1, 0.0), (2, 0.0)].into();
2928        let assignments = mgr.compute_demand_cpu_assignments(&demands, false).unwrap();
2929
2930        // 12 / 3 = 4 each
2931        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2932        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
2933        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
2934        let c1 = assignments
2935            .iter()
2936            .find(|a| a.id == cell1_info.cell_id)
2937            .unwrap();
2938        let c2 = assignments
2939            .iter()
2940            .find(|a| a.id == cell2_info.cell_id)
2941            .unwrap();
2942
2943        assert_eq!(cell0.primary.weight(), 4);
2944        assert_eq!(c1.primary.weight(), 4);
2945        assert_eq!(c2.primary.weight(), 4);
2946    }
2947
2948    #[test]
2949    fn test_demand_cpu_assignments_uneven_demand() {
2950        let tmp = TempDir::new().unwrap();
2951        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
2952        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
2953
2954        let mgr = CellManager::new_with_path(
2955            tmp.path().to_path_buf(),
2956            256,
2957            cpumask_for_range(12),
2958            HashSet::new(),
2959        )
2960        .unwrap();
2961
2962        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
2963        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
2964
2965        // cell1 is very busy (100%), cell2 is idle (1%), cell0 is moderate (50%)
2966        let demands: HashMap<u32, f64> = [
2967            (0, 50.0),
2968            (cell1_info.cell_id, 100.0),
2969            (cell2_info.cell_id, 1.0),
2970        ]
2971        .into();
2972        let assignments = mgr.compute_demand_cpu_assignments(&demands, false).unwrap();
2973
2974        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
2975        let c1 = assignments
2976            .iter()
2977            .find(|a| a.id == cell1_info.cell_id)
2978            .unwrap();
2979        let c2 = assignments
2980            .iter()
2981            .find(|a| a.id == cell2_info.cell_id)
2982            .unwrap();
2983
2984        // Busy cell should get more CPUs than idle cell
2985        assert!(
2986            c1.primary.weight() > c2.primary.weight(),
2987            "Busy cell ({}) should have more CPUs than idle cell ({})",
2988            c1.primary.weight(),
2989            c2.primary.weight()
2990        );
2991        // Each cell should have at least 1 CPU (floor guarantee)
2992        assert!(c2.primary.weight() >= 1);
2993        assert!(cell0.primary.weight() >= 1);
2994        // Total should be 12
2995        assert_eq!(
2996            cell0.primary.weight() + c1.primary.weight() + c2.primary.weight(),
2997            12
2998        );
2999    }
3000
3001    #[test]
3002    fn test_demand_cpu_assignments_with_cpusets() {
3003        let tmp = TempDir::new().unwrap();
3004
3005        // Two cells with overlapping cpusets
3006        let cell_a_path = tmp.path().join("cell_a");
3007        std::fs::create_dir(&cell_a_path).unwrap();
3008        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-7\n").unwrap();
3009
3010        let cell_b_path = tmp.path().join("cell_b");
3011        std::fs::create_dir(&cell_b_path).unwrap();
3012        std::fs::write(cell_b_path.join("cpuset.cpus"), "4-11\n").unwrap();
3013
3014        let mgr = CellManager::new_with_path(
3015            tmp.path().to_path_buf(),
3016            256,
3017            cpumask_for_range(16),
3018            HashSet::new(),
3019        )
3020        .unwrap();
3021
3022        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
3023        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
3024
3025        // Cell A is much busier than Cell B
3026        let demands: HashMap<u32, f64> = [
3027            (0, 10.0),
3028            (cell_a_info.cell_id, 90.0),
3029            (cell_b_info.cell_id, 10.0),
3030        ]
3031        .into();
3032        let assignments = mgr.compute_demand_cpu_assignments(&demands, false).unwrap();
3033
3034        let cell_a = assignments
3035            .iter()
3036            .find(|a| a.id == cell_a_info.cell_id)
3037            .unwrap();
3038        let cell_b = assignments
3039            .iter()
3040            .find(|a| a.id == cell_b_info.cell_id)
3041            .unwrap();
3042
3043        // Cell A should get more of the contested CPUs 4-7
3044        // Exclusive: A gets 0-3, B gets 8-11
3045        // Contested 4-7: A should get more due to higher demand
3046        assert!(
3047            cell_a.primary.weight() > cell_b.primary.weight(),
3048            "Cell A ({}) should have more CPUs than Cell B ({})",
3049            cell_a.primary.weight(),
3050            cell_b.primary.weight()
3051        );
3052
3053        // Both should have at least their exclusive CPUs
3054        assert!(cell_a.primary.weight() >= 4);
3055        assert!(cell_b.primary.weight() >= 4);
3056    }
3057
3058    #[test]
3059    fn test_demand_cpu_assignments_idle_cell_gets_floor() {
3060        let tmp = TempDir::new().unwrap();
3061        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
3062        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
3063
3064        let mgr = CellManager::new_with_path(
3065            tmp.path().to_path_buf(),
3066            256,
3067            cpumask_for_range(12),
3068            HashSet::new(),
3069        )
3070        .unwrap();
3071
3072        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
3073        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
3074
3075        // cell1 is very busy, cell2 is completely idle (weight 0)
3076        let demands: HashMap<u32, f64> = [
3077            (0, 50.0),
3078            (cell1_info.cell_id, 100.0),
3079            (cell2_info.cell_id, 0.0),
3080        ]
3081        .into();
3082        let assignments = mgr.compute_demand_cpu_assignments(&demands, false).unwrap();
3083
3084        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
3085        let c1 = assignments
3086            .iter()
3087            .find(|a| a.id == cell1_info.cell_id)
3088            .unwrap();
3089        let c2 = assignments
3090            .iter()
3091            .find(|a| a.id == cell2_info.cell_id)
3092            .unwrap();
3093
3094        // Idle cell gets its minimum target (1 CPU) via deficit-based distribution:
3095        // compute_targets assigns a target of 1, giving it a deficit of 1.
3096        assert_eq!(
3097            c2.primary.weight(),
3098            1,
3099            "Idle cell should get minimum target of 1 CPU"
3100        );
3101        // Busy cell should get the most CPUs
3102        assert!(c1.primary.weight() > cell0.primary.weight());
3103        assert!(c1.primary.weight() > c2.primary.weight());
3104        // Total should be 12
3105        assert_eq!(
3106            cell0.primary.weight() + c1.primary.weight() + c2.primary.weight(),
3107            12
3108        );
3109    }
3110
3111    #[test]
3112    fn test_demand_cpu_assignments_negative_weight_errors() {
3113        let tmp = TempDir::new().unwrap();
3114        std::fs::create_dir(tmp.path().join("cell1")).unwrap();
3115
3116        let mgr = CellManager::new_with_path(
3117            tmp.path().to_path_buf(),
3118            256,
3119            cpumask_for_range(8),
3120            HashSet::new(),
3121        )
3122        .unwrap();
3123
3124        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
3125
3126        let demands: HashMap<u32, f64> = [(0, 50.0), (cell1_info.cell_id, -10.0)].into();
3127        let result = mgr.compute_demand_cpu_assignments(&demands, false);
3128        assert!(result.is_err(), "Negative weight should be rejected");
3129        assert!(
3130            result
3131                .unwrap_err()
3132                .to_string()
3133                .contains("negative demand weight"),
3134            "Error message should mention negative weight"
3135        );
3136    }
3137
3138    // ==================== Deficit distribution tests ====================
3139
3140    #[test]
3141    fn test_deficit_distribution_with_cpusets() {
3142        // Two pinned cells with overlapping cpusets + cell0.
3143        // One cell has high demand weight -> gets more contested CPUs.
3144        // Cell that exceeds target from exclusive gets 0 contested.
3145        let tmp = TempDir::new().unwrap();
3146
3147        // cell_a: cpuset 0-9 (10 CPUs)
3148        let cell_a_path = tmp.path().join("cell_a");
3149        std::fs::create_dir(&cell_a_path).unwrap();
3150        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-9\n").unwrap();
3151
3152        // cell_b: cpuset 6-11 (6 CPUs), overlaps with cell_a on 6-9
3153        let cell_b_path = tmp.path().join("cell_b");
3154        std::fs::create_dir(&cell_b_path).unwrap();
3155        std::fs::write(cell_b_path.join("cpuset.cpus"), "6-11\n").unwrap();
3156
3157        // 16 CPUs total
3158        let mgr = CellManager::new_with_path(
3159            tmp.path().to_path_buf(),
3160            256,
3161            cpumask_for_range(16),
3162            HashSet::new(),
3163        )
3164        .unwrap();
3165
3166        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
3167        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
3168
3169        // cell_a has high demand (90), cell_b has low demand (10), cell0 moderate (10)
3170        let demands: HashMap<u32, f64> = [
3171            (0, 10.0),
3172            (cell_a_info.cell_id, 90.0),
3173            (cell_b_info.cell_id, 10.0),
3174        ]
3175        .into();
3176        let assignments = mgr.compute_demand_cpu_assignments(&demands, false).unwrap();
3177
3178        let cell_a = assignments
3179            .iter()
3180            .find(|a| a.id == cell_a_info.cell_id)
3181            .unwrap();
3182        let cell_b = assignments
3183            .iter()
3184            .find(|a| a.id == cell_b_info.cell_id)
3185            .unwrap();
3186        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
3187
3188        // Verify total = 16
3189        assert_eq!(
3190            cell_a.primary.weight() + cell_b.primary.weight() + cell0.primary.weight(),
3191            16,
3192        );
3193
3194        // cell_a should get the most CPUs (high demand)
3195        assert!(
3196            cell_a.primary.weight() > cell_b.primary.weight(),
3197            "cell_a ({}) should have more CPUs than cell_b ({})",
3198            cell_a.primary.weight(),
3199            cell_b.primary.weight()
3200        );
3201
3202        // Every cell should have at least 1 CPU
3203        assert!(cell_a.primary.weight() >= 1);
3204        assert!(cell_b.primary.weight() >= 1);
3205        assert!(cell0.primary.weight() >= 1);
3206    }
3207
3208    #[test]
3209    fn test_deficit_distribution_equal_weight_with_exclusive() {
3210        // Equal weights, one cell has cpuset covering half the CPUs.
3211        // The deficit adjustment should give the other cell(s) more unclaimed CPUs.
3212        let tmp = TempDir::new().unwrap();
3213
3214        // cell1 has cpuset 0-7 (8 CPUs exclusive, no overlap)
3215        let cell1_path = tmp.path().join("cell1");
3216        std::fs::create_dir(&cell1_path).unwrap();
3217        std::fs::write(cell1_path.join("cpuset.cpus"), "0-7\n").unwrap();
3218
3219        // cell2 has no cpuset (unpinned)
3220        std::fs::create_dir(tmp.path().join("cell2")).unwrap();
3221
3222        // 16 CPUs total, 3 cells (cell0, cell1, cell2), equal weight
3223        let mgr = CellManager::new_with_path(
3224            tmp.path().to_path_buf(),
3225            256,
3226            cpumask_for_range(16),
3227            HashSet::new(),
3228        )
3229        .unwrap();
3230
3231        let cell1_info = mgr.find_cell_by_name("cell1").unwrap();
3232        let cell2_info = mgr.find_cell_by_name("cell2").unwrap();
3233
3234        let assignments = mgr.compute_cpu_assignments(false).unwrap();
3235
3236        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
3237        let cell1 = assignments
3238            .iter()
3239            .find(|a| a.id == cell1_info.cell_id)
3240            .unwrap();
3241        let cell2 = assignments
3242            .iter()
3243            .find(|a| a.id == cell2_info.cell_id)
3244            .unwrap();
3245
3246        // Targets (equal weight, 3 cells, 16 CPUs): cell0=6, cell1=5, cell2=5
3247        // cell1 has 8 exclusive (exceeds target of 5), deficit = 0
3248        // 8 unclaimed CPUs split by deficit: cell0 deficit=6, cell2 deficit=5
3249        // cell0 gets ceil(6/11*8) ~= 4, cell2 gets floor(5/11*8) ~= 4
3250        // But exact split: 6/11*8 = 4.36, 5/11*8 = 3.63
3251        // floor: cell0=4, cell2=3 -> assigned=7, remainder=1 -> cell0 gets it
3252        // Result: cell0=5, cell1=8, cell2=3
3253
3254        assert_eq!(cell1.primary.weight(), 8); // Gets all its exclusive CPUs
3255        assert_eq!(
3256            cell0.primary.weight() + cell1.primary.weight() + cell2.primary.weight(),
3257            16,
3258        );
3259
3260        // cell0 should get more unclaimed CPUs than cell2 (higher deficit)
3261        assert!(
3262            cell0.primary.weight() >= cell2.primary.weight(),
3263            "cell0 ({}) should have >= CPUs than cell2 ({})",
3264            cell0.primary.weight(),
3265            cell2.primary.weight()
3266        );
3267    }
3268
3269    #[test]
3270    fn test_deficit_all_cells_exceed_target() {
3271        // Scenario where all claimants in a contested group already exceed their
3272        // global target from exclusive CPUs alone. Verify fallback to equal distribution.
3273        let tmp = TempDir::new().unwrap();
3274
3275        // cell_a: cpuset 0-9 (10 CPUs, overlaps with cell_b on 8-9)
3276        let cell_a_path = tmp.path().join("cell_a");
3277        std::fs::create_dir(&cell_a_path).unwrap();
3278        std::fs::write(cell_a_path.join("cpuset.cpus"), "0-9\n").unwrap();
3279
3280        // cell_b: cpuset 8-13 (6 CPUs, overlaps with cell_a on 8-9)
3281        let cell_b_path = tmp.path().join("cell_b");
3282        std::fs::create_dir(&cell_b_path).unwrap();
3283        std::fs::write(cell_b_path.join("cpuset.cpus"), "8-13\n").unwrap();
3284
3285        // 20 CPUs total, 3 cells, equal weight
3286        // Targets: cell0=7, cell_a=7, cell_b=6 (or similar)
3287        // cell_a exclusive: 0-7 (8 CPUs) -> exceeds target of 7
3288        // cell_b exclusive: 10-13 (4 CPUs) -> below target of 6
3289        // Contested: 8-9 (2 CPUs) -> cell_a deficit=0, cell_b deficit=2
3290        // cell_b should get both contested CPUs
3291        let mgr = CellManager::new_with_path(
3292            tmp.path().to_path_buf(),
3293            256,
3294            cpumask_for_range(20),
3295            HashSet::new(),
3296        )
3297        .unwrap();
3298
3299        let cell_a_info = mgr.find_cell_by_name("cell_a").unwrap();
3300        let cell_b_info = mgr.find_cell_by_name("cell_b").unwrap();
3301
3302        let assignments = mgr.compute_cpu_assignments(false).unwrap();
3303
3304        let cell_a = assignments
3305            .iter()
3306            .find(|a| a.id == cell_a_info.cell_id)
3307            .unwrap();
3308        let cell_b = assignments
3309            .iter()
3310            .find(|a| a.id == cell_b_info.cell_id)
3311            .unwrap();
3312        let cell0 = assignments.iter().find(|a| a.id == 0).unwrap();
3313
3314        // cell_a exceeded its target from exclusive alone, so it gets 0 contested CPUs.
3315        // cell_b has deficit, so it gets all 2 contested CPUs.
3316        let cell_a_contested: Vec<_> = (8..10)
3317            .filter(|&cpu| cell_a.primary.test_cpu(cpu))
3318            .collect();
3319        let cell_b_contested: Vec<_> = (8..10)
3320            .filter(|&cpu| cell_b.primary.test_cpu(cpu))
3321            .collect();
3322        assert_eq!(
3323            cell_a_contested.len(),
3324            0,
3325            "cell_a should get 0 contested CPUs (exceeded target)"
3326        );
3327        assert_eq!(
3328            cell_b_contested.len(),
3329            2,
3330            "cell_b should get all 2 contested CPUs (has deficit)"
3331        );
3332
3333        // Total should be 20
3334        assert_eq!(
3335            cell_a.primary.weight() + cell_b.primary.weight() + cell0.primary.weight(),
3336            20,
3337        );
3338
3339        // Every cell should have at least 1 CPU
3340        assert!(cell0.primary.weight() >= 1);
3341        assert!(cell_a.primary.weight() >= 1);
3342        assert!(cell_b.primary.weight() >= 1);
3343    }
3344
3345    // ==================== distribute_cpus_proportional tests ====================
3346
3347    #[test]
3348    fn test_distribute_proportional_basic() {
3349        let cpus: Vec<usize> = (0..8).collect();
3350        let recipients = vec![(0, 3.0), (1, 1.0)];
3351        let result = CpuManager::distribute_cpus_proportional(&cpus, &recipients).unwrap();
3352
3353        // 3/4 * 8 = 6 for cell 0, 1/4 * 8 = 2 for cell 1
3354        assert_eq!(result.get(&0).unwrap().len(), 6);
3355        assert_eq!(result.get(&1).unwrap().len(), 2);
3356    }
3357
3358    #[test]
3359    fn test_distribute_proportional_zero_weight_gets_nothing() {
3360        let cpus: Vec<usize> = (0..6).collect();
3361        let recipients = vec![(0, 1.0), (1, 0.0)];
3362        let result = CpuManager::distribute_cpus_proportional(&cpus, &recipients).unwrap();
3363
3364        // Cell 0 gets all, cell 1 gets nothing
3365        assert_eq!(result.get(&0).unwrap().len(), 6);
3366        assert!(result.get(&1).is_none() || result.get(&1).unwrap().is_empty());
3367    }
3368
3369    #[test]
3370    fn test_distribute_proportional_all_zero_fallback() {
3371        let cpus: Vec<usize> = (0..6).collect();
3372        let recipients = vec![(0, 0.0), (1, 0.0)];
3373        let result = CpuManager::distribute_cpus_proportional(&cpus, &recipients).unwrap();
3374
3375        // Falls back to equal division
3376        assert_eq!(result.get(&0).unwrap().len(), 3);
3377        assert_eq!(result.get(&1).unwrap().len(), 3);
3378    }
3379
3380    #[test]
3381    fn test_distribute_proportional_skewed_weights_floor_guarantee() {
3382        // Heavily skewed weights: 1.0 vs 100.0 with 38 CPUs (mirrors the bug scenario).
3383        // Without floor guarantee, cell 0 would get floor(1/101 * 38) = 0.
3384        let cpus: Vec<usize> = (0..38).collect();
3385        let recipients = vec![(2, 1.0), (3, 100.0)];
3386        let result = CpuManager::distribute_cpus_proportional(&cpus, &recipients).unwrap();
3387
3388        // Both cells must get at least 1 CPU
3389        let cell2_count = result.get(&2).map_or(0, |v| v.len());
3390        let cell3_count = result.get(&3).map_or(0, |v| v.len());
3391        assert!(
3392            cell2_count >= 1,
3393            "cell 2 must get at least 1 CPU, got {}",
3394            cell2_count,
3395        );
3396        assert!(
3397            cell3_count >= 1,
3398            "cell 3 must get at least 1 CPU, got {}",
3399            cell3_count,
3400        );
3401        assert_eq!(cell2_count + cell3_count, 38, "all CPUs must be assigned");
3402    }
3403
3404    #[test]
3405    fn test_distribute_proportional_overlapping_cpusets_no_starvation() {
3406        // Simulates two cells sharing an identical cpuset (all CPUs contested).
3407        // Cell A has deficit=1 (low demand), Cell B has deficit=87 (high demand).
3408        // Without floor guarantee, cell A gets 0 → death spiral.
3409        let cpus: Vec<usize> = (24..62).collect(); // 38 CPUs (24-61)
3410        let recipients = vec![(2, 1.0), (3, 87.0)];
3411        let result = CpuManager::distribute_cpus_proportional(&cpus, &recipients).unwrap();
3412
3413        let cell2_count = result.get(&2).map_or(0, |v| v.len());
3414        let cell3_count = result.get(&3).map_or(0, |v| v.len());
3415        assert!(
3416            cell2_count >= 1,
3417            "cell 2 must not be starved, got {} CPUs",
3418            cell2_count,
3419        );
3420        assert!(
3421            cell3_count >= 1,
3422            "cell 3 must not be starved, got {} CPUs",
3423            cell3_count,
3424        );
3425        assert_eq!(cell2_count + cell3_count, 38);
3426    }
3427
3428    // ==================== compute_targets tests ====================
3429
3430    #[test]
3431    fn test_compute_targets_equal_weight() {
3432        let targets = CpuManager::compute_targets(12, &[(0, 1.0), (1, 1.0), (2, 1.0)]).unwrap();
3433        assert_eq!(*targets.get(&0).unwrap(), 4);
3434        assert_eq!(*targets.get(&1).unwrap(), 4);
3435        assert_eq!(*targets.get(&2).unwrap(), 4);
3436    }
3437
3438    #[test]
3439    fn test_compute_targets_with_remainder() {
3440        let targets = CpuManager::compute_targets(10, &[(0, 1.0), (1, 1.0), (2, 1.0)]).unwrap();
3441        // 10 / 3 = 3 each + 1 remainder
3442        let total: usize = targets.values().sum();
3443        assert_eq!(total, 10);
3444        for &count in targets.values() {
3445            assert!((3..=4).contains(&count));
3446        }
3447    }
3448
3449    // ==================== CpuManager domain tests ====================
3450
3451    #[test]
3452    fn test_cpu_manager_non_root_domain_unpinned_invariants() {
3453        let domain = cpumask_from_cpulist(32, "8-15");
3454        let recipients = vec![
3455            CpuRecipient {
3456                id: 10,
3457                weight: 1.0,
3458                allowed: domain.clone(),
3459                claimed: None,
3460                minimum: CpuMinimum::default(),
3461            },
3462            CpuRecipient {
3463                id: 11,
3464                weight: 1.0,
3465                allowed: domain.clone(),
3466                claimed: None,
3467                minimum: CpuMinimum::default(),
3468            },
3469            CpuRecipient {
3470                id: 12,
3471                weight: 2.0,
3472                allowed: domain.clone(),
3473                claimed: None,
3474                minimum: CpuMinimum::default(),
3475            },
3476        ];
3477
3478        let assignments = CpuManager::new(&domain)
3479            .compute_assignments(&recipients, false)
3480            .unwrap();
3481
3482        assert_eq!(assignments.len(), 3);
3483        assert_eq!(find_assignment(&assignments, 10).primary.weight(), 2);
3484        assert_eq!(find_assignment(&assignments, 11).primary.weight(), 2);
3485        assert_eq!(find_assignment(&assignments, 12).primary.weight(), 4);
3486
3487        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
3488        assert_eq!(total, domain.weight());
3489
3490        for assignment in &assignments {
3491            assert!(
3492                assignment.borrowable.is_none(),
3493                "borrowable should stay disabled in this case"
3494            );
3495            for cpu in assignment.primary.iter() {
3496                assert!(domain.test_cpu(cpu), "CPU {} escaped the domain", cpu);
3497            }
3498        }
3499
3500        for i in 0..assignments.len() {
3501            for j in (i + 1)..assignments.len() {
3502                let overlap = assignments[i].primary.and(&assignments[j].primary);
3503                assert_eq!(
3504                    overlap.weight(),
3505                    0,
3506                    "Recipients {} and {} overlap",
3507                    assignments[i].id,
3508                    assignments[j].id
3509                );
3510            }
3511        }
3512
3513        let mut union = Cpumask::new();
3514        for assignment in &assignments {
3515            union = union.or(&assignment.primary);
3516        }
3517        assert_eq!(union, domain, "Primary masks should cover the whole domain");
3518    }
3519
3520    #[test]
3521    fn test_cpu_manager_non_contiguous_domain_borrowable_invariants() {
3522        let domain = cpumask_from_cpulist(32, "8-9,12,14-15,20-21,26");
3523        let allowed_1 = cpumask_from_cpulist(32, "0-1,8-9,12");
3524        let allowed_2 = cpumask_from_cpulist(32, "9,14-15,20-22");
3525        let recipients = vec![
3526            CpuRecipient {
3527                id: 1,
3528                weight: 1.0,
3529                allowed: allowed_1.clone(),
3530                claimed: Some(allowed_1.clone()),
3531                minimum: CpuMinimum::default(),
3532            },
3533            CpuRecipient {
3534                id: 2,
3535                weight: 2.0,
3536                allowed: allowed_2.clone(),
3537                claimed: Some(allowed_2.clone()),
3538                minimum: CpuMinimum::default(),
3539            },
3540            CpuRecipient {
3541                id: 3,
3542                weight: 1.0,
3543                allowed: domain.clone(),
3544                claimed: None,
3545                minimum: CpuMinimum::default(),
3546            },
3547        ];
3548
3549        let assignments = CpuManager::new(&domain)
3550            .compute_assignments(&recipients, true)
3551            .unwrap();
3552
3553        assert_eq!(assignments.len(), 3);
3554
3555        for assignment in &assignments {
3556            for cpu in assignment.primary.iter() {
3557                assert!(
3558                    domain.test_cpu(cpu),
3559                    "Primary CPU {} escaped the domain",
3560                    cpu
3561                );
3562            }
3563            let borrowable = assignment.borrowable.as_ref().unwrap();
3564            for cpu in borrowable.iter() {
3565                assert!(
3566                    domain.test_cpu(cpu),
3567                    "Borrowable CPU {} escaped the domain",
3568                    cpu
3569                );
3570            }
3571            assert_eq!(
3572                assignment.primary.and(borrowable).weight(),
3573                0,
3574                "Primary and borrowable overlap for recipient {}",
3575                assignment.id
3576            );
3577        }
3578
3579        let recipient_1 = find_assignment(&assignments, 1);
3580        let recipient_2 = find_assignment(&assignments, 2);
3581        let recipient_3 = find_assignment(&assignments, 3);
3582
3583        for cpu in recipient_1.primary.iter() {
3584            assert!(
3585                allowed_1.test_cpu(cpu),
3586                "Recipient 1 got CPU {} outside allowed",
3587                cpu
3588            );
3589        }
3590        for cpu in recipient_2.primary.iter() {
3591            assert!(
3592                allowed_2.test_cpu(cpu),
3593                "Recipient 2 got CPU {} outside allowed",
3594                cpu
3595            );
3596        }
3597
3598        let allowed_1_in_domain = domain.and(&allowed_1);
3599        let allowed_2_in_domain = domain.and(&allowed_2);
3600        let recipient_1_union = recipient_1
3601            .primary
3602            .or(recipient_1.borrowable.as_ref().unwrap());
3603        let recipient_2_union = recipient_2
3604            .primary
3605            .or(recipient_2.borrowable.as_ref().unwrap());
3606        assert_eq!(recipient_1_union, allowed_1_in_domain);
3607        assert_eq!(recipient_2_union, allowed_2_in_domain);
3608
3609        let recipient_3_union = recipient_3
3610            .primary
3611            .or(recipient_3.borrowable.as_ref().unwrap());
3612        assert_eq!(recipient_3_union, domain);
3613        assert_eq!(recipient_3.primary.weight(), 1);
3614        assert!(!recipient_3.primary.test_cpu(8));
3615        assert!(!recipient_3.primary.test_cpu(9));
3616        assert!(recipient_3.primary.test_cpu(26));
3617
3618        let mut union = Cpumask::new();
3619        for assignment in &assignments {
3620            union = union.or(&assignment.primary);
3621        }
3622        assert_eq!(union, domain, "Primary masks should cover the whole domain");
3623    }
3624
3625    /// Symmetric pairwise overlaps must produce equal cell sizes regardless
3626    /// of HashMap iteration order.
3627    #[test]
3628    fn test_symmetric_pairwise_overlap_produces_equal_cells() {
3629        let tmp = TempDir::new().unwrap();
3630
3631        // 5 cells on 56 CPUs. Every pair shares exactly 2 CPUs.
3632        // Each cell: 4 exclusive + 8 contested (2 per pair) = 12 in cpuset.
3633        // Exclusive: 0-19, contested: 20-39, unclaimed: 40-55 (cell 0).
3634        //
3635        // Shared pairs:
3636        //   AB: 20-21, AC: 22-23, AD: 24-25, AE: 26-27
3637        //   BC: 28-29, BD: 30-31, BE: 32-33
3638        //   CD: 34-35, CE: 36-37
3639        //   DE: 38-39
3640        let cpusets = [
3641            ("cell_a", "0-3,20-27"),
3642            ("cell_b", "4-7,20-21,28-33"),
3643            ("cell_c", "8-11,22-23,28-29,34-37"),
3644            ("cell_d", "12-15,24-25,30-31,34-35,38-39"),
3645            ("cell_e", "16-19,26-27,32-33,36-39"),
3646        ];
3647        for (name, cpus) in &cpusets {
3648            let p = tmp.path().join(name);
3649            std::fs::create_dir(&p).unwrap();
3650            std::fs::write(p.join("cpuset.cpus"), format!("{cpus}\n")).unwrap();
3651        }
3652
3653        let mgr = CellManager::new_with_path(
3654            tmp.path().to_path_buf(),
3655            256,
3656            cpumask_for_range(56),
3657            HashSet::new(),
3658        )
3659        .unwrap();
3660
3661        let assignments = mgr.compute_cpu_assignments(false).unwrap();
3662
3663        let workload: Vec<_> = assignments.iter().filter(|a| a.id != 0).collect();
3664        assert_eq!(workload.len(), 5, "Expected 5 workload cells");
3665
3666        // All workload cells must have equal CPU counts.
3667        let counts: Vec<(u32, usize)> = workload
3668            .iter()
3669            .map(|a| (a.id, a.primary.weight()))
3670            .collect();
3671        for &(cell_id, count) in &counts {
3672            assert_eq!(
3673                count, counts[0].1,
3674                "Cell {} has {} CPUs, expected {} — symmetric inputs \
3675                 should produce equal cell sizes. All counts: {:?}",
3676                cell_id, count, counts[0].1, counts,
3677            );
3678        }
3679
3680        // Verify no overlap between any pair of cells.
3681        for i in 0..assignments.len() {
3682            for j in (i + 1)..assignments.len() {
3683                for cpu in 0..56 {
3684                    assert!(
3685                        !(assignments[i].primary.test_cpu(cpu)
3686                            && assignments[j].primary.test_cpu(cpu)),
3687                        "CPU {} assigned to both cell {} and cell {}",
3688                        cpu,
3689                        assignments[i].id,
3690                        assignments[j].id,
3691                    );
3692                }
3693            }
3694        }
3695
3696        // Verify all 56 CPUs are assigned.
3697        let total: usize = assignments.iter().map(|a| a.primary.weight()).sum();
3698        assert_eq!(total, 56, "All CPUs must be assigned");
3699    }
3700}