Skip to content

Commit 9e1f166

Browse files
Separate out cgroup implementations.
1 parent 752988e commit 9e1f166

4 files changed

Lines changed: 269 additions & 137 deletions

File tree

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

Lines changed: 12 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -3,160 +3,35 @@
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.
13+
# Linux host memory: tries cgroup v2, then cgroup v1, then /proc/meminfo.
1214
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
1815
DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup"
1916

2017
def initialize(cgroup_root: nil)
2118
@cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
22-
@meminfo = false
2319
end
2420

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]
2721
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
22+
if Memory::Linux::CgroupV2.supported?(@cgroup_root)
23+
if capture = Memory::Linux::CgroupV2.new(cgroup_root: @cgroup_root).capture
24+
return capture
8325
end
8426
end
8527

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
28+
if Memory::Linux::CgroupV1.supported?(@cgroup_root)
29+
if capture = Memory::Linux::CgroupV1.new(cgroup_root: @cgroup_root).capture
30+
return capture
10231
end
10332
end
10433

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
140-
end
141-
end
142-
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
148-
end
149-
end
150-
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
34+
return Memory::Linux::Meminfo.new.capture if Memory::Linux::Meminfo.supported?
16035
end
16136
end
16237
end
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
limit_path = "#{root}/memory/memory.limit_in_bytes"
17+
usage_path = "#{root}/memory/memory.usage_in_bytes"
18+
19+
return false unless File.exist?(limit_path) && File.exist?(usage_path)
20+
21+
limit = File.read(limit_path).strip.to_i
22+
if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
23+
return true
24+
end
25+
26+
return false
27+
end
28+
29+
def initialize(cgroup_root: nil)
30+
@cgroup_root = (cgroup_root || DEFAULT_CGROUP_ROOT).to_s.chomp("/")
31+
end
32+
33+
def capture
34+
total = read_total
35+
return nil unless total && total.positive?
36+
37+
used = read_used
38+
return nil unless used
39+
used = 0 if used.negative?
40+
used = [used, total].min
41+
42+
swap_total, swap_used = read_swap
43+
reclaimable = read_reclaimable
44+
45+
return Host::Memory.new(total, used, swap_total, swap_used, reclaimable)
46+
end
47+
48+
private
49+
50+
def path(name)
51+
"#{@cgroup_root}/memory/#{name}"
52+
end
53+
54+
def read_total
55+
limit = File.read(path("memory.limit_in_bytes")).strip.to_i
56+
57+
if limit <= 0 || limit >= CGROUP_V1_UNLIMITED_THRESHOLD
58+
return nil
59+
end
60+
61+
return limit
62+
end
63+
64+
def read_used
65+
File.read(path("memory.usage_in_bytes")).strip.to_i
66+
end
67+
68+
def read_reclaimable
69+
unless content = (File.read(path("memory.stat")) rescue nil)
70+
return nil
71+
end
72+
73+
match = content.match(/^cache\s+(\d+)/m)
74+
75+
return match ? match[1].to_i : nil
76+
end
77+
78+
def read_swap
79+
unless content = (File.read("/proc/meminfo") rescue nil)
80+
return [nil, nil]
81+
end
82+
83+
swap_total_kb = content[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
84+
swap_free_kb = content[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
85+
86+
unless swap_total_kb
87+
return [nil, nil]
88+
end
89+
90+
swap_total = swap_total_kb * 1024
91+
swap_used = (swap_total_kb - (swap_free_kb || 0)) * 1024
92+
return swap_total, swap_used
93+
end
94+
end
95+
end
96+
end
97+
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)