Skip to main content

rocketmq_runtime/resource_budget/
memory.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Process memory limit discovery and the managed memory policy.
16//!
17//! Discovery answers what the kernel actually enforces for this process: the
18//! cgroup membership from `/proc/self/cgroup`, the hierarchy mounts and their
19//! roots from `/proc/self/mountinfo`, and the smallest finite hard limit visible
20//! along the membership path. The managed memory policy answers a different
21//! question: how many bytes the resource ledger may charge. The two numbers are
22//! deliberately separate, because thread stacks, allocator retention, the
23//! components that never register, page cache, and other processes in the same
24//! cgroup all consume the same limit.
25
26use 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/// The cgroup v1 sentinel that means "unlimited" rather than a byte count.
49///
50/// The kernel reports `PAGE_COUNTER_MAX` for a group without a memory limit.
51/// Any value at or above it is an unbounded marker, not a finite constraint.
52#[cfg(any(target_os = "linux", test))]
53const CGROUP_V1_UNLIMITED_SENTINEL: u64 = 0x7FFF_FFFF_FFFF_F000;
54
55/// A read-only view of the files that describe process memory constraints.
56///
57/// Production uses [`HostMemoryFileView`]. The abstraction exists so cgroup
58/// layouts, namespace roots, and read failures can be exercised deterministically
59/// without a host that is configured that way.
60pub trait MemoryFileView: fmt::Debug + Send + Sync + 'static {
61    /// Reads one file, returning `None` when the path does not exist.
62    ///
63    /// # Errors
64    ///
65    /// Returns an operational error for a file that exists and cannot be read,
66    /// so a permission or I/O failure is not mistaken for an absent constraint.
67    fn read_optional(&self, path: &Path) -> RuntimeResult<Option<String>>;
68}
69
70/// The production view over the host filesystem.
71#[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/// Identifies the memory limit source state.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum MemoryLimitSource {
90    /// Represents the configured case.
91    Configured,
92    /// Represents the environment case.
93    Environment,
94    /// Represents the cgroup v2 case.
95    CgroupV2,
96    /// Represents the cgroup v1 case.
97    CgroupV1,
98    /// Represents the host physical memory case.
99    HostPhysicalMemory,
100}
101
102/// Represents process memory limit.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct ProcessMemoryLimit {
105    bytes: u64,
106    source: MemoryLimitSource,
107}
108
109impl ProcessMemoryLimit {
110    /// Detects the effective process memory limit.
111    ///
112    /// # Errors
113    ///
114    /// Returns an operational error when process, cgroup, or environment
115    /// discovery cannot provide a finite limit.
116    pub fn detect() -> RuntimeResult<Self> {
117        Self::detect_with_view(&HostMemoryFileView)
118    }
119
120    /// Detects the effective process memory limit through an injectable file view.
121    ///
122    /// The explicit environment override wins. Otherwise the cgroup membership
123    /// and mounts are read from the view when the platform uses them, and the
124    /// smallest finite hard limit wins over the host physical memory fallback.
125    /// A `HostPhysicalMemory` source on Linux means no finite cgroup hard limit
126    /// was visible from this process's membership, so the kernel may enforce a
127    /// tighter constraint than the reported one.
128    ///
129    /// # Errors
130    ///
131    /// Returns a configuration error when the explicit environment value is not
132    /// a positive byte count, and an operational error when the view cannot read
133    /// a file that exists or when no finite constraint is discoverable.
134    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    /// Creates an explicitly configured process memory limit.
149    ///
150    /// # Errors
151    ///
152    /// Returns a contract violation when `bytes` is zero.
153    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    /// Returns the bytes.
167    pub const fn bytes(self) -> u64 {
168        self.bytes
169    }
170
171    #[must_use]
172    /// Returns the source.
173    pub const fn source(self) -> MemoryLimitSource {
174        self.source
175    }
176
177    /// Returns a positive bounded fraction of this limit.
178    ///
179    /// # Errors
180    ///
181    /// Returns a contract violation for a zero, inverted, or out-of-range
182    /// fraction.
183    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/// How the requested managed bytes are derived from the effective limit.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum ManagedMemoryRequest {
197    /// Charge the effective limit itself, the behavior before this policy existed.
198    #[default]
199    WholeLimit,
200    /// Charge an explicit byte count that is independent of the detected limit.
201    ExplicitBytes(u64),
202    /// Charge a bounded fraction of the effective limit.
203    Fraction {
204        /// The fraction numerator.
205        numerator: u64,
206        /// The fraction denominator.
207        denominator: u64,
208    },
209}
210
211/// The policy that derives the chargeable managed memory budget.
212///
213/// The resolved budget is
214/// `min(requested_managed, effective_limit - headroom_bytes)`, where
215/// `effective_limit` is the smaller of the detected limit and an explicitly
216/// configured limit. No ratio or headroom is imposed by default: the default
217/// charges the whole effective limit, which is the behavior that existed before
218/// this policy, so adopting a measured ratio stays an explicit opt-in. Lowering
219/// a budget does not revoke permits that were already granted; it bounds new
220/// admission.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub struct ManagedMemoryPolicy {
223    request: ManagedMemoryRequest,
224    headroom_bytes: u64,
225}
226
227impl ManagedMemoryPolicy {
228    /// Charges the entire effective limit.
229    #[must_use]
230    pub const fn whole_limit() -> Self {
231        Self {
232            request: ManagedMemoryRequest::WholeLimit,
233            headroom_bytes: 0,
234        }
235    }
236
237    /// Charges an explicit byte count.
238    ///
239    /// # Errors
240    ///
241    /// Returns a contract violation when `bytes` is zero, which could only
242    /// produce a zero budget.
243    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    /// Charges a fraction of the effective limit.
256    ///
257    /// # Errors
258    ///
259    /// Returns a contract violation for a zero, inverted, or out-of-range
260    /// fraction.
261    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    /// Reserves bytes that the ledger must leave unused inside the limit.
274    #[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    /// Returns how the requested managed bytes are derived.
281    #[must_use]
282    pub const fn request(self) -> ManagedMemoryRequest {
283        self.request
284    }
285
286    /// Returns the reserved headroom.
287    #[must_use]
288    pub const fn headroom_bytes(self) -> u64 {
289        self.headroom_bytes
290    }
291}
292
293/// The resolved memory numbers that drive the process resource budget.
294///
295/// The detected limit stays available for reporting while [`Self::managed_bytes`]
296/// is what the ledger may charge. The budget covers the ledger objects that
297/// register with the shared budget tree only.
298#[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    /// Resolves the effective limit and the managed budget.
308    ///
309    /// # Errors
310    ///
311    /// Returns a contract violation when the policy ratio is out of range, when
312    /// the headroom leaves no room inside the effective limit, or when the
313    /// resolved managed budget is zero.
314    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    /// Returns the detected or explicitly configured process memory limit.
353    #[must_use]
354    pub const fn detected(self) -> ProcessMemoryLimit {
355        self.detected
356    }
357
358    /// Returns the smaller of the detected limit and an explicit limit.
359    #[must_use]
360    pub const fn effective_limit_bytes(self) -> u64 {
361        self.effective_limit_bytes
362    }
363
364    /// Returns the managed bytes the policy asked for before headroom applied.
365    #[must_use]
366    pub const fn requested_managed_bytes(self) -> u64 {
367        self.requested_managed_bytes
368    }
369
370    /// Returns the managed bytes the ledger may charge.
371    #[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    /// A cgroup v2 hierarchy.
388    V2,
389    /// A cgroup v1 hierarchy that carries the memory controller.
390    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/// Parses `/proc/self/cgroup` into the process's memberships.
409///
410/// Malformed lines are skipped: the file is kernel-generated, and a layout this
411/// process cannot interpret is a missing constraint rather than a fatal error.
412#[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/// Parses `/proc/self/mountinfo` into the cgroup hierarchies visible to the
437/// process.
438///
439/// Only cgroup v2 mounts and cgroup v1 mounts that carry the memory controller
440/// are kept, because the others cannot bound this process's memory.
441#[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/// Decodes the octal escapes `mountinfo` uses for space, tab, newline, and
465/// backslash in paths.
466#[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/// Returns the on-disk directory of the membership and of every visible ancestor
489/// up to the mount root.
490///
491/// A finite limit set on an ancestor applies to the process, so the smallest
492/// finite value along the path is the constraint the kernel enforces.
493#[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/// Returns whether one mount can constrain one membership.
512#[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/// Reads the smallest finite hard limit visible to one membership.
524///
525/// `memory.high` is a pressure threshold rather than a cap, so it is never read
526/// here; only the hard limit files can bound the budget.
527#[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    // `parse_positive_bytes` rejects the v2 `max` value, and the v1 sentinel is
549    // rejected here, so both mean "no limit at this level".
550    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/// Resolves the cgroup constraint visible to this process, if any.
558#[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        // The cgroup layout is a Linux mechanism; other platforms keep their own
600        // platform implementation and rely on explicit configuration for
601        // additional container constraints.
602        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    /// A view over an in-memory layout.
642    #[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        // The explicit limit lowers the effective limit before headroom applies.
728        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        // The fixed paths only see the hierarchy root, whose v2 value is `max`,
790        // so this layout used to fall back to host physical memory.
791        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}