Skip to content

Commit af1255a

Browse files
Prefer /proc over ps on Linux for general metrics. (#6)
1 parent 4531c2d commit af1255a

9 files changed

Lines changed: 372 additions & 117 deletions

File tree

lib/process/metrics/general.rb

Lines changed: 7 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
module Process
1111
module Metrics
12-
PS = "ps"
13-
1412
DURATION = /\A
1513
(?:(?<days>\d+)-)? # Optional days (e.g., '2-')
1614
(?:(?<hours>\d+):)? # Optional hours (e.g., '1:')
@@ -36,19 +34,6 @@ def self.duration(value)
3634
end
3735
end
3836

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 * 1024}, # Virtual Size (convert from KiB to bytes)
46-
rss: ->(value){value.to_i * 1024}, # Resident Size (convert from KiB to bytes)
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-
5237
# General process information.
5338
class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :command, :memory)
5439
# Convert the object to a JSON serializable hash.
@@ -132,77 +117,13 @@ def self.capture_memory(processes)
132117
process.memory = Memory.capture(pid, count: count)
133118
end
134119
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
206120
end
207121
end
208122
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
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@ module Host
1010
# Uses sysctl (hw.memsize), vm_stat (free + inactive pages), and vm.swapusage for swap.
1111
class Memory::Darwin
1212
# 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
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
1717

18-
string = string.strip
18+
size_string = size_string.strip
1919

20-
case string
20+
case size_string
2121
when /([\d.]+)M/i then ($1.to_f * 1024 * 1024).round
2222
when /([\d.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
2323
when /([\d.]+)K/i then ($1.to_f * 1024).round
24-
else string.to_f.round
24+
else size_string.to_f.round
2525
end
2626
end
2727

0 commit comments

Comments
 (0)