Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 7 additions & 86 deletions lib/process/metrics/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

module Process
module Metrics
PS = "ps"

DURATION = /\A
(?:(?<days>\d+)-)? # Optional days (e.g., '2-')
(?:(?<hours>\d+):)? # Optional hours (e.g., '1:')
Expand All @@ -36,19 +34,6 @@ def self.duration(value)
end
end

# The fields that will be extracted from the `ps` command.
FIELDS = {
pid: ->(value){value.to_i}, # Process ID
ppid: ->(value){value.to_i}, # Parent Process ID
pgid: ->(value){value.to_i}, # Process Group ID
pcpu: ->(value){value.to_f}, # Percentage CPU
vsz: ->(value){value.to_i * 1024}, # Virtual Size (convert from KiB to bytes)
rss: ->(value){value.to_i * 1024}, # Resident Size (convert from KiB to bytes)
time: self.method(:duration), # CPU Time (seconds)
etime: self.method(:duration), # Elapsed Time (seconds)
command: ->(value){value}, # Command (name of the process)
}

# General process information.
class General < Struct.new(:process_id, :parent_process_id, :process_group_id, :processor_utilization, :virtual_size, :resident_size, :processor_time, :elapsed_time, :command, :memory)
# Convert the object to a JSON serializable hash.
Expand Down Expand Up @@ -132,77 +117,13 @@ def self.capture_memory(processes)
process.memory = Memory.capture(pid, count: count)
end
end

# 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.
#
# @parameter pid [Integer] The process ID to capture.
# @parameter ppid [Integer] The parent process ID to capture.
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
ps_pid = nil

# Extract the information from the `ps` command:
header, *lines = IO.pipe do |input, output|
arguments = [PS]

if pid && ppid.nil?
arguments.push("-p", Array(pid).join(","))
else
arguments.push("ax")
end

arguments.push("-o", FIELDS.keys.join(","))

ps_pid = Process.spawn(*arguments, out: output)
output.close

input.readlines.map(&:strip)
ensure
input.close

if ps_pid
begin
# Make sure to kill the ps process if it's still running:
Process.kill(:KILL, ps_pid)
# Reap the process:
Process.wait(ps_pid)
rescue => error
warn "Failed to cleanup ps process #{ps_pid}:\n#{error.full_message}"
end
end
end

processes = {}

lines.map do |line|
record = FIELDS.
zip(line.split(/\s+/, FIELDS.size)).
map{|(key, type), value| type.call(value)}
instance = self.new(*record)

processes[instance.process_id] = instance
end

if ppid
pids = Set.new

hierarchy = self.build_tree(processes)

self.expand_children(Array(pid), hierarchy, pids)
self.expand_children(Array(ppid), hierarchy, pids)

processes.select! do |pid, process|
if pid != ps_pid
pids.include?(pid)
end
end
end

if memory
self.capture_memory(processes)
end

return processes
end
end
end
end

# One backend provides General.capture: Linux uses /proc (no subprocess); other platforms use ps.
if RUBY_PLATFORM.include?("linux")
require_relative "general/linux"
else
require_relative "general/process_status"
end
131 changes: 131 additions & 0 deletions lib/process/metrics/general/linux.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2026, by Samuel Williams.

require "etc"

module Process
module Metrics
# 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.
module General::Linux
# Clock ticks per second for /proc stat times (utime, stime, starttime).
CLK_TCK = Etc.sysconf(Etc::SC_CLK_TCK) rescue 100

# Page size in bytes for RSS (resident set size is in pages in /proc/pid/stat).
PAGE_SIZE = Etc.sysconf(Etc::SC_PAGESIZE) rescue 4096

# Whether /proc is available so we can list processes without ps.
def self.supported?
File.directory?("/proc") && File.readable?("/proc/self/stat")
end

# 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.
# @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
# @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
# @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
# @returns [Hash<Integer, General>] Map of PID to General instance.
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

# 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.
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
end
end
end

if Process::Metrics::General::Linux.supported?
class << Process::Metrics::General
def capture(...)
Process::Metrics::General::Linux.capture(...)
end
end
else
require_relative "process_status"
end
109 changes: 109 additions & 0 deletions lib/process/metrics/general/process_status.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2026, by Samuel Williams.

module Process
module Metrics
# General process information via the process status command (`ps`). Used on non-Linux platforms (e.g. Darwin)
# where there is no /proc; ps is the portable way to get pid, ppid, times, and memory in one pass.
module General::ProcessStatus
PS = "ps"

# The fields that will be extracted from the `ps` command (order matches -o output).
FIELDS = {
pid: ->(value){value.to_i},
ppid: ->(value){value.to_i},
pgid: ->(value){value.to_i},
pcpu: ->(value){value.to_f},
vsz: ->(value){value.to_i * 1024},
rss: ->(value){value.to_i * 1024},
time: Process::Metrics.method(:duration),
etime: Process::Metrics.method(:duration),
command: ->(value){value},
}

# Whether process listing via ps is available on this system.
def self.supported?
system("which", PS, out: File::NULL, err: File::NULL)
end

# 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.
# @parameter pid [Integer | Array(Integer)] Process ID(s) to capture.
# @parameter ppid [Integer | Array(Integer)] Parent process ID(s) to include children for.
# @parameter memory [Boolean] Whether to capture detailed memory metrics (default: Memory.supported?).
# @returns [Hash<Integer, General>] Map of PID to General instance.
def self.capture(pid: nil, ppid: nil, memory: Memory.supported?)
spawned_pid = nil

header, *lines = IO.pipe do |input, output|
arguments = [PS]

# When filtering by ppid we need the full process list to build the tree, so use "ax"; otherwise limit to -p.
if pid && ppid.nil?
arguments.push("-p", Array(pid).join(","))
else
arguments.push("ax")
end

arguments.push("-o", FIELDS.keys.join(","))

spawned_pid = Process.spawn(*arguments, out: output)
output.close

input.readlines.map(&:strip)
ensure
input.close

# Always kill and reap the ps subprocess so we never leave it hanging if the pipe closes early.
if spawned_pid
begin
Process.kill(:KILL, spawned_pid)
Process.wait(spawned_pid)
rescue => error
warn "Failed to cleanup ps process #{spawned_pid}:\n#{error.full_message}"
end
end
end

processes = {}

lines.each do |line|
next if line.empty?

values = line.split(/\s+/, FIELDS.size)
next if values.size < FIELDS.size

record = FIELDS.keys.map.with_index{|key, i| FIELDS[key].call(values[i])}
instance = General.new(*record, nil)
processes[instance.process_id] = instance
end

# Restrict to the requested pid/ppid subtree; exclude our own ps process from the result.
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, _| process_id != spawned_pid && pids.include?(process_id)}
else
processes.delete(spawned_pid) if spawned_pid
end

General.capture_memory(processes) if memory

processes
end
end
end
end

# 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).
linux_supported = defined?(Process::Metrics::General::Linux) && Process::Metrics::General::Linux.supported?
if Process::Metrics::General::ProcessStatus.supported? && !linux_supported
class << Process::Metrics::General
def capture(...)
Process::Metrics::General::ProcessStatus.capture(...)
end
end
end
14 changes: 7 additions & 7 deletions lib/process/metrics/host/memory/darwin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ module Host
# Uses sysctl (hw.memsize), vm_stat (free + inactive pages), and vm.swapusage for swap.
class Memory::Darwin
# Parse a size string from vm.swapusage (e.g. "1024.00M", "512.00K") into bytes.
# @parameter string [String | Nil] The size string from sysctl vm.swapusage.
# @returns [Integer | Nil] Size in bytes, or nil if string is nil/empty.
def self.parse_swap_size(string)
return nil unless string
# @parameter size_string [String | Nil] The size string from sysctl vm.swapusage.
# @returns [Integer | Nil] Size in bytes, or nil if size_string is nil/empty.
def self.parse_swap_size(size_string)
return nil unless size_string

string = string.strip
size_string = size_string.strip

case string
case size_string
when /([\d.]+)M/i then ($1.to_f * 1024 * 1024).round
when /([\d.]+)G/i then ($1.to_f * 1024 * 1024 * 1024).round
when /([\d.]+)K/i then ($1.to_f * 1024).round
else string.to_f.round
else size_string.to_f.round
end
end

Expand Down
Loading
Loading