1use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::os::unix::fs::MetadataExt;
9use std::path::{Path, PathBuf};
10
11use anyhow::Context;
12use anyhow::Result;
13use anyhow::bail;
14use regex::Regex;
15use scx_utils::Cpumask;
16use serde::Deserialize;
17use serde::de;
18use serde_json::Value;
19use tracing::debug;
20
21use crate::cell_manager::CpuRecipient;
22
23#[derive(Clone, Debug)]
24pub struct ConfiguredSubcell {
25 pub id: u32,
26 pub name: String,
27 pub matches: Vec<Vec<SubcellMatch>>,
28}
29
30#[derive(Clone, Debug)]
31pub struct ConfiguredCell {
32 pub id: u32,
33 pub subcells: Vec<ConfiguredSubcell>,
34}
35
36#[derive(Clone, Debug)]
37pub struct ConfiguredCellResolution {
38 pub cell_assignments: Vec<(u64, u32)>,
39 pub cell_recipients: Vec<CpuRecipient>,
40 pub cells: Vec<ConfiguredCell>,
41}
42
43#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
44pub enum SubcellMatch {
45 CommPrefix(String),
46}
47
48#[derive(Clone, Debug, Deserialize)]
49enum CellMatch {
50 CgroupContains(String),
51 CgroupRegex(String),
52}
53
54#[derive(Clone, Debug, Deserialize)]
55struct CellSpec {
56 name: String,
57 #[serde(default, deserialize_with = "deserialize_optional_cell_match")]
58 matches: Option<CellMatch>,
59 #[serde(default)]
60 subcells: Vec<SubcellSpec>,
61}
62
63#[derive(Clone, Debug, Deserialize)]
64struct SubcellSpec {
65 name: String,
66 #[serde(default = "default_subcell_matches")]
67 matches: Vec<Vec<SubcellMatch>>,
68}
69
70#[derive(Clone, Debug)]
71struct CompiledCellSpec {
72 matcher: Option<CompiledCellMatch>,
73 subcells: Vec<ConfiguredSubcell>,
74}
75
76#[derive(Clone, Debug)]
77enum CompiledCellMatch {
78 CgroupContains(String),
79 CgroupRegex(Regex),
80}
81
82#[derive(Clone, Debug)]
83struct CgroupEntry {
84 path: PathBuf,
85 path_string: String,
86 cgid: u64,
87 cpuset: Option<Cpumask>,
88}
89
90#[derive(Clone, Debug)]
91struct MatchedCgroup {
92 spec_idx: usize,
93 cgid: u64,
94 cpuset: Option<Cpumask>,
95}
96
97#[derive(Clone, Debug)]
98pub struct ConfiguredCells {
99 specs: Vec<CompiledCellSpec>,
100 root_spec_idx: Option<usize>,
101 cgroup_root: PathBuf,
102 all_cpus: Cpumask,
103 max_cells: u32,
104 path_cell_ids: HashMap<PathBuf, u32>,
105 free_cell_ids: Vec<u32>,
106 next_cell_id: u32,
107}
108
109impl ConfiguredCells {
110 pub fn load(path: &Path, max_cells: u32, all_cpus: Cpumask) -> Result<Self> {
111 Self::load_with_root(path, PathBuf::from("/sys/fs/cgroup"), max_cells, all_cpus)
112 }
113
114 fn load_with_root(
115 path: &Path,
116 cgroup_root: PathBuf,
117 max_cells: u32,
118 all_cpus: Cpumask,
119 ) -> Result<Self> {
120 let contents = fs::read_to_string(path)
121 .with_context(|| format!("reading cell config {}", path.display()))?;
122 let specs: Vec<CellSpec> = serde_json::from_str(&contents)
123 .with_context(|| format!("parsing cell config {}", path.display()))?;
124 let specs = compile_specs(specs)?;
125 let root_spec_idx = specs.iter().rposition(|spec| spec.matcher.is_none());
126 if specs
127 .iter()
128 .enumerate()
129 .any(|(idx, spec)| spec.matcher.is_none() && Some(idx) != root_spec_idx)
130 {
131 bail!("only the final catch-all cell spec may use an empty match");
132 }
133
134 Ok(Self {
135 specs,
136 root_spec_idx,
137 cgroup_root,
138 all_cpus,
139 max_cells,
140 path_cell_ids: HashMap::new(),
141 free_cell_ids: Vec::new(),
142 next_cell_id: 1,
143 })
144 }
145
146 pub fn resolve(
147 &mut self,
148 cell_demands: Option<&HashMap<u32, f64>>,
149 cell0_min_cpus: usize,
150 ) -> Result<ConfiguredCellResolution> {
151 let cgroups = collect_cgroups(&self.cgroup_root)?;
152 let matched = self.match_cgroups(&cgroups);
153 self.reconcile_cell_ids(&matched)?;
154
155 let mut cell_assignments = Vec::new();
156 let mut cell_recipients = Vec::new();
157 let mut cells = Vec::new();
158
159 cells.push(ConfiguredCell {
160 id: 0,
161 subcells: self.root_subcells(),
162 });
163 cell_recipients.push(CpuRecipient::root(
164 0,
165 demand_weight(cell_demands, 0),
166 &self.all_cpus,
167 cell0_min_cpus,
168 ));
169
170 let mut matched_paths: Vec<_> = matched.keys().cloned().collect();
171 matched_paths.sort();
172
173 for path in matched_paths {
174 let matched_cgroup = matched
175 .get(&path)
176 .expect("BUG: matched path disappeared during config resolution");
177 let cell_id = *self
178 .path_cell_ids
179 .get(&path)
180 .expect("BUG: matched path is missing a cell id");
181 let spec = self
182 .specs
183 .get(matched_cgroup.spec_idx)
184 .expect("BUG: matched cgroup references missing spec");
185
186 cell_assignments.push((matched_cgroup.cgid, cell_id));
187 let weight = demand_weight(cell_demands, cell_id);
188 cell_recipients.push(match &matched_cgroup.cpuset {
189 Some(cpuset) => CpuRecipient::pinned(cell_id, weight, cpuset),
190 None => CpuRecipient::unpinned(cell_id, weight, &self.all_cpus),
191 });
192 cells.push(ConfiguredCell {
193 id: cell_id,
194 subcells: spec.subcells.clone(),
195 });
196 }
197
198 Ok(ConfiguredCellResolution {
199 cell_assignments,
200 cell_recipients,
201 cells,
202 })
203 }
204
205 pub fn format_cell_config(&self, assignments: &[crate::cell_manager::CpuAssignment]) -> String {
206 let names: HashMap<u32, String> = self
207 .path_cell_ids
208 .iter()
209 .map(|(path, id)| {
210 let name = path
211 .strip_prefix(&self.cgroup_root)
212 .ok()
213 .and_then(|rel| rel.to_str())
214 .filter(|rel| !rel.is_empty())
215 .map(|rel| format!("/{}", rel.trim_start_matches('/')))
216 .unwrap_or_else(|| path.display().to_string());
217 (*id, name)
218 })
219 .collect();
220
221 assignments
222 .iter()
223 .map(|assignment| {
224 let name = names
225 .get(&assignment.id)
226 .cloned()
227 .unwrap_or_else(|| "root".to_string());
228 format!(
229 "cell{}({})={}",
230 assignment.id,
231 name,
232 assignment.primary.to_cpulist()
233 )
234 })
235 .collect::<Vec<_>>()
236 .join(" ")
237 }
238
239 fn match_cgroups(&self, cgroups: &[CgroupEntry]) -> HashMap<PathBuf, MatchedCgroup> {
240 let mut raw_matches = HashMap::new();
241
242 for cgroup in cgroups {
243 if cgroup.path == self.cgroup_root {
244 continue;
245 }
246
247 for (spec_idx, spec) in self.specs.iter().enumerate() {
248 if Some(spec_idx) == self.root_spec_idx {
249 continue;
250 }
251 if !spec.matches(&cgroup.path_string) {
252 continue;
253 }
254
255 raw_matches.insert(
256 cgroup.path.clone(),
257 MatchedCgroup {
258 spec_idx,
259 cgid: cgroup.cgid,
260 cpuset: cgroup.cpuset.clone(),
261 },
262 );
263 break;
264 }
265 }
266
267 let mut paths: Vec<_> = raw_matches.keys().cloned().collect();
268 paths.sort_by_key(|path| path.components().count());
269
270 let mut matched: HashMap<PathBuf, MatchedCgroup> = HashMap::new();
271 for path in paths {
272 let raw_match = raw_matches
273 .get(&path)
274 .expect("BUG: raw matched path disappeared");
275 let covered_by_ancestor = path.ancestors().skip(1).any(|ancestor| {
276 matched
277 .get(ancestor)
278 .is_some_and(|ancestor_match| ancestor_match.spec_idx <= raw_match.spec_idx)
279 });
280 if covered_by_ancestor {
281 continue;
282 }
283
284 matched.insert(path.clone(), raw_match.clone());
285 }
286
287 matched
288 }
289
290 fn reconcile_cell_ids(&mut self, matched: &HashMap<PathBuf, MatchedCgroup>) -> Result<()> {
291 let matched_paths: HashSet<_> = matched.keys().cloned().collect();
292 let removed: Vec<_> = self
293 .path_cell_ids
294 .keys()
295 .filter(|path| !matched_paths.contains(*path))
296 .cloned()
297 .collect();
298
299 for path in removed {
300 if let Some(cell_id) = self.path_cell_ids.remove(&path) {
301 debug!("Removed configured cell {} for {}", cell_id, path.display());
302 self.free_cell_ids.push(cell_id);
303 }
304 }
305
306 let mut paths: Vec<_> = matched.keys().cloned().collect();
307 paths.sort();
308 for path in paths {
309 if self.path_cell_ids.contains_key(&path) {
310 continue;
311 }
312 let cell_id = self.allocate_cell_id()?;
313 debug!("Created configured cell {} for {}", cell_id, path.display());
314 self.path_cell_ids.insert(path, cell_id);
315 }
316
317 Ok(())
318 }
319
320 fn allocate_cell_id(&mut self) -> Result<u32> {
321 if let Some(id) = self.free_cell_ids.pop() {
322 return Ok(id);
323 }
324 if self.next_cell_id >= self.max_cells {
325 bail!("Cell ID space exhausted (max_cells={})", self.max_cells);
326 }
327 let id = self.next_cell_id;
328 self.next_cell_id += 1;
329 Ok(id)
330 }
331
332 fn root_subcells(&self) -> Vec<ConfiguredSubcell> {
333 self.root_spec_idx
334 .and_then(|idx| self.specs.get(idx))
335 .map(|spec| spec.subcells.clone())
336 .unwrap_or_else(default_configured_subcells)
337 }
338
339 pub fn all_cpus(&self) -> &Cpumask {
340 &self.all_cpus
341 }
342
343 pub fn cgroup_path_for_cell(&self, cell_id: u32) -> String {
346 self.path_cell_ids
347 .iter()
348 .find(|&(_, &id)| id == cell_id)
349 .map(|(path, _)| {
350 path.strip_prefix(&self.cgroup_root)
351 .map(|rel| format!("/{}", rel.to_string_lossy()))
352 .unwrap_or_else(|_| path.to_string_lossy().into_owned())
353 })
354 .unwrap_or_else(|| "/".to_string())
355 }
356}
357
358impl CompiledCellSpec {
359 fn matches(&self, cgroup_path: &str) -> bool {
360 match &self.matcher {
361 Some(CompiledCellMatch::CgroupContains(substr)) => cgroup_path.contains(substr),
362 Some(CompiledCellMatch::CgroupRegex(regex)) => regex.is_match(cgroup_path),
363 None => true,
364 }
365 }
366}
367
368fn compile_specs(specs: Vec<CellSpec>) -> Result<Vec<CompiledCellSpec>> {
369 if specs.is_empty() {
370 bail!("cell config must contain at least one cell spec");
371 }
372
373 specs
374 .into_iter()
375 .map(|spec| {
376 let matcher = match spec.matches {
377 Some(CellMatch::CgroupContains(substr)) => {
378 Some(CompiledCellMatch::CgroupContains(substr))
379 }
380 Some(CellMatch::CgroupRegex(expr)) => Some(CompiledCellMatch::CgroupRegex(
381 Regex::new(&expr).with_context(|| {
382 format!("invalid CgroupRegex '{}' for cell '{}'", expr, spec.name)
383 })?,
384 )),
385 None => None,
386 };
387
388 Ok(CompiledCellSpec {
389 matcher,
390 subcells: normalize_subcells(spec.subcells)?,
391 })
392 })
393 .collect()
394}
395
396fn normalize_subcells(subcells: Vec<SubcellSpec>) -> Result<Vec<ConfiguredSubcell>> {
397 if subcells.is_empty() {
398 return Ok(default_configured_subcells());
399 }
400
401 let mut normalized = Vec::new();
402 let mut next_id = 1;
403 let mut catch_all = None;
404
405 for subcell in subcells {
406 if is_catch_all_subcell(&subcell) {
407 if catch_all.is_some() {
408 bail!("cell config contains multiple catch-all subcells");
409 }
410 catch_all = Some(ConfiguredSubcell {
411 id: 0,
412 name: subcell.name,
413 matches: subcell.matches,
414 });
415 continue;
416 }
417
418 normalized.push(ConfiguredSubcell {
419 id: next_id,
420 name: subcell.name,
421 matches: subcell.matches,
422 });
423 next_id += 1;
424 }
425
426 let mut result = vec![catch_all.unwrap_or_else(default_catch_all_subcell)];
427 result.extend(normalized);
428
429 if result.len() > crate::MAX_SUBCELLS_PER_CELL {
430 bail!(
431 "cell config has too many subcells: {} > {}",
432 result.len(),
433 crate::MAX_SUBCELLS_PER_CELL
434 );
435 }
436
437 Ok(result)
438}
439
440fn is_catch_all_subcell(subcell: &SubcellSpec) -> bool {
441 subcell.matches.iter().any(|ands| ands.is_empty())
442}
443
444fn default_configured_subcells() -> Vec<ConfiguredSubcell> {
445 vec![default_catch_all_subcell()]
446}
447
448fn default_catch_all_subcell() -> ConfiguredSubcell {
449 ConfiguredSubcell {
450 id: 0,
451 name: "rest".to_string(),
452 matches: vec![Vec::new()],
453 }
454}
455
456fn default_subcell_matches() -> Vec<Vec<SubcellMatch>> {
457 vec![Vec::new()]
458}
459
460fn demand_weight(cell_demands: Option<&HashMap<u32, f64>>, cell_id: u32) -> f64 {
461 cell_demands
462 .and_then(|demands| demands.get(&cell_id).copied())
463 .unwrap_or(1.0)
464 .max(0.0)
465}
466
467fn collect_cgroups(root: &Path) -> Result<Vec<CgroupEntry>> {
468 let mut cgroups = Vec::new();
469 collect_cgroups_inner(root, &mut cgroups)?;
470 cgroups.sort_by(|a, b| a.path.cmp(&b.path));
471 Ok(cgroups)
472}
473
474fn collect_cgroups_inner(path: &Path, cgroups: &mut Vec<CgroupEntry>) -> Result<()> {
475 let metadata = match fs::metadata(path) {
476 Ok(metadata) => metadata,
477 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
478 Err(e) => {
479 return Err(e).with_context(|| format!("reading metadata for {}", path.display()));
480 }
481 };
482
483 if !metadata.is_dir() {
484 return Ok(());
485 }
486
487 let entries = match fs::read_dir(path) {
492 Ok(entries) => entries,
493 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
494 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
495 };
496
497 cgroups.push(CgroupEntry {
498 path: path.to_path_buf(),
499 path_string: path.display().to_string(),
500 cgid: metadata.ino(),
501 cpuset: read_cpuset(path)?,
502 });
503
504 for entry in entries {
505 let entry = match entry {
506 Ok(entry) => entry,
507 Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
508 Err(e) => {
509 return Err(e)
510 .with_context(|| format!("reading directory entry in {}", path.display()));
511 }
512 };
513 let file_type = match entry.file_type() {
514 Ok(file_type) => file_type,
515 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
516 Err(e) => {
517 return Err(e)
518 .with_context(|| format!("reading file type for {}", entry.path().display()));
519 }
520 };
521 if file_type.is_dir() {
522 collect_cgroups_inner(&entry.path(), cgroups)?;
523 }
524 }
525
526 Ok(())
527}
528
529fn read_cpuset(cgroup_path: &Path) -> Result<Option<Cpumask>> {
530 let cpuset_path = cgroup_path.join("cpuset.cpus");
531 match fs::read_to_string(&cpuset_path) {
532 Ok(content) => {
533 let content = content.trim();
534 if content.is_empty() {
535 Ok(None)
536 } else {
537 Cpumask::from_cpulist(content)
538 .with_context(|| {
539 format!(
540 "failed to parse cpuset '{}' from {}",
541 content,
542 cpuset_path.display()
543 )
544 })
545 .map(Some)
546 }
547 }
548 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
549 Err(e) => Err(e).with_context(|| format!("reading {}", cpuset_path.display())),
550 }
551}
552
553fn deserialize_optional_cell_match<'de, D>(deserializer: D) -> Result<Option<CellMatch>, D::Error>
554where
555 D: serde::Deserializer<'de>,
556{
557 let value = Value::deserialize(deserializer)?;
558 if value.as_object().is_some_and(|obj| obj.is_empty()) {
559 return Ok(None);
560 }
561 serde_json::from_value(value)
562 .map(Some)
563 .map_err(de::Error::custom)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use std::io::Write;
570
571 use tempfile::NamedTempFile;
572 use tempfile::TempDir;
573
574 fn write_config(contents: &str) -> NamedTempFile {
575 let mut file = NamedTempFile::new().unwrap();
576 file.write_all(contents.as_bytes()).unwrap();
577 file
578 }
579
580 #[test]
581 fn parses_example_config_and_makes_rest_subcell_zero() {
582 let config = write_config(
583 r#"
584 [
585 {
586 "name": "group",
587 "matches": { "CgroupRegex": "abc-xyz-[^/]+\\.group\\.slice" },
588 "subcells": [
589 { "name": "worker-a", "matches": [[{ "CommPrefix": "worker-a" }]] },
590 { "name": "worker-b", "matches": [[{ "CommPrefix": "worker-b" }]] },
591 { "name": "rest", "matches": [[]] }
592 ]
593 },
594 { "name": "workload.slice", "matches": { "CgroupContains": "workload.slice" } },
595 { "name": "rest", "matches": {} }
596 ]
597 "#,
598 );
599 let configured = ConfiguredCells::load_with_root(
600 config.path(),
601 PathBuf::from("/tmp"),
602 256,
603 Cpumask::new(),
604 )
605 .unwrap();
606
607 assert_eq!(configured.root_spec_idx, Some(2));
608 let subcells = &configured.specs[0].subcells;
609 assert_eq!(subcells[0].id, 0);
610 assert_eq!(subcells[0].name, "rest");
611 assert_eq!(subcells[1].id, 1);
612 assert_eq!(subcells[1].name, "worker-a");
613 assert_eq!(subcells[2].id, 2);
614 assert_eq!(subcells[2].name, "worker-b");
615 }
616
617 #[test]
618 fn descendant_inherits_same_matching_cell_spec() {
619 let config = write_config(
620 r#"
621 [
622 { "name": "parent", "matches": { "CgroupContains": "parent.slice" } },
623 { "name": "rest", "matches": {} }
624 ]
625 "#,
626 );
627 let root = TempDir::new().unwrap();
628 fs::create_dir(root.path().join("parent.slice")).unwrap();
629 fs::create_dir(root.path().join("parent.slice/child.scope")).unwrap();
630
631 let mut configured = ConfiguredCells::load_with_root(
632 config.path(),
633 root.path().to_path_buf(),
634 256,
635 Cpumask::new(),
636 )
637 .unwrap();
638 let resolution = configured.resolve(None, 0).unwrap();
639
640 assert_eq!(resolution.cell_assignments.len(), 1);
641 }
642
643 #[test]
644 fn higher_priority_descendant_gets_own_cell() {
645 let config = write_config(
646 r#"
647 [
648 { "name": "child", "matches": { "CgroupContains": "child.scope" } },
649 { "name": "parent", "matches": { "CgroupContains": "parent.slice" } },
650 { "name": "rest", "matches": {} }
651 ]
652 "#,
653 );
654 let root = TempDir::new().unwrap();
655 fs::create_dir(root.path().join("parent.slice")).unwrap();
656 fs::create_dir(root.path().join("parent.slice/child.scope")).unwrap();
657
658 let mut configured = ConfiguredCells::load_with_root(
659 config.path(),
660 root.path().to_path_buf(),
661 256,
662 Cpumask::new(),
663 )
664 .unwrap();
665 let resolution = configured.resolve(None, 0).unwrap();
666
667 assert_eq!(resolution.cell_assignments.len(), 2);
668 }
669}