Skip to content

Commit 4531c2d

Browse files
Introduce Host::Memory.
1 parent e49483e commit 4531c2d

9 files changed

Lines changed: 407 additions & 139 deletions

File tree

lib/process/metrics/host/memory.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
# Per-host (system-wide) memory metrics. Use Host::Memory for total/used/free and swap; use Process::Metrics::Memory for per-process metrics.
9+
module Host
10+
# Struct for host memory snapshot. All sizes in bytes.
11+
# @attribute total [Integer] Total memory (cgroup limit when in a container, else physical RAM).
12+
# @attribute used [Integer] Memory in use (total - free).
13+
# @attribute free [Integer] Available memory (MemAvailable-style: free + reclaimable).
14+
# @attribute swap_total [Integer, nil] Total swap, or nil if not available.
15+
# @attribute swap_used [Integer, nil] Swap in use, or nil if not available.
16+
Memory = Struct.new(:total, :used, :free, :swap_total, :swap_used) do
17+
alias as_json to_h
18+
19+
def to_json(*arguments)
20+
as_json.to_json(*arguments)
21+
end
22+
23+
# Create a zero-initialized Host::Memory instance.
24+
# @returns [Memory]
25+
def self.zero
26+
self.new(0, 0, 0, nil, nil)
27+
end
28+
29+
# Whether host memory capture is supported on this platform.
30+
# @returns [Boolean]
31+
def self.supported?
32+
false
33+
end
34+
35+
# Capture current host memory. Implemented by Host::Memory::Linux or Host::Memory::Darwin (in host/memory/linux.rb, host/memory/darwin.rb).
36+
# @returns [Memory | Nil] A Host::Memory instance, or nil if not supported or capture failed.
37+
def self.capture
38+
return nil
39+
end
40+
end
41+
end
42+
end
43+
end
44+
45+
if RUBY_PLATFORM.include?("linux")
46+
require_relative "memory/linux"
47+
elsif RUBY_PLATFORM.include?("darwin")
48+
require_relative "memory/darwin"
49+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
# Darwin (macOS) implementation of host memory metrics.
10+
# Uses sysctl (hw.memsize), vm_stat (free + inactive pages), and vm.swapusage for swap.
11+
class Memory::Darwin
12+
# Parse a size string from vm.swapusage (e.g. "1024.00M", "512.00K") into bytes.
13+
# @parameter string [String | Nil] The size string from sysctl vm.swapusage.
14+
# @returns [Integer | Nil] Size in bytes, or nil if string is nil/empty.
15+
def self.parse_swap_size(string)
16+
return nil unless string
17+
18+
string = string.strip
19+
20+
case string
21+
when /([\d.]+)M/i then ($1.to_f * 1024 * 1024).round
22+
when /([\d.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
23+
when /([\d.]+)K/i then ($1.to_f * 1024).round
24+
else string.to_f.round
25+
end
26+
end
27+
28+
# Capture current host memory. Reads total (hw.memsize), free (vm_stat), and swap (vm.swapusage).
29+
# @returns [Host::Memory | Nil] A Host::Memory instance, or nil if capture fails.
30+
def self.capture
31+
total = capture_total
32+
return nil unless total && total.positive?
33+
34+
free = capture_free
35+
return nil unless free
36+
37+
free = 0 if free.negative?
38+
used = [total - free, 0].max
39+
swap_total, swap_used = capture_swap
40+
41+
return Host::Memory.new(total, used, free, swap_total, swap_used)
42+
end
43+
44+
# Total physical RAM in bytes, from sysctl hw.memsize.
45+
# @returns [Integer | Nil]
46+
def self.capture_total
47+
IO.popen(["sysctl", "-n", "hw.memsize"], "r", &:read)&.strip&.to_i
48+
end
49+
50+
# Free + inactive (reclaimable) memory in bytes, from vm_stat. Matches Linux MemAvailable semantics.
51+
# @returns [Integer | Nil]
52+
def self.capture_free
53+
output = IO.popen(["vm_stat"], "r", &:read)
54+
page_size = output[/page size of (\d+) bytes/, 1]&.to_i
55+
return nil unless page_size && page_size.positive?
56+
57+
pages_free = output[/Pages free:\s*(\d+)/, 1]&.to_i || 0
58+
pages_inactive = output[/Pages inactive:\s*(\d+)/, 1]&.to_i || 0
59+
return (pages_free + pages_inactive) * page_size
60+
end
61+
62+
# Swap total and used in bytes, from sysctl vm.swapusage (e.g. "total = 64.00M used = 32.00M free = 32.00M").
63+
# @returns [Array(Integer | Nil, Integer | Nil)] [swap_total_bytes, swap_used_bytes], or [nil, nil] if unavailable.
64+
def self.capture_swap
65+
output = IO.popen(["sysctl", "-n", "vm.swapusage"], "r", &:read)
66+
return [nil, nil] unless output
67+
68+
total_string = output[/total\s*=\s*([\d.]+\s*[KMG]?)/i, 1]
69+
used_string = output[/used\s*=\s*([\d.]+\s*[KMG]?)/i, 1]
70+
swap_total = total_string ? parse_swap_size(total_string) : nil
71+
swap_used = used_string ? parse_swap_size(used_string) : nil
72+
73+
return swap_total, swap_used
74+
end
75+
end
76+
end
77+
end
78+
end
79+
80+
# Wire Host::Memory to this implementation on Darwin.
81+
class << Process::Metrics::Host::Memory
82+
def capture
83+
Process::Metrics::Host::Memory::Darwin.capture
84+
end
85+
86+
def supported?
87+
File.exist?("/usr/bin/vm_stat")
88+
end
89+
end
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
# 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
18+
19+
def initialize
20+
@meminfo = false
21+
end
22+
23+
# Capture current host memory. Reads total and used (from cgroup or meminfo), computes free, and parses swap from meminfo.
24+
# @returns [Host::Memory | Nil]
25+
def capture
26+
total = capture_total
27+
return nil unless total && total.positive?
28+
29+
used = capture_used(total)
30+
used = 0 if used.nil? || used.negative?
31+
used = [used, total].min
32+
free = total - used
33+
34+
swap_total, swap_used = capture_swap
35+
36+
return Host::Memory.new(total, used, free, swap_total, swap_used)
37+
end
38+
39+
private
40+
41+
# Memoized /proc/meminfo contents. Used for total (MemTotal), used (via MemAvailable), and swap when not in a cgroup.
42+
# @returns [String | Nil]
43+
def meminfo
44+
if @meminfo == false
45+
@meminfo = File.read("/proc/meminfo") rescue nil
46+
end
47+
48+
return @meminfo
49+
end
50+
51+
# Total memory in bytes: cgroups v2 memory.max, cgroups v1 memory.limit_in_bytes (if < threshold), else MemTotal from meminfo.
52+
# @returns [Integer | Nil]
53+
def capture_total
54+
if File.exist?("/sys/fs/cgroup/memory.max")
55+
limit = File.read("/sys/fs/cgroup/memory.max").strip
56+
return limit.to_i if limit != "max"
57+
end
58+
59+
if File.exist?("/sys/fs/cgroup/memory/memory.limit_in_bytes")
60+
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
61+
return limit if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
62+
end
63+
64+
unless meminfo = self.meminfo
65+
return nil
66+
end
67+
68+
meminfo.each_line do |line|
69+
if /MemTotal:\s*(?<total>\d+)\s*kB/ =~ line
70+
return $~[:total].to_i * 1024
71+
end
72+
end
73+
74+
return nil
75+
end
76+
77+
# Current memory usage in bytes: cgroups v2 memory.current, cgroups v1 memory.usage_in_bytes, or total - MemAvailable from meminfo.
78+
# @parameter total [Integer] Total memory (used to compute used from MemAvailable when not in cgroup).
79+
# @returns [Integer | Nil]
80+
def capture_used(total)
81+
if File.exist?("/sys/fs/cgroup/memory.current")
82+
current = File.read("/sys/fs/cgroup/memory.current").strip.to_i
83+
return current
84+
end
85+
86+
if File.exist?("/sys/fs/cgroup/memory/memory.usage_in_bytes")
87+
limit = File.read("/sys/fs/cgroup/memory/memory.limit_in_bytes").strip.to_i
88+
if limit > 0 && limit < CGROUP_V1_UNLIMITED_THRESHOLD
89+
return File.read("/sys/fs/cgroup/memory/memory.usage_in_bytes").strip.to_i
90+
end
91+
end
92+
93+
unless meminfo = self.meminfo
94+
return nil
95+
end
96+
97+
available_kb = meminfo[/MemAvailable:\s*(\d+)\s*kB/, 1]&.to_i
98+
available_kb ||= meminfo[/MemFree:\s*(\d+)\s*kB/, 1]&.to_i
99+
return nil unless available_kb
100+
101+
return [total - (available_kb * 1024), 0].max
102+
end
103+
104+
# Swap total and used in bytes from meminfo (SwapTotal, SwapFree).
105+
# @returns [Array(Integer, Integer)] [swap_total_bytes, swap_used_bytes], or [nil, nil] if no swap.
106+
def capture_swap
107+
return [nil, nil] unless meminfo
108+
swap_total_kb = meminfo[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
109+
swap_free_kb = meminfo[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
110+
111+
return [nil, nil] unless swap_total_kb
112+
113+
swap_total_bytes = swap_total_kb * 1024
114+
swap_used_bytes = (swap_total_kb - (swap_free_kb || 0)) * 1024
115+
116+
return swap_total_bytes, swap_used_bytes
117+
end
118+
end
119+
end
120+
end
121+
end
122+
123+
# Wire Host::Memory to this implementation on Linux.
124+
class << Process::Metrics::Host::Memory
125+
def capture
126+
Process::Metrics::Host::Memory::Linux.new.capture
127+
end
128+
129+
def supported?
130+
File.exist?("/proc/meminfo")
131+
end
132+
end

lib/process/metrics/memory.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright, 2019-2026, by Samuel Williams.
55

66
require "json"
7+
require_relative "host/memory"
78

89
module Process
910
module Metrics
@@ -33,6 +34,12 @@ def self.zero
3334
self.new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3435
end
3536

37+
# Total system/host memory in bytes. Delegates to Host::Memory.capture.
38+
# @returns [Integer | Nil]
39+
def self.total_size
40+
Host::Memory.capture&.total
41+
end
42+
3643
# Whether the memory usage can be captured on this system.
3744
def self.supported?
3845
false
@@ -46,5 +53,8 @@ def self.capture(pid, **options)
4653
end
4754
end
4855

49-
require_relative "memory/linux"
50-
require_relative "memory/darwin"
56+
if RUBY_PLATFORM.include?("linux")
57+
require_relative "memory/linux"
58+
elsif RUBY_PLATFORM.include?("darwin")
59+
require_relative "memory/darwin"
60+
end

0 commit comments

Comments
 (0)