lib/process/metrics/memory/darwin.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.

module Process
	module Metrics
		class Memory::Darwin
			VMMAP = "/usr/bin/vmmap"
			
			# Whether the memory usage can be captured on this system.
			def self.supported?
				File.executable?(VMMAP)
			end
			
			# @returns [Numeric] Total memory size in kilobytes.
			def self.total_size
				# sysctl hw.memsize
				IO.popen(["sysctl", "hw.memsize"], "r") do |io|
					io.each_line do |line|
						if line =~ /hw.memsize: (\d+)/
							return $1.to_i / 1024
						end
					end
				end
			end
			
			# Parse a size string into kilobytes.
			def self.parse_size(string)
				return 0 unless string
				
				case string.strip
				when /([\d\.]+)K/i then ($1.to_f).round
				when /([\d\.]+)M/i then ($1.to_f * 1024).round
				when /([\d\.]+)G/i then ($1.to_f * 1024 * 1024).round
				else (string.to_f / 1024).ceil
				end
			end
			
			LINE = /\A
				\s*
				(?<region_name>.+?)\s+
				(?<start_address>[0-9a-fA-F]+)-(?<end_address>[0-9a-fA-F]+)\s+
				\[\s*(?<virtual_size>[\d\.]+[KMG]?)\s+(?<resident_size>[\d\.]+[KMG]?)\s+(?<dirty_size>[\d\.]+[KMG]?)\s+(?<swap_size>[\d\.]+[KMG]?)\s*\]\s+
				(?<permissions>[rwx\-\/]+)\s+
				SM=(?<sharing_mode>\w+)
			/x
			
			# Capture memory usage for the given process IDs.
			def self.capture(pid, count: 1, **options)
				usage = Memory.zero
				
				IO.popen(["vmmap", pid.to_s], "r") do |io|
					io.each_line do |line|
						if match = LINE.match(line)
							virtual_size = parse_size(match[:virtual_size])
							resident_size = parse_size(match[:resident_size])
							dirty_size = parse_size(match[:dirty_size])
							swap_size = parse_size(match[:swap_size])
							
							# Update counts
							usage.map_count += 1
							usage.resident_size += resident_size
							usage.swap_size += swap_size
							
							# Private vs. Shared memory
							# COW=copy_on_write PRV=private NUL=empty ALI=aliased 
							# SHM=shared ZER=zero_filled S/A=shared_alias
							case match[:sharing_mode]
							when "PRV"
								usage.private_clean_size += resident_size - dirty_size
								usage.private_dirty_size += dirty_size
							when "COW", "SHM"
								usage.shared_clean_size += resident_size - dirty_size
								usage.shared_dirty_size += dirty_size
							end
							
							# Anonymous memory: no region detail path or special names
							if match[:region_name] =~ /MALLOC|VM_ALLOCATE|Stack|STACK|anonymous/
								usage.anonymous_size += resident_size
							end
						end
					end
				end
				
				# 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:
				usage.proportional_size = usage.resident_size / count
				usage.proportional_swap_size = usage.swap_size / count
				
				return usage
			end
		end
		
		if Memory::Darwin.supported?
			class << Memory
				def supported?
					return true
				end
				
				def total_size
					return Memory::Darwin.total_size
				end
				
				def capture(...)
					return Memory::Darwin.capture(...)
				end
			end
		end
	end
end