1use std::fmt;
27use std::io;
28use std::path::Path;
29#[cfg(any(target_os = "linux", test))]
30use std::path::PathBuf;
31
32use sysinfo::System;
33
34use crate::RuntimeContractPolicy;
35use crate::RuntimeContractViolation;
36use crate::RuntimeError;
37use crate::RuntimeResult;
38
39const EXPLICIT_MEMORY_LIMIT_ENV: &str = "ROCKETMQ_PROCESS_MEMORY_LIMIT_BYTES";
40#[cfg(any(target_os = "linux", test))]
41const CGROUP_MEMBERSHIP_PATH: &str = "/proc/self/cgroup";
42#[cfg(any(target_os = "linux", test))]
43const CGROUP_MOUNT_PATH: &str = "/proc/self/mountinfo";
44#[cfg(any(target_os = "linux", test))]
45const CGROUP_V2_HARD_LIMIT_FILE: &str = "memory.max";
46#[cfg(any(target_os = "linux", test))]
47const CGROUP_V1_HARD_LIMIT_FILE: &str = "memory.limit_in_bytes";
48#[cfg(any(target_os = "linux", test))]
53const CGROUP_V1_UNLIMITED_SENTINEL: u64 = 0x7FFF_FFFF_FFFF_F000;
54
55pub trait MemoryFileView: fmt::Debug + Send + Sync + 'static {
61 fn read_optional(&self, path: &Path) -> RuntimeResult<Option<String>>;
68}
69
70#[derive(Debug, Default, Clone, Copy)]
72pub struct HostMemoryFileView;
73
74impl MemoryFileView for HostMemoryFileView {
75 fn read_optional(&self, path: &Path) -> RuntimeResult<Option<String>> {
76 match std::fs::read_to_string(path) {
77 Ok(value) => Ok(Some(value)),
78 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
79 Err(error) => Err(RuntimeError::io(
80 crate::RuntimeOperation::DetectProcessMemoryLimit,
81 error,
82 )),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum MemoryLimitSource {
90 Configured,
92 Environment,
94 CgroupV2,
96 CgroupV1,
98 HostPhysicalMemory,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct ProcessMemoryLimit {
105 bytes: u64,
106 source: MemoryLimitSource,
107}
108
109impl ProcessMemoryLimit {
110 pub fn detect() -> RuntimeResult<Self> {
117 Self::detect_with_view(&HostMemoryFileView)
118 }
119
120 pub fn detect_with_view(view: &dyn MemoryFileView) -> RuntimeResult<Self> {
135 if let Some(value) = std::env::var_os(EXPLICIT_MEMORY_LIMIT_ENV) {
136 let value = value.to_string_lossy();
137 let bytes = parse_positive_bytes(&value)
138 .ok_or_else(|| RuntimeError::configuration(crate::RuntimeOperation::DetectProcessMemoryLimit))?;
139 return Ok(Self {
140 bytes,
141 source: MemoryLimitSource::Environment,
142 });
143 }
144
145 detect_platform_limit(view)
146 }
147
148 pub fn configured(bytes: u64) -> Result<Self, RuntimeContractViolation> {
154 if bytes == 0 {
155 return Err(RuntimeContractViolation::InvalidMemoryLimit {
156 policy: RuntimeContractPolicy::ConfiguredMemoryLimitPositive,
157 });
158 }
159 Ok(Self {
160 bytes,
161 source: MemoryLimitSource::Configured,
162 })
163 }
164
165 #[must_use]
166 pub const fn bytes(self) -> u64 {
168 self.bytes
169 }
170
171 #[must_use]
172 pub const fn source(self) -> MemoryLimitSource {
174 self.source
175 }
176
177 pub fn fraction(self, numerator: u64, denominator: u64) -> Result<u64, RuntimeContractViolation> {
184 if numerator == 0 || denominator == 0 || numerator > denominator {
185 return Err(RuntimeContractViolation::InvalidMemoryLimit {
186 policy: RuntimeContractPolicy::MemoryFractionPositiveAndBounded,
187 });
188 }
189 let bytes = (u128::from(self.bytes) * u128::from(numerator)) / u128::from(denominator);
190 Ok(bytes as u64)
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum ManagedMemoryRequest {
197 #[default]
199 WholeLimit,
200 ExplicitBytes(u64),
202 Fraction {
204 numerator: u64,
206 denominator: u64,
208 },
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub struct ManagedMemoryPolicy {
223 request: ManagedMemoryRequest,
224 headroom_bytes: u64,
225}
226
227impl ManagedMemoryPolicy {
228 #[must_use]
230 pub const fn whole_limit() -> Self {
231 Self {
232 request: ManagedMemoryRequest::WholeLimit,
233 headroom_bytes: 0,
234 }
235 }
236
237 pub fn explicit_bytes(bytes: u64) -> Result<Self, RuntimeContractViolation> {
244 if bytes == 0 {
245 return Err(RuntimeContractViolation::InvalidMemoryLimit {
246 policy: RuntimeContractPolicy::ManagedMemoryBudgetPositive,
247 });
248 }
249 Ok(Self {
250 request: ManagedMemoryRequest::ExplicitBytes(bytes),
251 headroom_bytes: 0,
252 })
253 }
254
255 pub fn fraction(numerator: u64, denominator: u64) -> Result<Self, RuntimeContractViolation> {
262 if numerator == 0 || denominator == 0 || numerator > denominator {
263 return Err(RuntimeContractViolation::InvalidMemoryLimit {
264 policy: RuntimeContractPolicy::ManagedMemoryRatioPositiveAndBounded,
265 });
266 }
267 Ok(Self {
268 request: ManagedMemoryRequest::Fraction { numerator, denominator },
269 headroom_bytes: 0,
270 })
271 }
272
273 #[must_use]
275 pub const fn with_headroom(mut self, headroom_bytes: u64) -> Self {
276 self.headroom_bytes = headroom_bytes;
277 self
278 }
279
280 #[must_use]
282 pub const fn request(self) -> ManagedMemoryRequest {
283 self.request
284 }
285
286 #[must_use]
288 pub const fn headroom_bytes(self) -> u64 {
289 self.headroom_bytes
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct ManagedMemoryBudget {
300 detected: ProcessMemoryLimit,
301 effective_limit_bytes: u64,
302 requested_managed_bytes: u64,
303 managed_bytes: u64,
304}
305
306impl ManagedMemoryBudget {
307 pub fn resolve(
315 detected: ProcessMemoryLimit,
316 configured: Option<ProcessMemoryLimit>,
317 policy: ManagedMemoryPolicy,
318 ) -> Result<Self, RuntimeContractViolation> {
319 let effective_limit_bytes = configured.map_or(detected.bytes(), |limit| detected.bytes().min(limit.bytes()));
320 let requested_managed_bytes = match policy.request {
321 ManagedMemoryRequest::WholeLimit => effective_limit_bytes,
322 ManagedMemoryRequest::ExplicitBytes(bytes) => bytes,
323 ManagedMemoryRequest::Fraction { numerator, denominator } => {
324 if numerator == 0 || denominator == 0 || numerator > denominator {
325 return Err(RuntimeContractViolation::InvalidMemoryLimit {
326 policy: RuntimeContractPolicy::ManagedMemoryRatioPositiveAndBounded,
327 });
328 }
329 u64::try_from(u128::from(effective_limit_bytes) * u128::from(numerator) / u128::from(denominator))
330 .unwrap_or(u64::MAX)
331 }
332 };
333 let after_headroom = effective_limit_bytes.checked_sub(policy.headroom_bytes).ok_or(
334 RuntimeContractViolation::InvalidMemoryLimit {
335 policy: RuntimeContractPolicy::ManagedMemoryHeadroomBelowEffectiveLimit,
336 },
337 )?;
338 let managed_bytes = requested_managed_bytes.min(after_headroom);
339 if managed_bytes == 0 {
340 return Err(RuntimeContractViolation::InvalidMemoryLimit {
341 policy: RuntimeContractPolicy::ManagedMemoryBudgetPositive,
342 });
343 }
344 Ok(Self {
345 detected,
346 effective_limit_bytes,
347 requested_managed_bytes,
348 managed_bytes,
349 })
350 }
351
352 #[must_use]
354 pub const fn detected(self) -> ProcessMemoryLimit {
355 self.detected
356 }
357
358 #[must_use]
360 pub const fn effective_limit_bytes(self) -> u64 {
361 self.effective_limit_bytes
362 }
363
364 #[must_use]
366 pub const fn requested_managed_bytes(self) -> u64 {
367 self.requested_managed_bytes
368 }
369
370 #[must_use]
372 pub const fn managed_bytes(self) -> u64 {
373 self.managed_bytes
374 }
375}
376
377fn host_physical_memory() -> Option<u64> {
378 let mut system = System::new();
379 system.refresh_memory();
380 let bytes = system.total_memory();
381 (bytes > 0).then_some(bytes)
382}
383
384#[cfg(any(target_os = "linux", test))]
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386enum CgroupHierarchy {
387 V2,
389 V1Memory,
391}
392
393#[cfg(any(target_os = "linux", test))]
394#[derive(Debug, Clone, PartialEq, Eq)]
395struct CgroupMembership {
396 controllers: Vec<String>,
397 path: PathBuf,
398}
399
400#[cfg(any(target_os = "linux", test))]
401#[derive(Debug, Clone, PartialEq, Eq)]
402struct CgroupMount {
403 root: PathBuf,
404 mount_point: PathBuf,
405 hierarchy: CgroupHierarchy,
406}
407
408#[cfg(any(target_os = "linux", test))]
413fn parse_cgroup_membership(content: &str) -> Vec<CgroupMembership> {
414 content
415 .lines()
416 .filter_map(|line| {
417 let mut fields = line.trim().splitn(3, ':');
418 let _hierarchy = fields.next()?;
419 let controllers = fields.next()?;
420 let path = fields.next()?;
421 if path.is_empty() {
422 return None;
423 }
424 Some(CgroupMembership {
425 controllers: controllers
426 .split(',')
427 .filter(|controller| !controller.is_empty())
428 .map(str::to_string)
429 .collect(),
430 path: PathBuf::from(path),
431 })
432 })
433 .collect()
434}
435
436#[cfg(any(target_os = "linux", test))]
442fn parse_cgroup_mounts(content: &str) -> Vec<CgroupMount> {
443 content
444 .lines()
445 .filter_map(|line| {
446 let fields = line.split_ascii_whitespace().collect::<Vec<_>>();
447 let separator = fields.iter().position(|field| *field == "-")?;
448 let fs_type = *fields.get(separator + 1)?;
449 let super_options = *fields.get(separator + 3)?;
450 let hierarchy = match fs_type {
451 "cgroup2" => CgroupHierarchy::V2,
452 "cgroup" if super_options.split(',').any(|option| option == "memory") => CgroupHierarchy::V1Memory,
453 _ => return None,
454 };
455 Some(CgroupMount {
456 root: PathBuf::from(unescape_mount_field(fields.get(3)?)),
457 mount_point: PathBuf::from(unescape_mount_field(fields.get(4)?)),
458 hierarchy,
459 })
460 })
461 .collect()
462}
463
464#[cfg(any(target_os = "linux", test))]
467fn unescape_mount_field(value: &str) -> String {
468 let characters = value.chars().collect::<Vec<_>>();
469 let mut decoded = String::with_capacity(value.len());
470 let mut index = 0;
471 while index < characters.len() {
472 if characters[index] == '\\' && index + 3 < characters.len() {
473 let digits = characters[index + 1..=index + 3].iter().collect::<String>();
474 if digits.bytes().all(|byte| (b'0'..=b'7').contains(&byte)) {
475 if let Ok(byte) = u8::from_str_radix(&digits, 8) {
476 decoded.push(byte as char);
477 index += 4;
478 continue;
479 }
480 }
481 }
482 decoded.push(characters[index]);
483 index += 1;
484 }
485 decoded
486}
487
488#[cfg(any(target_os = "linux", test))]
494fn visible_membership_paths(mount: &CgroupMount, membership: &Path) -> Vec<PathBuf> {
495 let Ok(relative) = membership.strip_prefix(&mount.root) else {
496 return Vec::new();
497 };
498 let mut paths = vec![mount.mount_point.join(relative)];
499 let mut current = relative;
500 while let Some(parent) = current.parent() {
501 if parent.as_os_str().is_empty() {
502 paths.push(mount.mount_point.clone());
503 break;
504 }
505 paths.push(mount.mount_point.join(parent));
506 current = parent;
507 }
508 paths
509}
510
511#[cfg(any(target_os = "linux", test))]
513fn mount_applies(mount: &CgroupMount, membership: &CgroupMembership) -> bool {
514 if !membership.path.starts_with(&mount.root) {
515 return false;
516 }
517 match mount.hierarchy {
518 CgroupHierarchy::V2 => membership.controllers.is_empty(),
519 CgroupHierarchy::V1Memory => membership.controllers.iter().any(|controller| controller == "memory"),
520 }
521}
522
523#[cfg(any(target_os = "linux", test))]
528fn cgroup_hard_limit(view: &dyn MemoryFileView, mount: &CgroupMount, membership: &Path) -> RuntimeResult<Option<u64>> {
529 let file = match mount.hierarchy {
530 CgroupHierarchy::V2 => CGROUP_V2_HARD_LIMIT_FILE,
531 CgroupHierarchy::V1Memory => CGROUP_V1_HARD_LIMIT_FILE,
532 };
533 let mut limit: Option<u64> = None;
534 for directory in visible_membership_paths(mount, membership) {
535 let Some(content) = view.read_optional(&directory.join(file))? else {
536 continue;
537 };
538 let Some(bytes) = parse_cgroup_hard_limit(mount.hierarchy, &content) else {
539 continue;
540 };
541 limit = Some(limit.map_or(bytes, |current: u64| current.min(bytes)));
542 }
543 Ok(limit)
544}
545
546#[cfg(any(target_os = "linux", test))]
547fn parse_cgroup_hard_limit(hierarchy: CgroupHierarchy, content: &str) -> Option<u64> {
548 let bytes = parse_positive_bytes(content)?;
551 match hierarchy {
552 CgroupHierarchy::V2 => Some(bytes),
553 CgroupHierarchy::V1Memory => (bytes < CGROUP_V1_UNLIMITED_SENTINEL).then_some(bytes),
554 }
555}
556
557#[cfg(any(target_os = "linux", test))]
559fn discover_cgroup_limit(view: &dyn MemoryFileView) -> RuntimeResult<Option<ProcessMemoryLimit>> {
560 let Some(membership_content) = view.read_optional(Path::new(CGROUP_MEMBERSHIP_PATH))? else {
561 return Ok(None);
562 };
563 let Some(mount_content) = view.read_optional(Path::new(CGROUP_MOUNT_PATH))? else {
564 return Ok(None);
565 };
566 let memberships = parse_cgroup_membership(&membership_content);
567 let mounts = parse_cgroup_mounts(&mount_content);
568 let mut discovered: Option<(u64, MemoryLimitSource)> = None;
569 for membership in &memberships {
570 for mount in &mounts {
571 if !mount_applies(mount, membership) {
572 continue;
573 }
574 let source = match mount.hierarchy {
575 CgroupHierarchy::V2 => MemoryLimitSource::CgroupV2,
576 CgroupHierarchy::V1Memory => MemoryLimitSource::CgroupV1,
577 };
578 let Some(bytes) = cgroup_hard_limit(view, mount, &membership.path)? else {
579 continue;
580 };
581 discovered = Some(match discovered {
582 Some(current) if current.0 <= bytes => current,
583 _ => (bytes, source),
584 });
585 }
586 }
587 Ok(discovered.map(|(bytes, source)| ProcessMemoryLimit { bytes, source }))
588}
589
590fn detect_platform_limit(view: &dyn MemoryFileView) -> RuntimeResult<ProcessMemoryLimit> {
591 let host = host_physical_memory().map(|bytes| ProcessMemoryLimit {
592 bytes,
593 source: MemoryLimitSource::HostPhysicalMemory,
594 });
595 #[cfg(any(target_os = "linux", test))]
596 let cgroup = discover_cgroup_limit(view)?;
597 #[cfg(not(any(target_os = "linux", test)))]
598 let cgroup: Option<ProcessMemoryLimit> = {
599 let _ = view;
603 None
604 };
605
606 [cgroup, host]
607 .into_iter()
608 .flatten()
609 .min_by_key(|limit| limit.bytes)
610 .ok_or_else(|| {
611 RuntimeError::internal(
612 crate::RuntimeOperation::DetectProcessMemoryLimit,
613 MemoryLimitDiscoveryFailure::Unavailable,
614 )
615 })
616}
617
618#[derive(Debug, thiserror::Error)]
619enum MemoryLimitDiscoveryFailure {
620 #[error("no finite process, cgroup, or host memory limit is available")]
621 Unavailable,
622}
623
624fn parse_positive_bytes(value: &str) -> Option<u64> {
625 value.trim().parse::<u64>().ok().filter(|bytes| *bytes > 0)
626}
627
628#[cfg(all(target_os = "linux", test))]
629fn parse_meminfo_bytes(value: &str) -> Option<u64> {
630 let line = value.lines().find(|line| line.starts_with("MemTotal:"))?;
631 let kibibytes = line.split_ascii_whitespace().nth(1)?.parse::<u64>().ok()?;
632 kibibytes.checked_mul(1024).filter(|bytes| *bytes > 0)
633}
634
635#[cfg(test)]
636mod tests {
637 use std::collections::HashMap;
638
639 use super::*;
640
641 #[derive(Debug, Default)]
643 struct FixtureFileView {
644 files: HashMap<PathBuf, String>,
645 unreadable: Vec<PathBuf>,
646 }
647
648 impl FixtureFileView {
649 fn with_files(files: &[(&str, &str)]) -> Self {
650 Self {
651 files: files
652 .iter()
653 .map(|(path, content)| (PathBuf::from(*path), (*content).to_string()))
654 .collect(),
655 unreadable: Vec::new(),
656 }
657 }
658
659 fn with_unreadable(mut self, path: &str) -> Self {
660 self.unreadable.push(PathBuf::from(path));
661 self
662 }
663 }
664
665 impl MemoryFileView for FixtureFileView {
666 fn read_optional(&self, path: &Path) -> RuntimeResult<Option<String>> {
667 if self.unreadable.iter().any(|candidate| candidate == path) {
668 return Err(RuntimeError::io(
669 crate::RuntimeOperation::DetectProcessMemoryLimit,
670 io::Error::new(io::ErrorKind::PermissionDenied, "injected unreadable constraint"),
671 ));
672 }
673 Ok(self.files.get(path).cloned())
674 }
675 }
676
677 const V2_MOUNT: &str = "29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw\n";
678 const GIB: u64 = 1024 * 1024 * 1024;
679
680 fn limit(bytes: u64, source: MemoryLimitSource) -> ProcessMemoryLimit {
681 ProcessMemoryLimit { bytes, source }
682 }
683
684 #[test]
685 fn fractions_remain_bounded_without_multiplication_overflow() {
686 let limit = ProcessMemoryLimit::configured(u64::MAX).expect("configured limit");
687 assert_eq!(limit.fraction(1, 4).expect("quarter"), u64::MAX / 4);
688 assert_eq!(limit.fraction(4, 4).expect("whole"), u64::MAX);
689 assert!(limit.fraction(0, 4).is_err());
690 assert!(limit.fraction(5, 4).is_err());
691 }
692
693 #[test]
694 fn platform_memory_detection_returns_a_positive_limit() {
695 let limit = ProcessMemoryLimit::detect().expect("test host must expose a process memory limit");
696
697 assert!(limit.bytes() > 0);
698 }
699
700 #[test]
701 fn the_default_policy_charges_the_whole_detected_limit() {
702 let detected = limit(8 * GIB, MemoryLimitSource::HostPhysicalMemory);
703
704 let budget = ManagedMemoryBudget::resolve(detected, None, ManagedMemoryPolicy::default())
705 .expect("the whole-limit policy resolves");
706
707 assert_eq!(budget.detected(), detected);
708 assert_eq!(budget.effective_limit_bytes(), 8 * GIB);
709 assert_eq!(budget.requested_managed_bytes(), 8 * GIB);
710 assert_eq!(budget.managed_bytes(), 8 * GIB);
711 assert_eq!(ManagedMemoryPolicy::default().headroom_bytes(), 0);
712 }
713
714 #[test]
715 fn the_managed_budget_honours_a_ratio_headroom_and_an_explicit_limit() {
716 let detected = limit(8 * GIB, MemoryLimitSource::CgroupV2);
717 let configured = ProcessMemoryLimit::configured(4 * GIB).expect("configured limit");
718
719 let fraction = ManagedMemoryPolicy::fraction(1, 2)
720 .expect("a half is bounded")
721 .with_headroom(GIB);
722 let budget = ManagedMemoryBudget::resolve(detected, None, fraction).expect("ratio budget resolves");
723 assert_eq!(budget.effective_limit_bytes(), 8 * GIB);
724 assert_eq!(budget.requested_managed_bytes(), 4 * GIB);
725 assert_eq!(budget.managed_bytes(), 4 * GIB);
726
727 let tightened =
729 ManagedMemoryBudget::resolve(detected, Some(configured), fraction).expect("the tightened budget resolves");
730 assert_eq!(tightened.effective_limit_bytes(), 4 * GIB);
731 assert_eq!(tightened.requested_managed_bytes(), 2 * GIB);
732 assert_eq!(tightened.managed_bytes(), 2 * GIB);
733
734 let explicit = ManagedMemoryPolicy::explicit_bytes(GIB).expect("a positive request is valid");
735 let budget = ManagedMemoryBudget::resolve(detected, None, explicit).expect("explicit budget resolves");
736 assert_eq!(budget.requested_managed_bytes(), GIB);
737 assert_eq!(budget.managed_bytes(), GIB);
738 }
739
740 #[test]
741 fn headroom_bounds_the_requested_managed_bytes() {
742 let detected = limit(2 * GIB, MemoryLimitSource::CgroupV2);
743
744 let budget = ManagedMemoryBudget::resolve(
745 detected,
746 None,
747 ManagedMemoryPolicy::whole_limit().with_headroom(1536 * 1024 * 1024),
748 )
749 .expect("headroom below the limit resolves");
750
751 assert_eq!(budget.requested_managed_bytes(), 2 * GIB);
752 assert_eq!(budget.managed_bytes(), 512 * 1024 * 1024);
753 }
754
755 #[test]
756 fn an_unrepresentable_managed_memory_policy_is_rejected() {
757 let detected = limit(8 * GIB, MemoryLimitSource::HostPhysicalMemory);
758
759 assert!(ManagedMemoryPolicy::fraction(0, 8).is_err());
760 assert!(ManagedMemoryPolicy::fraction(1, 0).is_err());
761 assert!(ManagedMemoryPolicy::fraction(9, 8).is_err());
762 assert!(ManagedMemoryPolicy::explicit_bytes(0).is_err());
763
764 let headroom_greater_than_limit = ManagedMemoryBudget::resolve(
765 detected,
766 None,
767 ManagedMemoryPolicy::whole_limit().with_headroom(9 * GIB),
768 )
769 .expect_err("headroom above the effective limit cannot be represented");
770 assert_eq!(
771 headroom_greater_than_limit.to_string(),
772 "process memory limit violates managed-memory-headroom-below-effective-limit"
773 );
774
775 let zero_budget = ManagedMemoryBudget::resolve(
776 detected,
777 None,
778 ManagedMemoryPolicy::whole_limit().with_headroom(8 * GIB),
779 )
780 .expect_err("a zero managed budget must be rejected");
781 assert_eq!(
782 zero_budget.to_string(),
783 "process memory limit violates managed-memory-budget-positive"
784 );
785 }
786
787 #[test]
788 fn a_nested_cgroup_limit_is_discovered_through_the_membership_path() {
789 let view = FixtureFileView::with_files(&[
792 ("/proc/self/cgroup", "0::/user.slice/nested.service\n"),
793 ("/proc/self/mountinfo", V2_MOUNT),
794 ("/sys/fs/cgroup/memory.max", "max\n"),
795 ("/sys/fs/cgroup/user.slice/memory.max", "max\n"),
796 ("/sys/fs/cgroup/user.slice/nested.service/memory.max", "1073741824\n"),
797 ]);
798
799 let discovered = discover_cgroup_limit(&view)
800 .expect("the fixture is readable")
801 .expect("a finite nested limit is discovered");
802
803 assert_eq!(discovered.bytes(), GIB);
804 assert_eq!(discovered.source(), MemoryLimitSource::CgroupV2);
805 }
806
807 #[test]
808 fn a_tighter_ancestor_limit_wins_over_a_leaf_without_a_limit() {
809 let view = FixtureFileView::with_files(&[
810 ("/proc/self/cgroup", "0::/parent/leaf\n"),
811 ("/sys/fs/cgroup/parent/leaf/memory.max", "max\n"),
812 ("/sys/fs/cgroup/parent/memory.max", "536870912\n"),
813 ("/proc/self/mountinfo", V2_MOUNT),
814 ]);
815
816 let discovered = discover_cgroup_limit(&view)
817 .expect("the fixture is readable")
818 .expect("the ancestor limit applies");
819
820 assert_eq!(discovered.bytes(), 512 * 1024 * 1024);
821 }
822
823 #[test]
824 fn a_mount_root_other_than_the_hierarchy_root_is_resolved() {
825 let view = FixtureFileView::with_files(&[
826 ("/proc/self/cgroup", "0::/docker/abc123\n"),
827 (
828 "/proc/self/mountinfo",
829 "29 23 0:26 /docker/abc123 /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw\n",
830 ),
831 ("/sys/fs/cgroup/memory.max", "2147483648\n"),
832 ]);
833
834 let discovered = discover_cgroup_limit(&view)
835 .expect("the fixture is readable")
836 .expect("the mounted root carries the constraint");
837
838 assert_eq!(discovered.bytes(), 2 * GIB);
839 }
840
841 #[test]
842 fn escaped_mount_paths_are_decoded() {
843 let view = FixtureFileView::with_files(&[
844 ("/proc/self/cgroup", "0::/nested/service\n"),
845 (
846 "/proc/self/mountinfo",
847 "29 23 0:26 / /sys/fs/cgroup\\040v2 rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw\n",
848 ),
849 ("/sys/fs/cgroup v2/nested/service/memory.max", "1073741824\n"),
850 ]);
851
852 let discovered = discover_cgroup_limit(&view)
853 .expect("the fixture is readable")
854 .expect("the escaped mount point resolves");
855
856 assert_eq!(discovered.bytes(), GIB);
857 }
858
859 #[test]
860 fn a_v1_mount_needs_the_memory_controller_and_ignores_the_unlimited_sentinel() {
861 let membership = "5:memory:/docker/abc\n";
862 let memory_mount =
863 "30 23 0:27 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,memory\n";
864 let unlimited = "9223372036854771712\n";
865
866 let unlimited_view = FixtureFileView::with_files(&[
867 ("/proc/self/cgroup", membership),
868 ("/proc/self/mountinfo", memory_mount),
869 ("/sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes", unlimited),
870 ("/sys/fs/cgroup/memory/docker/memory.limit_in_bytes", unlimited),
871 ("/sys/fs/cgroup/memory/memory.limit_in_bytes", unlimited),
872 ]);
873 assert!(
874 discover_cgroup_limit(&unlimited_view)
875 .expect("the fixture is readable")
876 .is_none(),
877 "the v1 unlimited sentinel is not a constraint"
878 );
879
880 let finite_view = FixtureFileView::with_files(&[
881 ("/proc/self/cgroup", membership),
882 ("/proc/self/mountinfo", memory_mount),
883 ("/sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes", unlimited),
884 ("/sys/fs/cgroup/memory/docker/memory.limit_in_bytes", "1073741824\n"),
885 ]);
886 let discovered = discover_cgroup_limit(&finite_view)
887 .expect("the fixture is readable")
888 .expect("the finite ancestor is the constraint");
889 assert_eq!(discovered.bytes(), GIB);
890 assert_eq!(discovered.source(), MemoryLimitSource::CgroupV1);
891
892 let cpu_only_view = FixtureFileView::with_files(&[
893 ("/proc/self/cgroup", membership),
894 (
895 "/proc/self/mountinfo",
896 "31 23 0:28 / /sys/fs/cgroup/cpu rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu\n",
897 ),
898 ]);
899 assert!(
900 discover_cgroup_limit(&cpu_only_view)
901 .expect("the fixture is readable")
902 .is_none(),
903 "a mount without the memory controller cannot bound memory"
904 );
905 }
906
907 #[test]
908 fn an_unreadable_constraint_is_an_error_rather_than_a_missing_one() {
909 let view = FixtureFileView::with_files(&[
910 ("/proc/self/cgroup", "0::/leaf\n"),
911 ("/proc/self/mountinfo", V2_MOUNT),
912 ("/sys/fs/cgroup/leaf/memory.max", "1073741824\n"),
913 ])
914 .with_unreadable("/sys/fs/cgroup/leaf/memory.max");
915
916 let error = discover_cgroup_limit(&view).expect_err("a permission failure must not read as absent");
917
918 assert_eq!(error.operation(), crate::RuntimeOperation::DetectProcessMemoryLimit);
919 }
920
921 #[test]
922 fn a_missing_membership_file_yields_no_cgroup_constraint() {
923 let view = FixtureFileView::with_files(&[("/proc/self/mountinfo", V2_MOUNT)]);
924
925 assert!(discover_cgroup_limit(&view)
926 .expect("a missing file is not an error")
927 .is_none());
928 }
929
930 #[test]
931 fn the_visible_paths_include_the_mount_root_and_every_ancestor() {
932 let mount = CgroupMount {
933 root: PathBuf::from("/docker"),
934 mount_point: PathBuf::from("/sys/fs/cgroup"),
935 hierarchy: CgroupHierarchy::V2,
936 };
937
938 let paths = visible_membership_paths(&mount, Path::new("/docker/abc/leaf"));
939
940 assert_eq!(
941 paths,
942 vec![
943 PathBuf::from("/sys/fs/cgroup/abc/leaf"),
944 PathBuf::from("/sys/fs/cgroup/abc"),
945 PathBuf::from("/sys/fs/cgroup"),
946 ]
947 );
948 assert!(visible_membership_paths(&mount, Path::new("/other/leaf")).is_empty());
949 }
950
951 #[test]
952 fn membership_parsing_records_controllers_and_skips_malformed_lines() {
953 let memberships = parse_cgroup_membership("0::/v2/leaf\n5:memory,cpu:/v1/leaf\nmalformed\n\n");
954
955 assert_eq!(memberships.len(), 2);
956 assert!(memberships[0].controllers.is_empty());
957 assert_eq!(memberships[0].path, PathBuf::from("/v2/leaf"));
958 assert_eq!(
959 memberships[1].controllers,
960 vec!["memory".to_string(), "cpu".to_string()]
961 );
962 assert_eq!(memberships[1].path, PathBuf::from("/v1/leaf"));
963 }
964
965 #[cfg(target_os = "linux")]
966 #[test]
967 fn linux_limit_parsers_reject_unbounded_and_accept_finite_values() {
968 assert_eq!(parse_cgroup_hard_limit(CgroupHierarchy::V2, "max\n"), None);
969 assert_eq!(
970 parse_cgroup_hard_limit(CgroupHierarchy::V2, "1048576\n"),
971 Some(1_048_576)
972 );
973 assert_eq!(
974 parse_cgroup_hard_limit(CgroupHierarchy::V1Memory, "9223372036854771712\n"),
975 None
976 );
977 assert_eq!(
978 parse_cgroup_hard_limit(CgroupHierarchy::V1Memory, "1048576\n"),
979 Some(1_048_576)
980 );
981 assert_eq!(
982 parse_meminfo_bytes("MemTotal: 2048 kB\nMemFree: 1 kB"),
983 Some(2_097_152)
984 );
985 }
986}