1use std::fmt;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum CgroupMemoryLimit {
15 Unlimited,
17 Finite(u64),
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum CgroupMemoryProbeFailureKind {
24 MalformedMembership,
25 MissingUnifiedMount,
26 MalformedMountInfo,
27 Io,
28 InvalidLimit,
29 InvalidCounter,
30 MissingCounter,
31 InconsistentCounters,
32}
33
34impl fmt::Display for CgroupMemoryProbeFailureKind {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let name = match self {
37 Self::MalformedMembership => "malformed-membership",
38 Self::MissingUnifiedMount => "missing-unified-mount",
39 Self::MalformedMountInfo => "malformed-mountinfo",
40 Self::Io => "io",
41 Self::InvalidLimit => "invalid-limit",
42 Self::InvalidCounter => "invalid-counter",
43 Self::MissingCounter => "missing-counter",
44 Self::InconsistentCounters => "inconsistent-counters",
45 };
46 formatter.write_str(name)
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct CgroupMemoryProbeFailure {
53 kind: CgroupMemoryProbeFailureKind,
54 path: Box<str>,
55 detail: Box<str>,
56}
57
58impl CgroupMemoryProbeFailure {
59 pub const fn kind(&self) -> CgroupMemoryProbeFailureKind {
60 self.kind
61 }
62
63 pub fn path(&self) -> &str {
64 &self.path
65 }
66
67 pub fn detail(&self) -> &str {
68 &self.detail
69 }
70}
71
72impl fmt::Display for CgroupMemoryProbeFailure {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 write!(formatter, "{} at {}: {}", self.kind, self.path, self.detail)
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct CgroupMemoryAvailability {
86 binding_path: Box<str>,
87 limit_bytes: u64,
88 current_bytes: u64,
89 inactive_file_bytes: u64,
90 working_set_bytes: u64,
91 available_bytes: u64,
92 inspected_levels: usize,
93}
94
95impl CgroupMemoryAvailability {
96 pub(crate) fn from_consistent_counters(
106 binding_path: impl Into<Box<str>>,
107 limit_bytes: u64,
108 current_bytes: u64,
109 inactive_file_bytes: u64,
110 inspected_levels: usize,
111 ) -> Option<Self> {
112 let working_set_bytes = current_bytes.checked_sub(inactive_file_bytes)?;
113 Some(Self {
114 binding_path: binding_path.into(),
115 limit_bytes,
116 current_bytes,
117 inactive_file_bytes,
118 working_set_bytes,
119 available_bytes: limit_bytes.saturating_sub(working_set_bytes),
120 inspected_levels,
121 })
122 }
123
124 pub const fn limit_bytes(&self) -> u64 {
125 self.limit_bytes
126 }
127
128 pub const fn current_bytes(&self) -> u64 {
129 self.current_bytes
130 }
131
132 pub const fn available_bytes(&self) -> u64 {
133 self.available_bytes
134 }
135
136}
137
138#[cfg(test)]
139mod tests_fixtures {
140 use super::*;
141
142 impl CgroupMemoryProbeFailure {
143 }
144
145 impl CgroupMemoryAvailability {
146 }
147}
148
149impl fmt::Display for CgroupMemoryAvailability {
150 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(
152 formatter,
153 "finite cgroup ceiling at {} (limit={}, current={}, inactive_file={}, working_set={}, available={}, visible_levels={})",
154 self.binding_path,
155 self.limit_bytes,
156 self.current_bytes,
157 self.inactive_file_bytes,
158 self.working_set_bytes,
159 self.available_bytes,
160 self.inspected_levels,
161 )
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Eq)]
167pub enum CgroupMemoryObservation {
168 NotPresent,
170 V2Unbounded {
173 cgroup_path: Box<str>,
174 inspected_levels: usize,
175 },
176 V2Limited(CgroupMemoryAvailability),
179 V1Limited(CgroupMemoryAvailability),
184 ProbeFailed(CgroupMemoryProbeFailure),
187}
188
189impl fmt::Display for CgroupMemoryObservation {
190 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 Self::NotPresent => formatter.write_str("no active cgroup memory controller"),
193 Self::V2Unbounded {
194 cgroup_path,
195 inspected_levels,
196 } => write!(
197 formatter,
198 "unbounded cgroup-v2 hierarchy at {cgroup_path} ({inspected_levels} memory.max levels)"
199 ),
200 Self::V2Limited(observation) => write!(formatter, "cgroup-v2 {observation}"),
201 Self::V1Limited(observation) => write!(formatter, "cgroup-v1 {observation}"),
202 Self::ProbeFailed(failure) => write!(formatter, "cgroup probe failed: {failure}"),
203 }
204 }
205}
206
207#[cfg(not(target_os = "linux"))]
208pub(crate) fn detect_cgroup_memory() -> CgroupMemoryObservation {
209 CgroupMemoryObservation::NotPresent
210}
211
212#[cfg(target_os = "linux")]
213mod linux {
214 use super::*;
215 use std::ffi::OsString;
216 use std::fs;
217 use std::io;
218 use std::os::unix::ffi::OsStringExt;
219 use std::path::{Component, Path, PathBuf};
220
221 impl CgroupMemoryProbeFailure {
222 fn new(
223 kind: CgroupMemoryProbeFailureKind,
224 path: impl Into<Box<str>>,
225 detail: impl Into<Box<str>>,
226 ) -> Self {
227 Self {
228 kind,
229 path: path.into(),
230 detail: detail.into(),
231 }
232 }
233 }
234
235 const PROC_SELF_CGROUP: &str = "/proc/self/cgroup";
236 const PROC_SELF_MOUNTINFO: &str = "/proc/self/mountinfo";
237
238 #[derive(Debug)]
239 struct ControllerMount {
240 root: PathBuf,
241 mount_point: PathBuf,
242 }
243
244 #[derive(Debug)]
245 struct MemoryMembership {
246 unified: Option<PathBuf>,
247 legacy: Option<PathBuf>,
248 }
249
250 pub(super) fn detect() -> CgroupMemoryObservation {
251 detect_from_proc_files(Path::new(PROC_SELF_CGROUP), Path::new(PROC_SELF_MOUNTINFO))
252 .unwrap_or_else(CgroupMemoryObservation::ProbeFailed)
253 }
254
255 fn detect_from_proc_files(
256 cgroup_file: &Path,
257 mountinfo_file: &Path,
258 ) -> Result<CgroupMemoryObservation, CgroupMemoryProbeFailure> {
259 let membership_text = read_required(cgroup_file)?;
260 let membership = parse_memory_membership(&membership_text, cgroup_file)?;
261 let mountinfo_text = read_required(mountinfo_file)?;
262 if let Some(legacy) = membership.legacy {
263 let mount = select_controller_mount(
264 &mountinfo_text,
265 &legacy,
266 mountinfo_file,
267 "cgroup",
268 Some("memory"),
269 )?;
270 let leaf = resolve_membership_leaf(&legacy, &mount, mountinfo_file)?;
271 return inspect_visible_v1_hierarchy(&leaf, &mount.mount_point);
272 }
273 if let Some(unified) = membership.unified {
274 let mount = select_controller_mount(
275 &mountinfo_text,
276 &unified,
277 mountinfo_file,
278 "cgroup2",
279 None,
280 )?;
281 let leaf = resolve_membership_leaf(&unified, &mount, mountinfo_file)?;
282 return inspect_visible_v2_hierarchy(&leaf, &mount.mount_point);
283 }
284 Ok(CgroupMemoryObservation::NotPresent)
285 }
286
287 fn parse_memory_membership(
288 text: &str,
289 source: &Path,
290 ) -> Result<MemoryMembership, CgroupMemoryProbeFailure> {
291 let mut unified = None;
292 let mut legacy = None;
293 for line in text.lines().filter(|line| !line.is_empty()) {
294 let mut fields = line.splitn(3, ':');
295 let hierarchy = fields.next();
296 let controllers = fields.next();
297 let path = fields.next();
298 let (Some(hierarchy), Some(controllers), Some(path)) = (hierarchy, controllers, path)
299 else {
300 return Err(failure(
301 CgroupMemoryProbeFailureKind::MalformedMembership,
302 source,
303 format!("invalid /proc/self/cgroup record {line:?}"),
304 ));
305 };
306 if controllers.split(',').any(|name| name == "memory") {
307 if legacy.is_some() {
308 return Err(failure(
309 CgroupMemoryProbeFailureKind::MalformedMembership,
310 source,
311 "multiple cgroup-v1 memory memberships",
312 ));
313 }
314 let path = PathBuf::from(path);
315 validate_absolute_cgroup_path(&path, source)?;
316 legacy = Some(path);
317 }
318 if hierarchy == "0" && controllers.is_empty() {
319 if unified.is_some() {
320 return Err(failure(
321 CgroupMemoryProbeFailureKind::MalformedMembership,
322 source,
323 "multiple unified cgroup-v2 memberships",
324 ));
325 }
326 let path = PathBuf::from(path);
327 validate_absolute_cgroup_path(&path, source)?;
328 unified = Some(path);
329 }
330 }
331 Ok(MemoryMembership { unified, legacy })
332 }
333
334 fn validate_absolute_cgroup_path(
335 path: &Path,
336 source: &Path,
337 ) -> Result<(), CgroupMemoryProbeFailure> {
338 if !path.is_absolute()
339 || path
340 .components()
341 .any(|component| matches!(component, Component::ParentDir))
342 {
343 return Err(failure(
344 CgroupMemoryProbeFailureKind::MalformedMembership,
345 source,
346 format!("invalid unified cgroup path {}", path.display()),
347 ));
348 }
349 Ok(())
350 }
351
352 fn select_controller_mount(
353 text: &str,
354 membership: &Path,
355 source: &Path,
356 filesystem: &str,
357 required_super_option: Option<&str>,
358 ) -> Result<ControllerMount, CgroupMemoryProbeFailure> {
359 let mut selected: Option<ControllerMount> = None;
360 for line in text.lines().filter(|line| !line.is_empty()) {
361 let Some((before_separator, after_separator)) = line.split_once(" - ") else {
362 return Err(failure(
363 CgroupMemoryProbeFailureKind::MalformedMountInfo,
364 source,
365 format!("mountinfo record has no separator: {line:?}"),
366 ));
367 };
368 let after_fields = after_separator.split_whitespace().collect::<Vec<_>>();
369 if after_fields.first().copied() != Some(filesystem) {
370 continue;
371 }
372 if let Some(required) = required_super_option
373 && !after_fields
374 .get(2)
375 .is_some_and(|options| options.split(',').any(|option| option == required))
376 {
377 continue;
378 }
379 let before_fields = before_separator.split_whitespace().collect::<Vec<_>>();
380 if before_fields.len() < 6 {
381 return Err(failure(
382 CgroupMemoryProbeFailureKind::MalformedMountInfo,
383 source,
384 format!("short {filesystem} mountinfo record: {line:?}"),
385 ));
386 }
387 let root = decode_mountinfo_path(before_fields[3], source)?;
388 let mount_point = decode_mountinfo_path(before_fields[4], source)?;
389 if !root.is_absolute() || !mount_point.is_absolute() {
390 return Err(failure(
391 CgroupMemoryProbeFailureKind::MalformedMountInfo,
392 source,
393 format!(
394 "{filesystem} mount paths must be absolute (root={}, mount_point={})",
395 root.display(),
396 mount_point.display()
397 ),
398 ));
399 }
400 if membership.strip_prefix(&root).is_err() {
401 continue;
402 }
403 let candidate = ControllerMount { root, mount_point };
404 let candidate_depth = candidate.root.components().count();
405 let selected_depth = selected
406 .as_ref()
407 .map_or(0, |mount| mount.root.components().count());
408 if selected.is_none() || candidate_depth > selected_depth {
409 selected = Some(candidate);
410 }
411 }
412 selected.ok_or_else(|| {
413 failure(
414 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
415 source,
416 format!(
417 "no {filesystem} memory mount covers process membership {}",
418 membership.display()
419 ),
420 )
421 })
422 }
423
424 fn resolve_membership_leaf(
425 membership: &Path,
426 mount: &ControllerMount,
427 source: &Path,
428 ) -> Result<PathBuf, CgroupMemoryProbeFailure> {
429 let relative = membership.strip_prefix(&mount.root).map_err(|_| {
430 failure(
431 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
432 source,
433 format!(
434 "cgroup path {} is outside selected mount root {}",
435 membership.display(),
436 mount.root.display()
437 ),
438 )
439 })?;
440 let relative = relative.strip_prefix(Path::new("/")).unwrap_or(relative);
441 Ok(mount.mount_point.join(relative))
442 }
443
444 fn decode_mountinfo_path(
445 raw: &str,
446 source: &Path,
447 ) -> Result<PathBuf, CgroupMemoryProbeFailure> {
448 let bytes = raw.as_bytes();
449 let mut decoded = Vec::with_capacity(bytes.len());
450 let mut index = 0;
451 while index < bytes.len() {
452 if bytes[index] != b'\\' {
453 decoded.push(bytes[index]);
454 index += 1;
455 continue;
456 }
457 let octal = bytes
458 .get(index + 1..index + 4)
459 .filter(|digits| digits.iter().all(|digit| (b'0'..=b'7').contains(digit)));
460 let Some(octal) = octal else {
461 return Err(failure(
462 CgroupMemoryProbeFailureKind::MalformedMountInfo,
463 source,
464 format!("invalid mountinfo path escape in {raw:?}"),
465 ));
466 };
467 let value = u16::from(octal[0] - b'0') * 64
468 + u16::from(octal[1] - b'0') * 8
469 + u16::from(octal[2] - b'0');
470 let value = u8::try_from(value).map_err(|_| {
471 failure(
472 CgroupMemoryProbeFailureKind::MalformedMountInfo,
473 source,
474 format!("mountinfo path escape exceeds one byte in {raw:?}"),
475 )
476 })?;
477 decoded.push(value);
478 index += 4;
479 }
480 Ok(PathBuf::from(OsString::from_vec(decoded)))
481 }
482
483 fn inspect_visible_v2_hierarchy(
484 leaf: &Path,
485 mount_point: &Path,
486 ) -> Result<CgroupMemoryObservation, CgroupMemoryProbeFailure> {
487 if !leaf.starts_with(mount_point) {
488 return Err(failure(
489 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
490 leaf,
491 format!("leaf is outside mount point {}", mount_point.display()),
492 ));
493 }
494 let leaf_metadata = fs::metadata(leaf).map_err(|error| io_failure(leaf, error))?;
495 if !leaf_metadata.is_dir() {
496 return Err(failure(
497 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
498 leaf,
499 "resolved process cgroup is not a directory",
500 ));
501 }
502 let mut directory = leaf.to_path_buf();
503 let mut inspected_levels = 0usize;
504 let mut binding: Option<CgroupMemoryAvailability> = None;
505 loop {
506 let max_path = directory.join("memory.max");
507 let current_path = directory.join("memory.current");
508 let stat_path = directory.join("memory.stat");
509 match read_optional(&max_path)? {
510 Some(raw_limit) => {
511 inspected_levels = inspected_levels.saturating_add(1);
512 match parse_limit(&raw_limit, &max_path)? {
513 CgroupMemoryLimit::Unlimited => {}
514 CgroupMemoryLimit::Finite(limit_bytes) => {
515 let current_before_raw =
516 read_optional(¤t_path)?.ok_or_else(|| {
517 failure(
518 CgroupMemoryProbeFailureKind::MissingCounter,
519 ¤t_path,
520 "finite memory.max requires memory.current",
521 )
522 })?;
523 let current_before = parse_counter(¤t_before_raw, ¤t_path)?;
524 let stat_raw = read_optional(&stat_path)?.ok_or_else(|| {
525 failure(
526 CgroupMemoryProbeFailureKind::MissingCounter,
527 &stat_path,
528 "finite memory.max requires memory.stat",
529 )
530 })?;
531 let inactive_file_bytes =
532 parse_stat_counter(&stat_raw, "inactive_file", &stat_path)?;
533 let current_after_raw =
539 read_optional(¤t_path)?.ok_or_else(|| {
540 failure(
541 CgroupMemoryProbeFailureKind::MissingCounter,
542 ¤t_path,
543 "memory.current disappeared during cgroup probe",
544 )
545 })?;
546 let current_after = parse_counter(¤t_after_raw, ¤t_path)?;
547 let current_bytes = current_before.max(current_after);
548 if inactive_file_bytes > current_bytes {
549 return Err(failure(
550 CgroupMemoryProbeFailureKind::InconsistentCounters,
551 &stat_path,
552 format!(
553 "inactive_file={inactive_file_bytes} exceeds memory.current={current_bytes}"
554 ),
555 ));
556 }
557 let candidate = CgroupMemoryAvailability::from_consistent_counters(
558 directory.display().to_string().into_boxed_str(),
559 limit_bytes,
560 current_bytes,
561 inactive_file_bytes,
562 0,
563 )
564 .ok_or_else(|| {
565 failure(
566 CgroupMemoryProbeFailureKind::InconsistentCounters,
567 &stat_path,
568 "memory counters became inconsistent during construction",
569 )
570 })?;
571 if binding.as_ref().map_or(true, |current| {
572 candidate.available_bytes() < current.available_bytes()
573 }) {
574 binding = Some(candidate);
575 }
576 }
577 }
578 }
579 None => {
580 let has_current = read_optional(¤t_path)?.is_some();
581 let has_stat = read_optional(&stat_path)?.is_some();
582 if directory != mount_point && (has_current || has_stat) {
588 return Err(failure(
589 CgroupMemoryProbeFailureKind::MissingCounter,
590 &max_path,
591 "memory controller files are present but memory.max is missing",
592 ));
593 }
594 }
595 }
596 if directory == mount_point {
597 break;
598 }
599 let Some(parent) = directory.parent() else {
600 return Err(failure(
601 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
602 &directory,
603 "cgroup hierarchy ended before its mount point",
604 ));
605 };
606 directory = parent.to_path_buf();
607 }
608 if let Some(mut binding) = binding {
609 binding.inspected_levels = inspected_levels;
610 Ok(CgroupMemoryObservation::V2Limited(binding))
611 } else {
612 Ok(CgroupMemoryObservation::V2Unbounded {
613 cgroup_path: leaf.display().to_string().into_boxed_str(),
614 inspected_levels,
615 })
616 }
617 }
618
619 fn inspect_visible_v1_hierarchy(
620 leaf: &Path,
621 mount_point: &Path,
622 ) -> Result<CgroupMemoryObservation, CgroupMemoryProbeFailure> {
623 if !leaf.starts_with(mount_point) {
624 return Err(failure(
625 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
626 leaf,
627 format!("leaf is outside mount point {}", mount_point.display()),
628 ));
629 }
630 let leaf_metadata = fs::metadata(leaf).map_err(|error| io_failure(leaf, error))?;
631 if !leaf_metadata.is_dir() {
632 return Err(failure(
633 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
634 leaf,
635 "resolved process cgroup is not a directory",
636 ));
637 }
638
639 let hierarchy_path = leaf.join("memory.use_hierarchy");
640 let hierarchy_raw = read_required(&hierarchy_path)?;
641 let hierarchical = match parse_counter(&hierarchy_raw, &hierarchy_path)? {
642 0 => false,
643 1 => true,
644 value => {
645 return Err(failure(
646 CgroupMemoryProbeFailureKind::InvalidCounter,
647 &hierarchy_path,
648 format!("memory.use_hierarchy must be 0 or 1, got {value}"),
649 ));
650 }
651 };
652
653 let mut directory = leaf.to_path_buf();
654 let mut inspected_levels = 0usize;
655 let mut binding: Option<CgroupMemoryAvailability> = None;
656 loop {
657 let limit_path = directory.join("memory.limit_in_bytes");
658 let usage_path = directory.join("memory.usage_in_bytes");
659 let stat_path = directory.join("memory.stat");
660 let limit_bytes = parse_counter(&read_required(&limit_path)?, &limit_path)?;
661 let usage_before = parse_counter(&read_required(&usage_path)?, &usage_path)?;
662 let stat_raw = read_required(&stat_path)?;
663 let inactive_key = if hierarchical {
664 "total_inactive_file"
665 } else {
666 "inactive_file"
667 };
668 let inactive_file_bytes = parse_stat_counter(&stat_raw, inactive_key, &stat_path)?;
669 let usage_after = parse_counter(&read_required(&usage_path)?, &usage_path)?;
670 let current_bytes = usage_before.max(usage_after);
671 if inactive_file_bytes > current_bytes {
672 return Err(failure(
673 CgroupMemoryProbeFailureKind::InconsistentCounters,
674 &stat_path,
675 format!(
676 "{inactive_key}={inactive_file_bytes} exceeds memory.usage_in_bytes={current_bytes}"
677 ),
678 ));
679 }
680 inspected_levels = inspected_levels.saturating_add(1);
681 let candidate = CgroupMemoryAvailability::from_consistent_counters(
682 directory.display().to_string().into_boxed_str(),
683 limit_bytes,
684 current_bytes,
685 inactive_file_bytes,
686 0,
687 )
688 .ok_or_else(|| {
689 failure(
690 CgroupMemoryProbeFailureKind::InconsistentCounters,
691 &stat_path,
692 "memory counters became inconsistent during construction",
693 )
694 })?;
695 if binding
696 .as_ref()
697 .is_none_or(|current| candidate.available_bytes() < current.available_bytes())
698 {
699 binding = Some(candidate);
700 }
701
702 if !hierarchical || directory == mount_point {
703 break;
704 }
705 let Some(parent) = directory.parent() else {
706 return Err(failure(
707 CgroupMemoryProbeFailureKind::MissingUnifiedMount,
708 &directory,
709 "cgroup hierarchy ended before its mount point",
710 ));
711 };
712 directory = parent.to_path_buf();
713 }
714
715 let mut binding = binding.ok_or_else(|| {
716 failure(
717 CgroupMemoryProbeFailureKind::MissingCounter,
718 leaf,
719 "cgroup-v1 memory hierarchy exposed no accounting level",
720 )
721 })?;
722 binding.inspected_levels = inspected_levels;
723 Ok(CgroupMemoryObservation::V1Limited(binding))
724 }
725
726 fn parse_limit(
727 raw: &str,
728 source: &Path,
729 ) -> Result<CgroupMemoryLimit, CgroupMemoryProbeFailure> {
730 let value = raw.trim();
731 if value == "max" {
732 return Ok(CgroupMemoryLimit::Unlimited);
733 }
734 let bytes = value.parse::<u64>().map_err(|_| {
735 failure(
736 CgroupMemoryProbeFailureKind::InvalidLimit,
737 source,
738 format!("expected literal max or an unsigned byte count, got {value:?}"),
739 )
740 })?;
741 Ok(CgroupMemoryLimit::Finite(bytes))
742 }
743
744 fn parse_counter(raw: &str, source: &Path) -> Result<u64, CgroupMemoryProbeFailure> {
745 let value = raw.trim();
746 value.parse::<u64>().map_err(|_| {
747 failure(
748 CgroupMemoryProbeFailureKind::InvalidCounter,
749 source,
750 format!("expected an unsigned byte count, got {value:?}"),
751 )
752 })
753 }
754
755 fn parse_stat_counter(
756 raw: &str,
757 key: &str,
758 source: &Path,
759 ) -> Result<u64, CgroupMemoryProbeFailure> {
760 for line in raw.lines() {
761 let mut fields = line.split_whitespace();
762 let Some(name) = fields.next() else {
763 continue;
764 };
765 if name != key {
766 continue;
767 }
768 let Some(value) = fields.next() else {
769 return Err(failure(
770 CgroupMemoryProbeFailureKind::InvalidCounter,
771 source,
772 format!("memory.stat key {key:?} has no value"),
773 ));
774 };
775 if fields.next().is_some() {
776 return Err(failure(
777 CgroupMemoryProbeFailureKind::InvalidCounter,
778 source,
779 format!("memory.stat key {key:?} has trailing fields"),
780 ));
781 }
782 return value.parse::<u64>().map_err(|_| {
783 failure(
784 CgroupMemoryProbeFailureKind::InvalidCounter,
785 source,
786 format!("memory.stat key {key:?} is not an unsigned byte count"),
787 )
788 });
789 }
790 Err(failure(
791 CgroupMemoryProbeFailureKind::MissingCounter,
792 source,
793 format!("memory.stat is missing required key {key:?}"),
794 ))
795 }
796
797 fn read_required(path: &Path) -> Result<String, CgroupMemoryProbeFailure> {
798 fs::read_to_string(path).map_err(|error| io_failure(path, error))
799 }
800
801 fn read_optional(path: &Path) -> Result<Option<String>, CgroupMemoryProbeFailure> {
802 match fs::read_to_string(path) {
803 Ok(value) => Ok(Some(value)),
804 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
805 Err(error) => Err(io_failure(path, error)),
806 }
807 }
808
809 fn io_failure(path: &Path, error: io::Error) -> CgroupMemoryProbeFailure {
810 failure(CgroupMemoryProbeFailureKind::Io, path, error.to_string())
811 }
812
813 fn failure(
814 kind: CgroupMemoryProbeFailureKind,
815 path: &Path,
816 detail: impl Into<Box<str>>,
817 ) -> CgroupMemoryProbeFailure {
818 CgroupMemoryProbeFailure::new(kind, path.display().to_string().into_boxed_str(), detail)
819 }
820
821 #[cfg(test)]
822 mod tests {
823 use super::*;
824 use std::fs;
825 use tempfile::TempDir;
826
827 struct Fixture {
828 _temp: TempDir,
829 mount: PathBuf,
830 cgroup_file: PathBuf,
831 mountinfo_file: PathBuf,
832 }
833
834 impl Fixture {
835 fn new(membership: &str) -> Self {
836 let temp = TempDir::new().expect("fixture tempdir");
837 let mount = temp.path().join("cgroup2");
838 fs::create_dir_all(&mount).expect("fixture mount");
839 let cgroup_file = temp.path().join("self.cgroup");
840 fs::write(&cgroup_file, format!("0::{membership}\n")).expect("fixture membership");
841 let mountinfo_file = temp.path().join("self.mountinfo");
842 fs::write(
843 &mountinfo_file,
844 format!(
845 "29 23 0:26 / {} rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n",
846 mount.display()
847 ),
848 )
849 .expect("fixture mountinfo");
850 Self {
851 _temp: temp,
852 mount,
853 cgroup_file,
854 mountinfo_file,
855 }
856 }
857
858 fn level(&self, relative: &str, limit: &str, current: u64, inactive: u64) {
859 let directory = self.mount.join(relative.trim_start_matches('/'));
860 fs::create_dir_all(&directory).expect("fixture level");
861 fs::write(directory.join("memory.max"), format!("{limit}\n"))
862 .expect("fixture memory.max");
863 fs::write(directory.join("memory.current"), format!("{current}\n"))
864 .expect("fixture memory.current");
865 fs::write(
866 directory.join("memory.stat"),
867 format!("anon 1\ninactive_file {inactive}\nactive_file 2\n"),
868 )
869 .expect("fixture memory.stat");
870 }
871
872 fn observe(&self) -> CgroupMemoryObservation {
873 detect_from_proc_files(&self.cgroup_file, &self.mountinfo_file)
874 .unwrap_or_else(CgroupMemoryObservation::ProbeFailed)
875 }
876 }
877
878 #[test]
879 fn literal_max_is_typed_unbounded_even_when_current_is_large() {
880 let fixture = Fixture::new("/tenant/leaf");
881 fixture.level("tenant/leaf", "max", u64::MAX - 1, 0);
882 assert!(matches!(
883 fixture.observe(),
884 CgroupMemoryObservation::V2Unbounded {
885 inspected_levels: 1,
886 ..
887 }
888 ));
889 }
890
891 #[test]
892 fn cgroup_v2_root_accounting_without_memory_max_is_unbounded() {
893 let fixture = Fixture::new("/");
894 fs::write(fixture.mount.join("memory.current"), "1000\n").expect("root current");
895 fs::write(fixture.mount.join("memory.stat"), "inactive_file 700\n").expect("root stat");
896 assert!(matches!(
897 fixture.observe(),
898 CgroupMemoryObservation::V2Unbounded {
899 inspected_levels: 0,
900 ..
901 }
902 ));
903 }
904
905 #[test]
906 fn finite_zero_remains_authoritative() {
907 let fixture = Fixture::new("/tenant/leaf");
908 fixture.level("tenant/leaf", "0", 0, 0);
909 let CgroupMemoryObservation::V2Limited(observation) = fixture.observe() else {
910 panic!("finite zero must be a real cgroup ceiling");
911 };
912 assert_eq!(observation.limit_bytes(), 0);
913 assert_eq!(observation.available_bytes(), 0);
914 }
915
916 #[test]
917 fn malformed_active_controller_fails_closed() {
918 let fixture = Fixture::new("/tenant/leaf");
919 fixture.level("tenant/leaf", "1000", 500, 100);
920 fs::write(
921 fixture.mount.join("tenant/leaf/memory.current"),
922 "not-a-counter\n",
923 )
924 .expect("corrupt current");
925 let CgroupMemoryObservation::ProbeFailed(failure) = fixture.observe() else {
926 panic!("malformed active controller must fail closed");
927 };
928 assert_eq!(failure.kind(), CgroupMemoryProbeFailureKind::InvalidCounter);
929 }
930
931 #[test]
932 fn inconsistent_cache_counter_fails_closed() {
933 let fixture = Fixture::new("/tenant/leaf");
934 fixture.level("tenant/leaf", "1000", 100, 101);
935 let CgroupMemoryObservation::ProbeFailed(failure) = fixture.observe() else {
936 panic!("inconsistent controller counters must fail closed");
937 };
938 assert_eq!(
939 failure.kind(),
940 CgroupMemoryProbeFailureKind::InconsistentCounters
941 );
942 }
943
944 #[test]
945 fn hybrid_hierarchy_uses_the_active_v1_memory_controller() {
946 let fixture = Fixture::new("/tenant/leaf");
947 let v1_mount = fixture.mount.parent().unwrap().join("cgroup-memory");
948 let v1_leaf = v1_mount.join("legacy");
949 fs::create_dir_all(&v1_leaf).expect("v1 leaf");
950 fs::write(v1_leaf.join("memory.use_hierarchy"), "0\n").expect("hierarchy");
951 fs::write(v1_leaf.join("memory.limit_in_bytes"), "2048\n").expect("limit");
952 fs::write(v1_leaf.join("memory.usage_in_bytes"), "1024\n").expect("usage");
953 fs::write(
954 v1_leaf.join("memory.stat"),
955 "inactive_file 256\ntotal_inactive_file 256\n",
956 )
957 .expect("stat");
958 fs::write(&fixture.cgroup_file, "0::/tenant/leaf\n4:memory:/legacy\n")
959 .expect("hybrid membership");
960 let original = fs::read_to_string(&fixture.mountinfo_file).expect("mountinfo");
961 fs::write(
962 &fixture.mountinfo_file,
963 format!(
964 "{original}31 23 0:30 / {} rw - cgroup cgroup rw,memory\n",
965 v1_mount.display()
966 ),
967 )
968 .expect("hybrid mountinfo");
969 let CgroupMemoryObservation::V1Limited(observation) = fixture.observe() else {
970 panic!("hybrid memory accounting must follow v1");
971 };
972 assert_eq!(observation.available_bytes(), 1280);
973 }
974 }
975}
976
977#[cfg(target_os = "linux")]
978pub(crate) fn detect_cgroup_memory() -> CgroupMemoryObservation {
979 linux::detect()
980}
981
982#[cfg(all(test, not(target_os = "linux")))]
983mod non_linux_tests {
984 use super::*;
985
986 #[test]
987 fn non_linux_platform_has_no_cgroup_controller() {
988 assert_eq!(detect_cgroup_memory(), CgroupMemoryObservation::NotPresent);
989 }
990}