33# Released under the MIT License.
44# Copyright, 2025-2026, by Samuel Williams.
55
6+ require_relative "linux/cgroup_v2"
7+ require_relative "linux/cgroup_v1"
8+ require_relative "linux/meminfo"
9+
610module Process
711 module Metrics
812 module Host
9- # Linux implementation of host memory metrics.
10- # Uses cgroups v2 (memory.max, memory.current) or cgroups v1 (memory.limit_in_bytes, memory.usage_in_bytes) when in a container;
11- # otherwise reads /proc/meminfo (MemTotal, MemAvailable/MemFree, SwapTotal/SwapFree). Parses meminfo once per capture and reuses it.
12- class Memory ::Linux
13- # Threshold for distinguishing actual memory limits from "unlimited" sentinel values in cgroups v1.
14- # In cgroups v1, when memory.limit_in_bytes is set to unlimited (by writing -1), the kernel stores a very large sentinel near 2^63.
15- # Any value >= 2^60 (1 exabyte) is treated as unlimited and we fall back to /proc/meminfo.
16- # Reference: https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
17- CGROUP_V1_UNLIMITED_THRESHOLD = 2 **60
13+ # Linux host memory: tries cgroup v2, then cgroup v1, then /proc/meminfo.
14+ module Memory ::Linux
1815 DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
19-
20- def initialize ( cgroup_root : nil )
21- @cgroup_root = ( cgroup_root || DEFAULT_CGROUP_ROOT ) . to_s . chomp ( "/" )
22- @meminfo = false
23- end
24-
25- # Capture current host memory. Reads total and used (from cgroup or meminfo), computes free, parses swap and reclaimable from meminfo/cgroup.
26- # @returns [Host::Memory | Nil]
27- def capture
28- total = capture_total
29- return nil unless total && total . positive?
30-
31- used = capture_used ( total )
32- used = 0 if used . nil? || used . negative?
33- used = [ used , total ] . min
34-
35- swap_total , swap_used = capture_swap
36- reclaimable = capture_reclaimable
37-
38- return Host ::Memory . new ( total , used , swap_total , swap_used , reclaimable )
39- end
40-
41- private
42-
43- # Path for cgroups v2 (unified): e.g. /sys/fs/cgroup/memory.stat
44- def cgroup_v2_path ( name )
45- "#{ @cgroup_root } /#{ name } "
46- end
47-
48- # Path for cgroups v1 (memory controller): e.g. /sys/fs/cgroup/memory/memory.stat
49- def cgroup_v1_path ( name )
50- "#{ @cgroup_root } /memory/#{ name } "
51- end
52-
53- # Memoized /proc/meminfo contents. Used for total (MemTotal), used (via MemAvailable), and swap when not in a cgroup.
54- # @returns [String | Nil]
55- def meminfo
56- if @meminfo == false
57- @meminfo = File . read ( "/proc/meminfo" ) rescue nil
58- end
59-
60- return @meminfo
61- end
62-
63- # Total memory in bytes: cgroups v2 memory.max, cgroups v1 memory.limit_in_bytes (if < threshold), else MemTotal from meminfo.
64- # @returns [Integer | Nil]
65- def capture_total
66- if File . exist? ( cgroup_v2_path ( "memory.max" ) )
67- limit = File . read ( cgroup_v2_path ( "memory.max" ) ) . strip
68- return limit . to_i if limit != "max"
69- end
70-
71- if File . exist? ( cgroup_v1_path ( "memory.limit_in_bytes" ) )
72- limit = File . read ( cgroup_v1_path ( "memory.limit_in_bytes" ) ) . strip . to_i
73- return limit if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
74- end
75-
76- unless meminfo_content = self . meminfo
77- return nil
78- end
79-
80- meminfo_content . each_line do |line |
81- if /MemTotal:\s *(?<total>\d +)\s *kB/ =~ line
82- return $~[ :total ] . to_i * 1024
83- end
84- end
85-
86- return nil
87- end
88-
89- # Current memory usage in bytes: cgroups v2 memory.current, cgroups v1 memory.usage_in_bytes, or total - MemAvailable from meminfo.
90- # @parameter total [Integer] Total memory (used to compute used from MemAvailable when not in cgroup).
91- # @returns [Integer | Nil]
92- def capture_used ( total )
93- if File . exist? ( cgroup_v2_path ( "memory.current" ) )
94- current = File . read ( cgroup_v2_path ( "memory.current" ) ) . strip . to_i
95- return current
96- end
97-
98- if File . exist? ( cgroup_v1_path ( "memory.usage_in_bytes" ) )
99- limit = File . read ( cgroup_v1_path ( "memory.limit_in_bytes" ) ) . strip . to_i
100- if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
101- return File . read ( cgroup_v1_path ( "memory.usage_in_bytes" ) ) . strip . to_i
102- end
103- end
104-
105- unless meminfo_content = self . meminfo
106- return nil
107- end
108-
109- available_kilobytes = meminfo_content [ /MemAvailable:\s *(\d +)\s *kB/ , 1 ] &.to_i
110- available_kilobytes ||= meminfo_content [ /MemFree:\s *(\d +)\s *kB/ , 1 ] &.to_i
111- return nil unless available_kilobytes
112-
113- return [ total - ( available_kilobytes * 1024 ) , 0 ] . max
114- end
115-
116- # Swap total and used in bytes from meminfo (SwapTotal, SwapFree).
117- # @returns [Array(Integer, Integer)] [swap_total_bytes, swap_used_bytes], or [nil, nil] if no swap.
118- def capture_swap
119- return [ nil , nil ] unless meminfo_content = self . meminfo
120- swap_total_kilobytes = meminfo_content [ /SwapTotal:\s *(\d +)\s *kB/ , 1 ] &.to_i
121- swap_free_kilobytes = meminfo_content [ /SwapFree:\s *(\d +)\s *kB/ , 1 ] &.to_i
122-
123- return [ nil , nil ] unless swap_total_kilobytes
124-
125- swap_total_bytes = swap_total_kilobytes * 1024
126- swap_used_bytes = ( swap_total_kilobytes - ( swap_free_kilobytes || 0 ) ) * 1024
127-
128- return swap_total_bytes , swap_used_bytes
129- end
130-
131- # Reclaimable memory in bytes (page cache, buffers, reclaimable slab). Included in used_size.
132- # From cgroups v2 memory.stat "file", cgroups v1 memory.stat "cache", or meminfo Cached + Buffers + SReclaimable.
133- # @returns [Integer | Nil]
134- def capture_reclaimable
135- if File . exist? ( cgroup_v2_path ( "memory.stat" ) )
136- # cgroups v2: "file" is file-backed (page cache), in bytes
137- content = File . read ( cgroup_v2_path ( "memory.stat" ) ) rescue nil
138- if content && ( m = content . match ( /^file\s +(\d +)/m ) )
139- return m [ 1 ] . to_i
16+
17+ def self . capture ( cgroup_root : DEFAULT_CGROUP_ROOT )
18+ if Memory ::Linux ::CgroupV2 . supported? ( cgroup_root )
19+ if capture = Memory ::Linux ::CgroupV2 . new ( cgroup_root : cgroup_root ) . capture
20+ return capture
14021 end
14122 end
14223
143- if File . exist? ( cgroup_v1_path ( "memory.stat" ) )
144- # cgroups v1: "cache" is page cache, in bytes
145- content = File . read ( cgroup_v1_path ( "memory.stat" ) ) rescue nil
146- if content && ( m = content . match ( /^cache\s +(\d +)/m ) )
147- return m [ 1 ] . to_i
24+ if Memory ::Linux ::CgroupV1 . supported? ( cgroup_root )
25+ if capture = Memory ::Linux ::CgroupV1 . new ( cgroup_root : cgroup_root ) . capture
26+ return capture
14827 end
14928 end
15029
151- # meminfo: Cached + Buffers + SReclaimable (kB)
152- unless meminfo_content = self . meminfo
153- return nil
154- end
155- cached_kb = meminfo_content [ /Cached:\s *(\d +)\s *kB/ , 1 ] &.to_i || 0
156- buffers_kb = meminfo_content [ /Buffers:\s *(\d +)\s *kB/ , 1 ] &.to_i || 0
157- sreclaimable_kb = meminfo_content [ /SReclaimable:\s *(\d +)\s *kB/ , 1 ] &.to_i || 0
158- reclaimable_kb = cached_kb + buffers_kb + sreclaimable_kb
159- return reclaimable_kb * 1024
30+ return Memory ::Linux ::Meminfo . new . capture if Memory ::Linux ::Meminfo . supported?
16031 end
16132 end
16233 end
@@ -166,7 +37,7 @@ def capture_reclaimable
16637# Wire Host::Memory to this implementation on Linux.
16738class << Process ::Metrics ::Host ::Memory
16839 def capture
169- Process ::Metrics ::Host ::Memory ::Linux . new . capture
40+ Process ::Metrics ::Host ::Memory ::Linux . capture
17041 end
17142
17243 def supported?
0 commit comments