module Linux
General process information by reading /proc. Used on Linux to avoid spawning ps.
We read directly from the kernel (proc(5)) so there is no subprocess and no parsing of
external command output; same data source as the kernel uses for process accounting.
Parses /proc/[pid]/stat and /proc/[pid]/cmdline for each process.
Definitions
CLK_TCK = Etc.sysconf(Etc::SC_CLK_TCK) rescue 100
Clock ticks per second for /proc stat times (utime, stime, starttime).
PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096
Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
def self.supported?
Whether /proc is available so we can list processes without ps.
Implementation
def self.supported?
File.directory?("/proc") && File.readable?("/proc/self/stat")
end
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
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.
Signature
-
parameter
pidInteger | Array(Integer) Process ID(s) to capture.
-
parameter
ppidInteger | Array(Integer) Parent process ID(s) to include children for.
-
parameter
memoryBoolean Whether to capture detailed memory metrics (default: Memory.supported?).
-
returns
Hash<Integer, General> Map of PID to General instance.
Implementation
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
# When filtering by ppid we need the full process list to build the parent-child tree,
# so we enumerate all numeric /proc entries; when only pid is set we read just those.
pids_to_read = if pid && ppid.nil?
Array(pid)
else
Dir.children("/proc").filter{|e| e.match?(/\A\d+\z/)}.map(&:to_i)
end
uptime_jiffies = nil
processes = {}
pids_to_read.each do |pid|
stat_path = "/proc/#{pid}/stat"
next unless File.readable?(stat_path)
stat_content = File.read(stat_path)
# comm field can contain spaces and parentheses; find the closing ')' (proc(5)).
closing_paren_index = stat_content.rindex(")")
next unless closing_paren_index
executable_name = stat_content[1...closing_paren_index]
fields = stat_content[(closing_paren_index + 2)..].split(/\s+/)
# 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.
parent_process_id = fields[1].to_i
process_group_id = fields[2].to_i
utime = fields[11].to_i
stime = fields[12].to_i
starttime = fields[19].to_i
virtual_size = fields[20].to_i
resident_pages = fields[21].to_i
# Read /proc/uptime once per capture and reuse for every process (starttime is in jiffies since boot).
uptime_jiffies ||= begin
uptime_seconds = File.read("/proc/uptime").split(/\s+/).first.to_f
(uptime_seconds * CLK_TCK).to_i
end
processor_time = (utime + stime).to_f / CLK_TCK
elapsed_time = [(uptime_jiffies - starttime).to_f / CLK_TCK, 0.0].max
command = read_command(pid, executable_name)
processes[pid] = General.new(
pid,
parent_process_id,
process_group_id,
0.0, # processor_utilization: would need two samples; not available from single stat read
virtual_size,
resident_pages * PAGE_SIZE,
processor_time,
elapsed_time,
command,
nil
)
rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
# Process disappeared or we can't read it.
next
end
# Restrict to the requested pid/ppid subtree using the same tree logic as the ps backend.
if ppid
pids = Set.new
hierarchy = General.build_tree(processes)
General.expand_children(Array(pid), hierarchy, pids) if pid
General.expand_children(Array(ppid), hierarchy, pids)
processes.select!{|process_id, _| pids.include?(process_id)}
end
General.capture_memory(processes) if memory
processes
end
def self.read_command(pid, command_fallback)
Read command line from /proc/[pid]/cmdline; fall back to executable name from stat if empty. Use binread because cmdline is NUL-separated and may contain non-UTF-8 bytes; we split on NUL and join for display.
Implementation
def self.read_command(pid, command_fallback)
path = "/proc/#{pid}/cmdline"
return command_fallback unless File.readable?(path)
cmdline_content = File.binread(path)
return command_fallback if cmdline_content.empty?
# cmdline is NUL-separated; replace with spaces for display.
cmdline_content.split("\0").join(" ").strip
rescue Errno::ENOENT, Errno::ESRCH, Errno::EACCES
command_fallback
end