|
| 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 commit comments