process-metrics 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b288419449c98ce2b53cc2ecb8d3d2660a473c17677b66e6f18015b803db1482
4
- data.tar.gz: 67b4d639f7744814e47e61aac54ef7c588557a94ca8454926594d742f9cf88ce
3
+ metadata.gz: bb30f9e1ffed610ad4a6735a8875f6438eae603ce53e6cb67274e7f893f1c4d5
4
+ data.tar.gz: 0474ced532e19377ee8b4b7003c21d1a098afc8df5fb3f9865ea163aa756eced
5
5
  SHA512:
6
- metadata.gz: 0a1686b0609d8009a2d5a26b659876c29c5378d89b35b39777b5d55d53413b2a10558470075ce0303959f77213ceef101cad19547dc95743440d0243af2746e1
7
- data.tar.gz: 13a3e9bbc06eb5412a6b37c499f531eebf58317c1944d0e679dd68d8b87abc4450e62b7166c435334745555d26bb873b9ddc47d65324bdf17832b27865f60a2c
6
+ metadata.gz: 898911e287967bb88596747da31bc6e956bdf4aa8bdea23b461df7998922155d72bd577ca0092320514aa41738547576abd379779a89cf66a22d5a63f0e279f8
7
+ data.tar.gz: 0f78a3b25a090f9823feae392358281e10f1828a0cb4d324ad880a0a877e0ae34d8cd9d4fbedf7018fe847b5dff1b2f2c6a27702eebb61d714afb46faf4eda7a
checksums.yaml.gz.sig CHANGED
Binary file
@@ -32,29 +32,30 @@ Process::Metrics::General.capture(pid: Process.pid)
32
32
  # =>
33
33
  # {3517456=>
34
34
  # #<struct Process::Metrics::General
35
- # pid=3517456,
36
- # ppid=3517432,
37
- # pgid=3517456,
35
+ # process_id=3517456,
36
+ # parent_process_id=3517432,
37
+ # process_group_id=3517456,
38
38
  # processor_utilization=0.0,
39
- # vsz=0,
40
- # rss=486892,
41
- # time=29928,
42
- # etime=2593,
39
+ # virtual_size=445768278528,
40
+ # resident_size=20348928,
41
+ # processor_time=0.05,
42
+ # elapsed_time=2.0,
43
43
  # command="irb",
44
44
  # memory=
45
45
  # #<struct Process::Metrics::Memory
46
46
  # map_count=193,
47
- # total_size=486896,
48
- # resident_size=30556,
49
- # proportional_size=25672,
50
- # shared_clean_size=5008,
47
+ # resident_size=31289344,
48
+ # proportional_size=26288128,
49
+ # shared_clean_size=5128192,
51
50
  # shared_dirty_size=0,
52
- # private_clean_size=5180,
53
- # private_dirty_size=20368,
54
- # referenced_size=30548,
55
- # anonymous_size=20376,
51
+ # private_clean_size=5304320,
52
+ # private_dirty_size=20856832,
53
+ # referenced_size=31281152,
54
+ # anonymous_size=20865024,
56
55
  # swap_size=0,
57
- # proportional_swap_size=0>>}
56
+ # proportional_swap_size=0,
57
+ # minor_faults=0,
58
+ # major_faults=0>>}
58
59
  ```
59
60
 
60
61
  If you want to capture a tree of processes, you can specify the `ppid:` option instead.
@@ -67,8 +68,8 @@ The {ruby Process::Metrics::General} struct contains the following fields:
67
68
  - `parent_process_id` - Parent Process ID, the process ID of the process that started this process.
68
69
  - `process_group_id` - Process Group ID, the process group ID of the process, which can be shared by multiple processes.
69
70
  - `processor_utilization` - Processor Utilization (%), the percentage of CPU time used by the process (over a system-specific duration).
70
- - `total_size` - Memory Size (KB), the total size of the process's memory space (usually over-estimated as it doesn't take into account shared memory).
71
- - `resident_size` - Resident (Set) Size (KB), the amount of physical memory used by the process.
71
+ - `total_size` - Memory Size (bytes), the total size of the process's memory space (usually over-estimated as it doesn't take into account shared memory).
72
+ - `resident_size` - Resident (Set) Size (bytes), the amount of physical memory used by the process.
72
73
  - `processor_time` - CPU Time (s), the amount of CPU time used by the process.
73
74
  - `elapsed_time` - Elapsed Time (s), the amount of time the process has been running.
74
75
  - `command` - Command Name, the name of the command that started the process.
@@ -76,15 +77,15 @@ The {ruby Process::Metrics::General} struct contains the following fields:
76
77
  The {ruby Process::Metrics::Memory} struct contains the following fields:
77
78
 
78
79
  - `map_count` - Number of Memory Mappings, e.g. number of thread stacks, fiber stacks, shared libraries, memory mapped files, etc.
79
- - `resident_size` - Resident Memory Size (KB), the amount of physical memory used by the process.
80
- - `proportional_size` - Proportional Memory Size (KB), the amount of memory that the process is using, taking into account shared memory.
81
- - `shared_clean_size` - Shared Clean Memory Size (KB), the amount of shared memory that is clean (not modified).
82
- - `shared_dirty_size` - Shared Dirty Memory Size (KB), the amount of shared memory that is dirty (modified).
83
- - `private_clean_size` - Private Clean Memory Size (KB), the amount of private memory that is clean (not modified).
84
- - `private_dirty_size` - Private Dirty Memory Size (KB), the amount of private memory that is dirty (modified).
85
- - `referenced_size` - Referenced Memory Size (KB), active page-cache that isn't going to be reclaimed any time soon.
86
- - `anonymous_size` - Anonymous Memory Size (KB), mapped memory that isn't backed by a file.
87
- - `swap_size` - Swap Memory Size (KB), the amount of memory that has been swapped to disk.
88
- - `proportional_swap_size` - Proportional Swap Memory Size (KB), the amount of memory that has been swapped to disk, excluding shared memory.
80
+ - `resident_size` - Resident Memory Size (bytes), the amount of physical memory used by the process.
81
+ - `proportional_size` - Proportional Memory Size (bytes), the amount of memory that the process is using, taking into account shared memory.
82
+ - `shared_clean_size` - Shared Clean Memory Size (bytes), the amount of shared memory that is clean (not modified).
83
+ - `shared_dirty_size` - Shared Dirty Memory Size (bytes), the amount of shared memory that is dirty (modified).
84
+ - `private_clean_size` - Private Clean Memory Size (bytes), the amount of private memory that is clean (not modified).
85
+ - `private_dirty_size` - Private Dirty Memory Size (bytes), the amount of private memory that is dirty (modified).
86
+ - `referenced_size` - Referenced Memory Size (bytes), active page-cache that isn't going to be reclaimed any time soon.
87
+ - `anonymous_size` - Anonymous Memory Size (bytes), mapped memory that isn't backed by a file.
88
+ - `swap_size` - Swap Memory Size (bytes), the amount of memory that has been swapped to disk.
89
+ - `proportional_swap_size` - Proportional Swap Memory Size (bytes), the amount of memory that has been swapped to disk, excluding shared memory.
89
90
 
90
91
  In general, the interpretation of these fields is operating system specific. At best, they provide a rough estimate of the process's memory usage, but you should consult the documentation for your operating system for more details on exactly what each field represents.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2025, by Samuel Williams.
4
+ # Copyright, 2020-2026, by Samuel Williams.
5
5
 
6
6
  require "samovar"
7
7
 
@@ -90,23 +90,28 @@ module Process
90
90
  UNITS = ["KiB", "MiB", "GiB"]
91
91
 
92
92
  # Format a memory size value in human-readable units.
93
- # @parameter value [Numeric] The size value in kilobytes.
93
+ # @parameter value [Numeric] The size value in bytes.
94
94
  # @parameter units [Array(String)] The unit labels to use for scaling.
95
95
  # @returns [String] A formatted string with value and unit (e.g., "512KiB", "1.5MiB").
96
96
  def format_size(value, units: UNITS)
97
- unit = 0
97
+ unit = -1
98
98
 
99
- while value > 1024.0 && unit < units.size
99
+ while value >= 1024.0 && unit < units.size - 1
100
100
  value /= 1024.0
101
101
  unit += 1
102
102
  end
103
103
 
104
- return "#{value.round(unit)}#{units[unit]}"
104
+ if unit < 0
105
+ # Value is less than 1 KiB, show in bytes
106
+ return "#{value.round(0)}B"
107
+ else
108
+ return "#{value.round(unit)}#{units[unit]}"
109
+ end
105
110
  end
106
111
 
107
112
  # Format a memory value with a horizontal bar showing utilization relative to total.
108
- # @parameter value [Numeric] The memory value in kilobytes.
109
- # @parameter total [Numeric] The total memory available in kilobytes.
113
+ # @parameter value [Numeric] The memory value in bytes.
114
+ # @parameter total [Numeric] The total memory available in bytes.
110
115
  # @parameter terminal [Console::Terminal] The terminal to output styled text.
111
116
  def format_memory(value, total, terminal)
112
117
  if value > (total * 0.8)
@@ -123,10 +128,11 @@ module Process
123
128
  end
124
129
 
125
130
  # Get the total memory to use for percentage calculations.
126
- # @returns [Integer] Total memory in kilobytes.
131
+ # @returns [Integer] Total memory in bytes.
127
132
  def total_memory
128
133
  if total_memory = @options[:total_memory]
129
- return total_memory * 1024
134
+ # Convert from MiB to bytes
135
+ return total_memory * 1024 * 1024
130
136
  else
131
137
  return Process::Metrics::Memory.total_size
132
138
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
+
6
+ require "etc"
7
+
8
+ module Process
9
+ module Metrics
10
+ # General process information by reading /proc. Used on Linux to avoid spawning `ps`.
11
+ # We read directly from the kernel (proc(5)) so there is no subprocess and no parsing of
12
+ # external command output; same data source as the kernel uses for process accounting.
13
+ # Parses /proc/[pid]/stat and /proc/[pid]/cmdline for each process.
14
+ module General::Linux
15
+ # Clock ticks per second for /proc stat times (utime, stime, starttime).
16
+ CLK_TCK = Etc.sysconf(Etc::SC_CLK_TCK) rescue 100
17
+
18
+ # Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
19
+ PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096
20
+
21
+ # Whether /proc is available so we can list processes without ps.
22
+ def self.supported?
23
+ File.directory?("/proc") && File.readable?("/proc/self/stat")
24
+ end
25
+
26
+ # Capture process information from /proc. If given `pid`, captures only those process(es). If given `ppid`, captures that parent and all descendants. Both can be given to capture a process and its children.
27
+ # @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
28
+ # @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
29
+ # @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
30
+ # @returns [Hash<Integer, General>] Map of PID to General instance.
31
+ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
32
+ # When filtering by ppid we need the full process list to build the parent-child tree,
33
+ # so we enumerate all numeric /proc entries; when only pid is set we read just those.
34
+ pids_to_read = if pid && ppid.nil?
35
+ Array(pid)
36
+ else
37
+ Dir.children("/proc").filter{|e| e.match?(/\A\d+\z/)}.map(&:to_i)
38
+ end
39
+
40
+ uptime_jiffies = nil
41
+
42
+ processes = {}
43
+ pids_to_read.each do |pid|
44
+ stat_path = "/proc/#{pid}/stat"
45
+ next unless File.readable?(stat_path)
46
+
47
+ stat_content = File.read(stat_path)
48
+ # comm field can contain spaces and parentheses; find the closing ')' (proc(5)).
49
+ closing_paren_index = stat_content.rindex(")")
50
+ next unless closing_paren_index
51
+
52
+ executable_name = stat_content[1...closing_paren_index]
53
+ fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
54
+ # After comm: state(3), ppid(4), pgrp(5), ... utime(14), stime(15), ... starttime(22), vsz(23), rss(24). 0-based: ppid=1, pgrp=2, utime=11, stime=12, starttime=19, vsz=20, rss=21.
55
+ parent_process_id = fields[1].to_i
56
+ process_group_id = fields[2].to_i
57
+ utime = fields[11].to_i
58
+ stime = fields[12].to_i
59
+ starttime = fields[19].to_i
60
+ virtual_size = fields[20].to_i
61
+ resident_pages = fields[21].to_i
62
+
63
+ # Read /proc/uptime once per capture and reuse for every process (starttime is in jiffies since boot).
64
+ uptime_jiffies ||= begin
65
+ uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
66
+ (uptime_seconds * CLK_TCK).to_i
67
+ end
68
+
69
+ processor_time = (utime + stime).to_f / CLK_TCK
70
+ elapsed_time = [(uptime_jiffies - starttime).to_f / CLK_TCK, 0.0].max
71
+
72
+ command = read_command(pid, executable_name)
73
+
74
+ processes[pid] = General.new(
75
+ pid,
76
+ parent_process_id,
77
+ process_group_id,
78
+ 0.0, # processor_utilization: would need two samples; not available from single stat read
79
+ virtual_size,
80
+ resident_pages * PAGE_SIZE,
81
+ processor_time,
82
+ elapsed_time,
83
+ command,
84
+ nil
85
+ )
86
+ rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
87
+ # Process disappeared or we can't read it.
88
+ next
89
+ end
90
+
91
+ # Restrict to the requested pid/ppid subtree using the same tree logic as the ps backend.
92
+ if ppid
93
+ pids = Set.new
94
+ hierarchy = General.build_tree(processes)
95
+ General.expand_children(Array(pid), hierarchy, pids) if pid
96
+ General.expand_children(Array(ppid), hierarchy, pids)
97
+ processes.select!{|process_id, _| pids.include?(process_id)}
98
+ end
99
+
100
+ General.capture_memory(processes) if memory
101
+
102
+ processes
103
+ end
104
+
105
+ # Read command line from /proc/[pid]/cmdline; fall back to executable name from stat if empty.
106
+ # Use binread because cmdline is NUL-separated and may contain non-UTF-8 bytes; we split on NUL and join for display.
107
+ def self.read_command(pid, command_fallback)
108
+ path = "/proc/#{pid}/cmdline"
109
+ return command_fallback unless File.readable?(path)
110
+
111
+ cmdline_content = File.binread(path)
112
+ return command_fallback if cmdline_content.empty?
113
+
114
+ # cmdline is NUL-separated; replace with spaces for display.
115
+ cmdline_content.split("\0").join(" ").strip
116
+ rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
117
+ command_fallback
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ if Process::Metrics::General::Linux.supported?
124
+ class << Process::Metrics::General
125
+ def capture(...)
126
+ Process::Metrics::General::Linux.capture(...)
127
+ end
128
+ end
129
+ else
130
+ require_relative "process_status"
131
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
+
6
+ module Process
7
+ module Metrics
8
+ # General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
9
+ # where there is no /proc; ps is the portable way to get pid, ppid, times, and memory in one pass.
10
+ module General::ProcessStatus
11
+ PS = "ps"
12
+
13
+ # The fields that will be extracted from the `ps` command (order matches -o output).
14
+ FIELDS = {
15
+ pid: ->(value){value.to_i},
16
+ ppid: ->(value){value.to_i},
17
+ pgid: ->(value){value.to_i},
18
+ pcpu: ->(value){value.to_f},
19
+ vsz: ->(value){value.to_i * 1024},
20
+ rss: ->(value){value.to_i * 1024},
21
+ time: Process::Metrics.method(:duration),
22
+ etime: Process::Metrics.method(:duration),
23
+ command: ->(value){value},
24
+ }
25
+
26
+ # Whether process listing via ps is available on this system.
27
+ def self.supported?
28
+ system("which", PS, out: File::NULL, err: File::NULL)
29
+ end
30
+
31
+ # Capture process information using ps. If given a `pid`, captures that process; if given `ppid`, captures that process and all descendants. Specify both to capture a process and its children.
32
+ # @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
33
+ # @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
34
+ # @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
35
+ # @returns [Hash<Integer, General>] Map of PID to General instance.
36
+ def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
37
+ spawned_pid = nil
38
+
39
+ header, *lines = IO.pipe do |input, output|
40
+ arguments = [PS]
41
+
42
+ # When filtering by ppid we need the full process list to build the tree, so use "ax"; otherwise limit to -p.
43
+ if pid && ppid.nil?
44
+ arguments.push("-p", Array(pid).join(","))
45
+ else
46
+ arguments.push("ax")
47
+ end
48
+
49
+ arguments.push("-o", FIELDS.keys.join(","))
50
+
51
+ spawned_pid = Process.spawn(*arguments, out: output)
52
+ output.close
53
+
54
+ input.readlines.map(&:strip)
55
+ ensure
56
+ input.close
57
+
58
+ # Always kill and reap the ps subprocess so we never leave it hanging if the pipe closes early.
59
+ if spawned_pid
60
+ begin
61
+ Process.kill(:KILL, spawned_pid)
62
+ Process.wait(spawned_pid)
63
+ rescue => error
64
+ warn "Failed to cleanup ps process #{spawned_pid}:\n#{error.full_message}"
65
+ end
66
+ end
67
+ end
68
+
69
+ processes = {}
70
+
71
+ lines.each do |line|
72
+ next if line.empty?
73
+
74
+ values = line.split(/\s+/, FIELDS.size)
75
+ next if values.size < FIELDS.size
76
+
77
+ record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
78
+ instance = General.new(*record, nil)
79
+ processes[instance.process_id] = instance
80
+ end
81
+
82
+ # Restrict to the requested pid/ppid subtree; exclude our own ps process from the result.
83
+ if ppid
84
+ pids = Set.new
85
+ hierarchy = General.build_tree(processes)
86
+ General.expand_children(Array(pid), hierarchy, pids) if pid
87
+ General.expand_children(Array(ppid), hierarchy, pids)
88
+ processes.select!{|process_id, _| process_id != spawned_pid && pids.include?(process_id)}
89
+ else
90
+ processes.delete(spawned_pid) if spawned_pid
91
+ end
92
+
93
+ General.capture_memory(processes) if memory
94
+
95
+ processes
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ # Wire General.capture to this implementation when ProcessStatus is available and the Linux backend is not active (so Linux can load both for comparison tests).
102
+ linux_supported = defined?(Process::Metrics::General::Linux) && Process::Metrics::General::Linux.supported?
103
+ if Process::Metrics::General::ProcessStatus.supported? && !linux_supported
104
+ class << Process::Metrics::General
105
+ def capture(...)
106
+ Process::Metrics::General::ProcessStatus.capture(...)
107
+ end
108
+ end
109
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "memory"
7
7
  require "set"
@@ -9,8 +9,6 @@ require "json"
9
9
 
10
10
  module Process
11
11
  module Metrics
12
- PS = "ps"
13
-
14
12
  DURATION = /\A
15
13
  (?:(?<days>\d+)-)? # Optional days (e.g., '2-')
16
14
  (?:(?<hours>\d+):)? # Optional hours (e.g., '1:')
@@ -36,19 +34,6 @@ module Process
36
34
  end
37
35
  end
38
36
 
39
- # The fields that will be extracted from the `ps` command.
40
- FIELDS = {
41
- pid: ->(value){value.to_i}, # Process ID
42
- ppid: ->(value){value.to_i}, # Parent Process ID
43
- pgid: ->(value){value.to_i}, # Process Group ID
44
- pcpu: ->(value){value.to_f}, # Percentage CPU
45
- vsz: ->(value){value.to_i}, # Virtual Size (KiB)
46
- rss: ->(value){value.to_i}, # Resident Size (KiB)
47
- time: self.method(:duration), # CPU Time (seconds)
48
- etime: self.method(:duration), # Elapsed Time (seconds)
49
- command: ->(value){value}, # Command (name of the process)
50
- }
51
-
52
37
  # General process information.
53
38
  class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :command, :memory)
54
39
  # Convert the object to a JSON serializable hash.
@@ -73,7 +58,7 @@ module Process
73
58
  as_json.to_json(*arguments)
74
59
  end
75
60
 
76
- # The total size of the process in memory, in kilobytes.
61
+ # The total size of the process in memory, in bytes.
77
62
  def total_size
78
63
  if memory = self.memory
79
64
  memory.proportional_size
@@ -132,77 +117,13 @@ module Process
132
117
  process.memory = Memory.capture(pid, count: count)
133
118
  end
134
119
  end
135
-
136
- # Capture process information. If given a `pid`, it will capture the details of that process. If given a `ppid`, it will capture the details of all child processes. Specify both `pid` and `ppid` if you want to capture a process and all its children.
137
- #
138
- # @parameter pid [Integer] The process ID to capture.
139
- # @parameter ppid [Integer] The parent process ID to capture.
140
- def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
141
- ps_pid = nil
142
-
143
- # Extract the information from the `ps` command:
144
- header, *lines = IO.pipe do |input, output|
145
- arguments = [PS]
146
-
147
- if pid && ppid.nil?
148
- arguments.push("-p", Array(pid).join(","))
149
- else
150
- arguments.push("ax")
151
- end
152
-
153
- arguments.push("-o", FIELDS.keys.join(","))
154
-
155
- ps_pid = Process.spawn(*arguments, out: output)
156
- output.close
157
-
158
- input.readlines.map(&:strip)
159
- ensure
160
- input.close
161
-
162
- if ps_pid
163
- begin
164
- # Make sure to kill the ps process if it's still running:
165
- Process.kill(:KILL, ps_pid)
166
- # Reap the process:
167
- Process.wait(ps_pid)
168
- rescue => error
169
- warn "Failed to cleanup ps process #{ps_pid}:\n#{error.full_message}"
170
- end
171
- end
172
- end
173
-
174
- processes = {}
175
-
176
- lines.map do |line|
177
- record = FIELDS.
178
- zip(line.split(/\s+/, FIELDS.size)).
179
- map{|(key, type), value| type.call(value)}
180
- instance = self.new(*record)
181
-
182
- processes[instance.process_id] = instance
183
- end
184
-
185
- if ppid
186
- pids = Set.new
187
-
188
- hierarchy = self.build_tree(processes)
189
-
190
- self.expand_children(Array(pid), hierarchy, pids)
191
- self.expand_children(Array(ppid), hierarchy, pids)
192
-
193
- processes.select! do |pid, process|
194
- if pid != ps_pid
195
- pids.include?(pid)
196
- end
197
- end
198
- end
199
-
200
- if memory
201
- self.capture_memory(processes)
202
- end
203
-
204
- return processes
205
- end
206
120
  end
207
121
  end
208
122
  end
123
+
124
+ # One backend provides General.capture: Linux uses /proc (no subprocess); other platforms use ps.
125
+ if RUBY_PLATFORM.include?("linux")
126
+ require_relative "general/linux"
127
+ else
128
+ require_relative "general/process_status"
129
+ end
@@ -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 size_string [String | Nil] The size string from sysctl vm.swapusage.
14
+ # @returns [Integer | Nil] Size in bytes, or nil if size_string is nil/empty.
15
+ def self.parse_swap_size(size_string)
16
+ return nil unless size_string
17
+
18
+ size_string = size_string.strip
19
+
20
+ case size_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 size_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
@@ -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_content = self.meminfo
65
+ return nil
66
+ end
67
+
68
+ meminfo_content.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_content = self.meminfo
94
+ return nil
95
+ end
96
+
97
+ available_kilobytes = meminfo_content[/MemAvailable:\s*(\d+)\s*kB/, 1]&.to_i
98
+ available_kilobytes ||= meminfo_content[/MemFree:\s*(\d+)\s*kB/, 1]&.to_i
99
+ return nil unless available_kilobytes
100
+
101
+ return [total - (available_kilobytes * 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_content = self.meminfo
108
+ swap_total_kilobytes = meminfo_content[/SwapTotal:\s*(\d+)\s*kB/, 1]&.to_i
109
+ swap_free_kilobytes = meminfo_content[/SwapFree:\s*(\d+)\s*kB/, 1]&.to_i
110
+
111
+ return [nil, nil] unless swap_total_kilobytes
112
+
113
+ swap_total_bytes = swap_total_kilobytes * 1024
114
+ swap_used_bytes = (swap_total_kilobytes - (swap_free_kilobytes || 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
@@ -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
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Process
7
7
  module Metrics
8
- # Darwin (macOS) implementation of memory metrics using vmmap.
8
+ # Darwin (macOS) implementation of per-process memory metrics using vmmap(1).
9
+ # Parses vmmap output for virtual/resident/dirty/swap per region and maps sharing mode (PRV, COW, SHM) to private/shared fields.
9
10
  class Memory::Darwin
10
11
  VMMAP = "/usr/bin/vmmap"
11
12
 
@@ -14,32 +15,21 @@ module Process
14
15
  File.executable?(VMMAP)
15
16
  end
16
17
 
17
- # @returns [Numeric] Total memory size in kilobytes.
18
- def self.total_size
19
- # sysctl hw.memsize
20
- IO.popen(["sysctl", "hw.memsize"], "r") do |io|
21
- io.each_line do |line|
22
- if line =~ /hw.memsize: (\d+)/
23
- return $1.to_i / 1024
24
- end
25
- end
26
- end
27
- end
28
-
29
- # Parse a size string from vmmap output into kilobytes.
30
- # @parameter string [String | Nil] The size string (e.g., "4K", "1.5M", "2G").
31
- # @returns [Integer] The size in kilobytes.
32
- def self.parse_size(string)
33
- return 0 unless string
18
+ # Parse a size string from vmmap (e.g. "4K", "1.5M", "2G") into bytes.
19
+ # @parameter size_string [String | Nil]
20
+ # @returns [Integer]
21
+ def self.parse_size(size_string)
22
+ return 0 unless size_string
34
23
 
35
- case string.strip
36
- when /([\d\.]+)K/i then ($1.to_f).round
37
- when /([\d\.]+)M/i then ($1.to_f * 1024).round
38
- when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024).round
39
- else (string.to_f / 1024).ceil
24
+ case size_string.strip
25
+ when /([\d\.]+)K/i then ($1.to_f * 1024).round
26
+ when /([\d\.]+)M/i then ($1.to_f * 1024 * 1024).round
27
+ when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
28
+ else (size_string.to_f).ceil
40
29
  end
41
30
  end
42
31
 
32
+ # Regex for vmmap region lines: region name, address range, [virtual resident dirty swap], permissions, SM=sharing_mode.
43
33
  LINE = /\A
44
34
  \s*
45
35
  (?<region_name>.+?)\s+
@@ -49,26 +39,24 @@ module Process
49
39
  SM=(?<sharing_mode>\w+)
50
40
  /x
51
41
 
52
- # Capture memory usage for the given process IDs.
42
+ # Capture memory usage by running vmmap for the given pid and summing region sizes. Proportional size is estimated as resident_size / count (Darwin has no PSS).
43
+ # @parameter pid [Integer] Process ID.
44
+ # @parameter count [Integer] Number of processes for proportional estimate (default: 1).
45
+ # @returns [Memory | Nil]
53
46
  def self.capture(pid, count: 1, **options)
54
47
  IO.popen(["vmmap", pid.to_s], "r") do |io|
55
48
  usage = Memory.zero
56
-
57
49
  io.each_line do |line|
58
50
  if match = LINE.match(line)
59
51
  usage.map_count += 1
60
-
61
52
  virtual_size = parse_size(match[:virtual_size])
62
53
  resident_size = parse_size(match[:resident_size])
63
54
  dirty_size = parse_size(match[:dirty_size])
64
55
  swap_size = parse_size(match[:swap_size])
65
-
66
56
  usage.resident_size += resident_size
67
57
  usage.swap_size += swap_size
68
58
 
69
- # Private vs. Shared memory
70
- # COW=copy_on_write PRV=private NUL=empty ALI=aliased
71
- # SHM=shared ZER=zero_filled S/A=shared_alias
59
+ # Private vs. Shared memory: COW=copy_on_write PRV=private NUL=empty ALI=aliased SHM=shared ZER=zero_filled S/A=shared_alias
72
60
  case match[:sharing_mode]
73
61
  when "PRV"
74
62
  usage.private_clean_size += resident_size - dirty_size
@@ -85,10 +73,8 @@ module Process
85
73
  end
86
74
  end
87
75
 
88
- if usage.map_count.zero?
89
- # vmap might not fail, but also might not return any data.
90
- return nil
91
- end
76
+ # vmmap might not fail, but also might not return any data.
77
+ return nil if usage.map_count.zero?
92
78
 
93
79
  # Darwin does not expose proportional memory usage, so we guess based on the number of processes. Yes, this is a terrible hack, but it's the most reasonable thing to do given the constraints:
94
80
  usage.proportional_size = usage.resident_size / count
@@ -101,29 +87,24 @@ module Process
101
87
  return nil
102
88
  end
103
89
  end
90
+ end
91
+ end
92
+
93
+ # Wire Memory.capture and Memory.supported? to this implementation when vmmap is executable.
94
+ if Process::Metrics::Memory::Darwin.supported?
95
+ class << Process::Metrics::Memory
96
+ # Whether memory capture is supported on this platform.
97
+ # @returns [Boolean] True if vmmap is available.
98
+ def supported?
99
+ true
100
+ end
104
101
 
105
- if Memory::Darwin.supported?
106
- class << Memory
107
- # Whether memory capture is supported on this platform.
108
- # @returns [Boolean] True if vmmap is available.
109
- def supported?
110
- return true
111
- end
112
-
113
- # Get total system memory size.
114
- # @returns [Integer] Total memory in kilobytes.
115
- def total_size
116
- return Memory::Darwin.total_size
117
- end
118
-
119
- # Capture memory metrics for a process.
120
- # @parameter pid [Integer] The process ID.
121
- # @parameter options [Hash] Additional options (e.g., count for proportional estimates).
122
- # @returns [Memory] A Memory instance with captured metrics.
123
- def capture(...)
124
- return Memory::Darwin.capture(...)
125
- end
126
- end
102
+ # Capture memory metrics for a process.
103
+ # @parameter pid [Integer] The process ID.
104
+ # @parameter options [Hash] Additional options (e.g. count for proportional estimates).
105
+ # @returns [Memory | Nil] A Memory instance with captured metrics.
106
+ def capture(...)
107
+ Process::Metrics::Memory::Darwin.capture(...)
127
108
  end
128
109
  end
129
110
  end
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Process
7
7
  module Metrics
8
- # Linux implementation of memory metrics using `/proc/[pid]/smaps` and `/proc/[pid]/stat`.
8
+ # Linux implementation of per-process memory metrics using `/proc/[pid]/smaps` or `/proc/[pid]/smaps_rollup`, and `/proc/[pid]/stat` for fault counters.
9
+ # Prefers smaps_rollup when readable (single summary); otherwise falls back to full smaps and counts maps from /proc/[pid]/maps.
9
10
  class Memory::Linux
10
- # Extract minor/major page fault counters from `/proc/[pid]/stat` and assign to usage.
11
- # @parameter pid [Integer] The process ID.
12
- # @parameter usage [Memory] The Memory instance to populate with fault counters.
11
+ # Extract minor and major page fault counters from `/proc/[pid]/stat` (proc(5): fields 10=minflt, 12=majflt) and assign to usage.
12
+ # @parameter pid [Integer] Process ID.
13
+ # @parameter usage [Memory] Memory instance to populate with minor_faults and major_faults.
13
14
  def self.capture_faults(pid, usage)
14
- stat = File.read("/proc/#{pid}/stat")
15
+ stat_content = File.read("/proc/#{pid}/stat")
15
16
  # The comm field can contain spaces and parentheses; find the closing ')':
16
- rparen_index = stat.rindex(")")
17
- return unless rparen_index
18
- fields = stat[(rparen_index+2)..-1].split(/\s+/)
17
+ closing_paren_index = stat_content.rindex(")")
18
+ return unless closing_paren_index
19
+ fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
19
20
  # proc(5): field 10=minflt, 12=majflt; our fields array is 0-indexed from field 3.
20
21
  usage.minor_faults = fields[10-3].to_i
21
22
  usage.major_faults = fields[12-3].to_i
@@ -23,16 +24,7 @@ module Process
23
24
  # Ignore.
24
25
  end
25
26
 
26
- # @returns [Numeric] Total memory size in kilobytes.
27
- def self.total_size
28
- File.read("/proc/meminfo").each_line do |line|
29
- if /MemTotal:\s+(?<total>\d+) kB/ =~ line
30
- return total.to_i
31
- end
32
- end
33
- end
34
-
35
- # The fields that will be extracted from the `smaps` data.
27
+ # Mapping from smaps/smaps_rollup line names to Memory struct members (values in kB, converted to bytes when parsing).
36
28
  SMAP = {
37
29
  "Rss" => :resident_size,
38
30
  "Pss" => :proportional_size,
@@ -52,22 +44,28 @@ module Process
52
44
  true
53
45
  end
54
46
 
55
- # Capture memory usage for the given process IDs.
56
- def self.capture(pid, **options)
47
+ # Capture memory usage from /proc/[pid]/smaps_rollup and /proc/[pid]/maps. Optionally fill fault counters from /proc/[pid]/stat.
48
+ # @parameter pid [Integer] Process ID.
49
+ # @parameter faults [Boolean] Whether to capture minor_faults and major_faults (default: true).
50
+ # @returns [Memory | Nil]
51
+ def self.capture(pid, faults: true, **options)
57
52
  File.open("/proc/#{pid}/smaps_rollup") do |file|
58
53
  usage = Memory.zero
59
-
60
54
  file.each_line do |line|
61
55
  if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
62
56
  if key = SMAP[name]
63
- usage[key] += value.to_i
57
+ # Convert from kilobytes to bytes:
58
+ usage[key] += value.to_i * 1024
64
59
  end
65
60
  end
66
61
  end
67
62
 
68
63
  usage.map_count += File.readlines("/proc/#{pid}/maps").size
69
- # Also capture fault counters:
70
- self.capture_faults(pid, usage)
64
+
65
+ # Also capture fault counters if requested:
66
+ if faults
67
+ self.capture_faults(pid, usage)
68
+ end
71
69
 
72
70
  return usage
73
71
  end
@@ -81,17 +79,20 @@ module Process
81
79
  true
82
80
  end
83
81
 
84
- # Capture memory usage for the given process IDs.
85
- def self.capture(pid, **options)
82
+ # Capture memory usage from /proc/[pid]/smaps (and map count from VmFlags) and /proc/[pid]/maps. Optionally fill fault counters from /proc/[pid]/stat.
83
+ # @parameter pid [Integer] Process ID.
84
+ # @parameter faults [Boolean] Whether to capture minor_faults and major_faults (default: true).
85
+ # @returns [Memory | Nil]
86
+ def self.capture(pid, faults: true, **options)
86
87
  File.open("/proc/#{pid}/smaps") do |file|
87
88
  usage = Memory.zero
88
-
89
89
  file.each_line do |line|
90
90
  # The format of this is fixed according to:
91
91
  # https://github.com/torvalds/linux/blob/351c8a09b00b5c51c8f58b016fffe51f87e2d820/fs/proc/task_mmu.c#L804-L814
92
92
  if /(?<name>.*?):\s+(?<value>\d+) kB/ =~ line
93
93
  if key = SMAP[name]
94
- usage[key] += value.to_i
94
+ # Convert from kilobytes to bytes:
95
+ usage[key] += value.to_i * 1024
95
96
  end
96
97
  elsif /VmFlags:\s+(?<flags>.*)/ =~ line
97
98
  # It should be possible to extract the number of fibers and each fiber's memory usage.
@@ -100,8 +101,10 @@ module Process
100
101
  end
101
102
  end
102
103
 
103
- # Also capture fault counters:
104
- self.capture_faults(pid, usage)
104
+ # Also capture fault counters if requested:
105
+ if faults
106
+ self.capture_faults(pid, usage)
107
+ end
105
108
 
106
109
  return usage
107
110
  end
@@ -115,29 +118,25 @@ module Process
115
118
  end
116
119
  end
117
120
  end
121
+ end
122
+ end
123
+
124
+ # Wire Memory.capture and Memory.supported? to this implementation when smaps or smaps_rollup is readable.
125
+ if Process::Metrics::Memory::Linux.supported?
126
+ class << Process::Metrics::Memory
127
+ # Whether memory capture is supported on this platform.
128
+ # @returns [Boolean] True if /proc/[pid]/smaps or smaps_rollup is readable.
129
+ def supported?
130
+ true
131
+ end
118
132
 
119
- if Memory::Linux.supported?
120
- class << Memory
121
- # Whether memory capture is supported on this platform.
122
- # @returns [Boolean] True if /proc/[pid]/smaps or smaps_rollup is readable.
123
- def supported?
124
- return true
125
- end
126
-
127
- # Get total system memory size.
128
- # @returns [Integer] Total memory in kilobytes.
129
- def total_size
130
- return Memory::Linux.total_size
131
- end
132
-
133
- # Capture memory metrics for a process.
134
- # @parameter pid [Integer] The process ID.
135
- # @parameter options [Hash] Additional options.
136
- # @returns [Memory] A Memory instance with captured metrics.
137
- def capture(...)
138
- return Memory::Linux.capture(...)
139
- end
140
- end
133
+ # Capture memory metrics for a process.
134
+ # @parameter pid [Integer] The process ID.
135
+ # @parameter faults [Boolean] Whether to capture fault counters (default: true).
136
+ # @parameter options [Hash] Additional options.
137
+ # @returns [Memory | Nil] A Memory instance with captured metrics.
138
+ def capture(...)
139
+ Process::Metrics::Memory::Linux.capture(...)
141
140
  end
142
141
  end
143
142
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require "json"
7
+ require_relative "host/memory"
7
8
 
8
9
  module Process
9
10
  module Metrics
10
- # Represents memory usage for a process, sizes are in kilobytes.
11
+ # Represents memory usage for a process, sizes are in bytes.
11
12
  class Memory < Struct.new(:map_count, :resident_size, :proportional_size, :shared_clean_size, :shared_dirty_size, :private_clean_size, :private_dirty_size, :referenced_size, :anonymous_size, :swap_size, :proportional_swap_size, :minor_faults, :major_faults)
12
13
 
13
14
  alias as_json to_h
@@ -33,6 +34,12 @@ module Process
33
34
  self.new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
34
35
  end
35
36
 
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
+
36
43
  # Whether the memory usage can be captured on this system.
37
44
  def self.supported?
38
45
  false
@@ -46,5 +53,8 @@ module Process
46
53
  end
47
54
  end
48
55
 
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
@@ -7,6 +7,6 @@
7
7
  module Process
8
8
  # @namespace
9
9
  module Metrics
10
- VERSION = "0.8.0"
10
+ VERSION = "0.10.0"
11
11
  end
12
12
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2019-2025, by Samuel Williams.
3
+ Copyright, 2019-2026, by Samuel Williams.
4
4
  Copyright, 2024, by Adam Daniels.
5
5
 
6
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
data/readme.md CHANGED
@@ -16,6 +16,22 @@ Please see the [project documentation](https://socketry.github.io/process-metric
16
16
 
17
17
  Please see the [project releases](https://socketry.github.io/process-metrics/releases/index) for all releases.
18
18
 
19
+ ### v0.10.0
20
+
21
+ - **Host::Memory**: New per-host struct `Process::Metrics::Host::Memory` with `total`, `used`, `free`, `swap_total`, `swap_used` (all bytes). Use `Host::Memory.capture` to get a snapshot; `.supported?` indicates platform support.
22
+
23
+ ### v0.9.0
24
+
25
+ - `Process::Metrics::Memory.total_size` takes into account cgroup limits.
26
+ - On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
27
+ - Report all sizes in bytes for consistency.
28
+
29
+ ### v0.8.0
30
+
31
+ - Kill `ps` before waiting to avoid hanging when using the process-status backend.
32
+ - Ignore `Errno::EACCES` when reading process information.
33
+ - Cleaner process management for the `ps`-based capture path.
34
+
19
35
  ### v0.7.0
20
36
 
21
37
  - Be more proactive about returning nil if memory capture failed.
@@ -55,24 +71,6 @@ Please see the [project releases](https://socketry.github.io/process-metrics/rel
55
71
  - Added missing dependencies: `bake-test-external` and `json` gem.
56
72
  - Added summary lines for PSS (Proportional Set Size) and USS (Unique Set Size).
57
73
 
58
- ### v0.2.1
59
-
60
- - Added missing dependency to gemspec.
61
- - Added example of command line usage to documentation.
62
- - Renamed `rsz` to `rss` (Resident Set Size) for consistency across Darwin and Linux platforms.
63
-
64
- ### v0.2.0
65
-
66
- - Added `process-metrics` command line interface for monitoring processes.
67
- - Implemented structured data using Ruby structs for better performance and clarity.
68
- - Added documentation about PSS (Proportional Set Size) and USS (Unique Set Size) metrics.
69
-
70
- ### v0.1.1
71
-
72
- - Removed `Gemfile.lock` from version control.
73
- - Fixed process metrics to exclude the `ps` command itself from measurements.
74
- - Fixed documentation formatting issues.
75
-
76
74
  ## Contributing
77
75
 
78
76
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Releases
2
2
 
3
+ ## v0.10.0
4
+
5
+ - **Host::Memory**: New per-host struct `Process::Metrics::Host::Memory` with `total`, `used`, `free`, `swap_total`, `swap_used` (all bytes). Use `Host::Memory.capture` to get a snapshot; `.supported?` indicates platform support.
6
+
7
+ ## v0.9.0
8
+
9
+ - `Process::Metrics::Memory.total_size` takes into account cgroup limits.
10
+ - On Linux, capturing faults is optional, controlled by `capture(faults: true/false)`.
11
+ - Report all sizes in bytes for consistency.
12
+
13
+ ## v0.8.0
14
+
15
+ - Kill `ps` before waiting to avoid hanging when using the process-status backend.
16
+ - Ignore `Errno::EACCES` when reading process information.
17
+ - Cleaner process management for the `ps`-based capture path.
18
+
3
19
  ## v0.7.0
4
20
 
5
21
  - Be more proactive about returning nil if memory capture failed.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: process-metrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -94,6 +94,11 @@ files:
94
94
  - lib/process/metrics/command/summary.rb
95
95
  - lib/process/metrics/command/top.rb
96
96
  - lib/process/metrics/general.rb
97
+ - lib/process/metrics/general/linux.rb
98
+ - lib/process/metrics/general/process_status.rb
99
+ - lib/process/metrics/host/memory.rb
100
+ - lib/process/metrics/host/memory/darwin.rb
101
+ - lib/process/metrics/host/memory/linux.rb
97
102
  - lib/process/metrics/memory.rb
98
103
  - lib/process/metrics/memory/darwin.rb
99
104
  - lib/process/metrics/memory/linux.rb
@@ -122,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
127
  - !ruby/object:Gem::Version
123
128
  version: '0'
124
129
  requirements: []
125
- rubygems_version: 3.6.9
130
+ rubygems_version: 4.0.3
126
131
  specification_version: 4
127
132
  summary: Provide detailed OS-specific process metrics.
128
133
  test_files: []
metadata.gz.sig CHANGED
Binary file