Skip to content

Commit 4ada3f8

Browse files
Separate out cgroup implementations.
1 parent 752988e commit 4ada3f8

File tree

4 files changed

+267
-145
lines changed

4 files changed

+267
-145
lines changed

lib/process/metrics/host/memory/linux.rb

Lines changed: 16 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -3,160 +3,31 @@
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+
610
module 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.
16738
class << 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?
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025-2026, by Samuel Williams.
5+
6+
module Process
7+
module Metrics
8+
module Host
9+
class Memory::Linux::CgroupV1
10+
CGROUP_V1_UNLIMITED_THRESHOLD = 2**60
11+
DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
12+
13+
def self.supported?(cgroup_root = DEFAULT_CGROUP_ROOT)
14+
root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
15+
16+
if File.exist?("#{root}/memory/memory.limit_in_bytes") && File.exist?("#{root}/memory/memory.usage_in_bytes")
17+
return true
18+
end
19+
20+
return false
21+
end
22+
23+
def initialize(cgroup_root: nil)
24+
@cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
25+
end
26+
27+
def capture
28+
total = read_total
29+
return nil unless total && total.positive?
30+
31+
used = read_used
32+
return nil unless used
33+
used = 0 if used.negative?
34+
used = [used, total].min
35+
36+
swap_total, swap_used = read_swap
37+
reclaimable = read_reclaimable
38+
39+
return Host::Memory.new(total, used, swap_total, swap_used, reclaimable)
40+
end
41+
42+
private
43+
44+
def path(name)
45+
"#{@cgroup_root}/memory/#{name}"
46+
end
47+
48+
def read_total
49+
limit = File.read(path("memory.limit_in_bytes")).strip.to_i
50+
51+
if limit <= 0 || limit >= CGROUP_V1_UNLIMITED_THRESHOLD
52+
return nil
53+
end
54+
55+
return limit
56+
end
57+
58+
def read_used
59+
File.read(path("memory.usage_in_bytes")).strip.to_i
60+
end
61+
62+
def read_reclaimable
63+
unless content = (File.read(path("memory.stat")) rescue nil)
64+
return nil
65+
end
66+
67+
match = content.match(/^cache\s+(\d+)/m)
68+
69+
return match ? match[1].to_i : nil
70+
end
71+
72+
def read_swap
73+
unless content = (File.read("/proc/meminfo") rescue nil)
74+
return [nil, nil]
75+
end
76+
77+
swap_total_kb = content[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
78+
swap_free_kb = content[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
79+
80+
unless swap_total_kb
81+
return [nil, nil]
82+
end
83+
84+
swap_total = swap_total_kb * 1024
85+
swap_used = (swap_total_kb - (swap_free_kb || 0)) * 1024
86+
return swap_total, swap_used
87+
end
88+
end
89+
end
90+
end
91+
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025-2026, by Samuel Williams.
5+
6+
module Process
7+
module Metrics
8+
module Host
9+
class Memory::Linux::CgroupV2
10+
DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
11+
12+
def self.supported?(cgroup_root = DEFAULT_CGROUP_ROOT)
13+
root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
14+
15+
if File.exist?("#{root}/memory.current") && File.exist?("#{root}/memory.max")
16+
return true
17+
end
18+
19+
return false
20+
end
21+
22+
def initialize(cgroup_root: nil)
23+
@cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
24+
end
25+
26+
def capture
27+
total = read_total
28+
return nil unless total && total.positive?
29+
30+
used = read_used
31+
used = 0 if used.nil? || used.negative?
32+
used = [used, total].min
33+
34+
swap_total, swap_used = read_swap
35+
reclaimable = read_reclaimable
36+
37+
return Host::Memory.new(total, used, swap_total, swap_used, reclaimable)
38+
end
39+
40+
private
41+
42+
def path(name)
43+
"#{@cgroup_root}/#{name}"
44+
end
45+
46+
def read_total
47+
limit = File.read(path("memory.max")).strip
48+
49+
if limit == "max"
50+
return nil
51+
end
52+
53+
return limit.to_i
54+
end
55+
56+
def read_used
57+
File.read(path("memory.current")).strip.to_i
58+
end
59+
60+
def read_reclaimable
61+
unless content = (File.read(path("memory.stat")) rescue nil)
62+
return nil
63+
end
64+
65+
match = content.match(/^file\s+(\d+)/m)
66+
return match ? match[1].to_i : nil
67+
end
68+
69+
def read_swap
70+
unless content = (File.read("/proc/meminfo") rescue nil)
71+
return [nil, nil]
72+
end
73+
74+
swap_total_kb = content[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
75+
swap_free_kb = content[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
76+
77+
unless swap_total_kb
78+
return [nil, nil]
79+
end
80+
81+
swap_total = swap_total_kb * 1024
82+
swap_used = (swap_total_kb - (swap_free_kb || 0)) * 1024
83+
return swap_total, swap_used
84+
end
85+
end
86+
end
87+
end
88+
end

0 commit comments

Comments
 (0)