agentmon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/exe/agentmon +18 -0
  6. data/lib/agentmon/commands/cli_options.rb +21 -0
  7. data/lib/agentmon/commands/footprint.rb +81 -0
  8. data/lib/agentmon/commands/memory.rb +110 -0
  9. data/lib/agentmon/commands/record.rb +118 -0
  10. data/lib/agentmon/commands/report.rb +127 -0
  11. data/lib/agentmon/commands/sessions.rb +98 -0
  12. data/lib/agentmon/commands/top.rb +87 -0
  13. data/lib/agentmon/commands/tree.rb +96 -0
  14. data/lib/agentmon/darwin.rb +235 -0
  15. data/lib/agentmon/engine.rb +115 -0
  16. data/lib/agentmon/focus.rb +88 -0
  17. data/lib/agentmon/metrics/memory.rb +76 -0
  18. data/lib/agentmon/metrics/network.rb +245 -0
  19. data/lib/agentmon/metrics/pressure_drivers.rb +57 -0
  20. data/lib/agentmon/metrics/process_rates.rb +65 -0
  21. data/lib/agentmon/metrics/process_rows.rb +41 -0
  22. data/lib/agentmon/metrics/session_ledger.rb +160 -0
  23. data/lib/agentmon/metrics/session_memory.rb +84 -0
  24. data/lib/agentmon/metrics/session_names.rb +169 -0
  25. data/lib/agentmon/metrics/sessions.rb +80 -0
  26. data/lib/agentmon/metrics/system.rb +114 -0
  27. data/lib/agentmon/model.rb +263 -0
  28. data/lib/agentmon/probes/cwd.rb +32 -0
  29. data/lib/agentmon/probes/memory.rb +139 -0
  30. data/lib/agentmon/probes/network.rb +326 -0
  31. data/lib/agentmon/probes/processes.rb +72 -0
  32. data/lib/agentmon/probes/system.rb +134 -0
  33. data/lib/agentmon/program.rb +55 -0
  34. data/lib/agentmon/reading.rb +83 -0
  35. data/lib/agentmon/recorders/memory.rb +19 -0
  36. data/lib/agentmon/recorders/sessions.rb +40 -0
  37. data/lib/agentmon/registry.rb +155 -0
  38. data/lib/agentmon/sampler.rb +50 -0
  39. data/lib/agentmon/store.rb +86 -0
  40. data/lib/agentmon/ui/connections.rb +75 -0
  41. data/lib/agentmon/ui/interaction.rb +94 -0
  42. data/lib/agentmon/ui/memory.rb +125 -0
  43. data/lib/agentmon/ui/process_actions.rb +49 -0
  44. data/lib/agentmon/ui/process_detail.rb +148 -0
  45. data/lib/agentmon/ui/process_network.rb +13 -0
  46. data/lib/agentmon/ui/process_scopes.rb +32 -0
  47. data/lib/agentmon/ui/process_waits.rb +99 -0
  48. data/lib/agentmon/ui/processes.rb +42 -0
  49. data/lib/agentmon/ui/session_focus.rb +126 -0
  50. data/lib/agentmon/ui/session_memory.rb +78 -0
  51. data/lib/agentmon/ui/sessions.rb +130 -0
  52. data/lib/agentmon/ui/theme.rb +49 -0
  53. data/lib/agentmon/ui.rb +80 -0
  54. data/lib/agentmon/version.rb +5 -0
  55. data/lib/agentmon/views/dense.rb +157 -0
  56. data/lib/agentmon/views/history.rb +62 -0
  57. data/lib/agentmon/views/signals.rb +279 -0
  58. data/lib/agentmon/views/visual.rb +172 -0
  59. data/lib/agentmon/views/widgets.rb +352 -0
  60. data/lib/agentmon/views.rb +875 -0
  61. data/lib/agentmon.rb +38 -0
  62. metadata +135 -0
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentmon
4
+ # The data model: every value that crosses a file boundary (a probe's output, a metric's output,
5
+ # a store record) is one of these. Stories build against these shapes, so they can be written in
6
+ # parallel; changing one is a contract change (docs/design.md).
7
+ #
8
+ # Units, everywhere:
9
+ # sizes and cumulative I/O bytes (Integer)
10
+ # rates bytes per second (Float)
11
+ # CPU time seconds (Float), never Mach ticks
12
+ # CPU usage percent of one core (Float): 250.0 is two and a half cores busy
13
+ # instants epoch seconds (Float); intervals use the monotonic clock
14
+ # nil unknown (unreadable, or not measured yet), never 0
15
+
16
+ # One probe pass: what every probe returned at one instant. `parts[:processes]` and so on.
17
+ # `errors` maps a probe that raised to its message (its part keeps the previous value).
18
+ Sample = Data.define(:at, :mono, :parts, :errors) do
19
+ def initialize(at:, mono:, parts:, errors: {}) = super
20
+
21
+ def [](name) = parts[name]
22
+ end
23
+
24
+ # probes/processes.rb: one process. Cumulative counters are since the process started.
25
+ # `readable` is false for processes the kernel won't describe to us (root daemons): then only
26
+ # the ps fields (pid, ppid, path, resident, ps_cpu) are set and the rest are nil.
27
+ ProcessStat = Data.define(
28
+ :pid, :ppid,
29
+ :name, # basename of the executable
30
+ :path, # executable path as ps reports it (comm)
31
+ :readable, # rusage was readable
32
+ :start_ticks, # Mach ticks at start: [pid, start_ticks] survives pid reuse (nil if unreadable)
33
+ :started_at, # epoch seconds (nil if unreadable)
34
+ :cpu_time, # own user+system seconds
35
+ :child_cpu_time, # user+system seconds of children it has reaped
36
+ :resident, # bytes (rusage, or ps rss when unreadable)
37
+ :footprint, # physical footprint, bytes: what Activity Monitor calls Memory
38
+ :peak_footprint, # lifetime max footprint, bytes
39
+ :disk_read, # cumulative bytes
40
+ :disk_written, # cumulative bytes
41
+ :ps_cpu, # ps %cpu (a decaying average): the fallback when rusage is unreadable
42
+ # Paging and scheduling, from the same two calls per process (rusage, PROC_PIDTASKALLINFO).
43
+ # nil when unreadable. Counts are cumulative event counts (Integer), not bytes.
44
+ :wired, # wired (unpageable) bytes now
45
+ :pageins, # count of faults that had to read a page from disk (rusage ri_pageins)
46
+ :faults, # count of page faults of any kind (pti_faults: a 32-bit kernel counter, wraps)
47
+ :cow_faults, # count of copy-on-write faults (pti_cow_faults: 32-bit, wraps)
48
+ :context_switches, # count (pti_csw: 32-bit, wraps)
49
+ :runnable_time, # seconds its threads were runnable: on a CPU *or waiting for one*
50
+ # (ri_runnable_time). Time spent waiting to run = runnable_time - cpu_time
51
+ :threads, # threads now
52
+ :running_threads # threads running now
53
+ ) do
54
+ # The paging and scheduling fields default to nil (unknown), so code written before they
55
+ # existed, and fixtures that don't care, still build ProcessStats.
56
+ def initialize(wired: nil, pageins: nil, faults: nil, cow_faults: nil, context_switches: nil, runnable_time: nil,
57
+ threads: nil, running_threads: nil, **) = super
58
+
59
+ def identity = [pid, start_ticks]
60
+ end
61
+
62
+ # metrics/process_rates.rb output, per pid: rates between the previous sample and this one.
63
+ # A process seen for the first time (or unreadable) has ps's %cpu and nil for every other rate.
64
+ ProcessRates = Data.define(
65
+ :cpu, # percent of one core
66
+ :read_rate, :write_rate, # disk bytes/s
67
+ :pagein_rate, # pageins/s: each one waited for the disk (the closest thing to I/O wait)
68
+ :fault_rate, # page faults/s
69
+ :cow_fault_rate, # copy-on-write faults/s
70
+ :context_switch_rate, # context switches/s
71
+ :run_wait # percent of one core spent runnable but not running, i.e. waiting for
72
+ # a CPU: (Δrunnable_time - Δcpu_time) / interval x 100
73
+ ) do
74
+ def initialize(pagein_rate: nil, fault_rate: nil, cow_fault_rate: nil, context_switch_rate: nil, run_wait: nil,
75
+ **) = super
76
+ end
77
+
78
+ # metrics/sessions.rb: who an agent session is. `kind` is :cli (an outermost claude/codex CLI
79
+ # process) or :app (a desktop app and all its helpers). `id` is stable across agentmon runs:
80
+ # "claude-4242-1790711088" (name, root pid, root start second).
81
+ # `label` is the one display string every consumer shows and searches: "claude 4242 · repo", or
82
+ # with a human name "Agentmon r2ui integration · claude 4242 · repo" (name first, so a table
83
+ # cut at a word boundary keeps it). `title`, `status`, `threads` come from SessionName (nil/[]
84
+ # when unknown).
85
+ SessionInfo = Data.define(:id, :kind, :name, :root_pid, :label, :cwd, :started_at, :title, :status, :threads) do
86
+ def initialize(title: nil, status: nil, threads: [], **) = super
87
+ end
88
+
89
+ # metrics/session_names.rb output, per session root pid: what the agent itself calls the session.
90
+ SessionName = Data.define(
91
+ :title, # String: the session's name ("Agentmon r2ui integration"); for a codex process hosting
92
+ # several threads, its newest named thread plus " +N" for N more open threads; nil unknown
93
+ :status, # "busy" / "idle" from Claude Code's session file; nil for codex or unknown
94
+ :threads # Array of String: a codex process's open threads' names, newest first ([] for Claude)
95
+ )
96
+
97
+ # metrics/sessions.rb output: the sessions alive in this sample and which one each pid is in.
98
+ SessionMap = Data.define(:sessions, :by_pid) do
99
+ def of(pid) = (id = by_pid[pid]) && sessions.find { |s| s.id == id }
100
+ end
101
+
102
+ # metrics/session_ledger.rb output (one per session, alive or ended in the last
103
+ # SessionLedger::KEEP_ENDED seconds). Current values are 0 once it has ended.
104
+ Session = Data.define(
105
+ :id, :kind, :name, :root_pid, :label, :cwd,
106
+ :started_at, # the root process's start (epoch), or first_seen_at when unknown
107
+ :first_seen_at, # when agentmon first saw it
108
+ :last_seen_at, # last sample it was alive in
109
+ :ended_at, # nil while alive
110
+ :processes, # live member processes
111
+ :cpu, # percent, now
112
+ :footprint, # bytes, now (readable members only)
113
+ :resident, # bytes, now
114
+ :read_rate, # bytes/s, now
115
+ :write_rate, # bytes/s, now
116
+ :peak_footprint, # max footprint seen over its life (bytes)
117
+ :cpu_seconds, # CPU seconds over its life, reaped children included (see design.md)
118
+ :bytes_read, # disk bytes read by members while observed (a lower bound)
119
+ :bytes_written, # disk bytes written by members while observed (a lower bound)
120
+ # From the latest SessionInfo (SessionName): human name, Claude's "busy"/"idle", codex thread
121
+ # names newest first. nil/[] when unknown.
122
+ :title, :status, :threads
123
+ ) do
124
+ def initialize(title: nil, status: nil, threads: [], **) = super
125
+
126
+ def alive? = ended_at.nil?
127
+
128
+ def duration = (ended_at || last_seen_at) - (started_at || first_seen_at)
129
+
130
+ # The store line for kind "sessions" (docs/design.md, "Store").
131
+ def to_record = to_h.merge(kind: kind.to_s)
132
+ end
133
+
134
+ # metrics/process_rows.rb output: one row per process for tables (dashboard and CLI).
135
+ ProcessRow = Data.define(
136
+ :pid, :ppid, :name, :path, :cwd,
137
+ :session, # session label, nil when it isn't in an agent session
138
+ :session_id,
139
+ :cpu, # percent (rusage delta, or ps %cpu when unreadable or first seen)
140
+ :footprint, :resident, :peak_footprint, # bytes
141
+ :read_rate, :write_rate, # bytes/s, nil until two samples
142
+ :cpu_time, :disk_written, # seconds, bytes (cumulative)
143
+ :started_at, :readable,
144
+ # Paging and scheduling (from ProcessStat and ProcessRates); nil when unreadable or before two
145
+ # samples. Optional, so rows built without them still work.
146
+ :disk_read, # bytes (cumulative)
147
+ :wired, # bytes now
148
+ :pageins, :faults, :cow_faults, :context_switches, # counts (cumulative)
149
+ :runnable_time, # seconds (cumulative), time on a CPU included
150
+ :threads, :running_threads, # counts now
151
+ :pagein_rate, :fault_rate, :cow_fault_rate, :context_switch_rate, # per second
152
+ :run_wait, # percent of one core spent waiting for a CPU
153
+ # Network (metrics/network.rb, from nettop); nil when unknown (no snapshot yet, first sighting).
154
+ :net_in_rate, :net_out_rate # bytes/s received / sent over all its sockets
155
+ ) do
156
+ def initialize(disk_read: nil, wired: nil, pageins: nil, faults: nil, cow_faults: nil, context_switches: nil,
157
+ runnable_time: nil, threads: nil, running_threads: nil, pagein_rate: nil, fault_rate: nil,
158
+ cow_fault_rate: nil, context_switch_rate: nil, run_wait: nil, net_in_rate: nil,
159
+ net_out_rate: nil, **) = super
160
+ end
161
+
162
+ # probes/network.rb: one socket (flow) of a process as one `nettop -x -L 0` line prints it.
163
+ NetFlow = Data.define(
164
+ :protocol, # "tcp4", "udp6", "quic4", ...
165
+ :local, # local endpoint as nettop prints it: "192.0.2.5:55584" (IPv4 `:` port),
166
+ :remote, # "2001:db8::1.443" (IPv6 `.` port), "*:*" / "*.*" wildcards
167
+ :remote_host, # remote address or name without the port; nil for the `*` wildcard
168
+ :remote_port, # Integer, nil for `*`
169
+ :interface, # "en0", "lo0"; nil when nettop leaves it empty
170
+ :state, # "Established", "Listen", ...; nil for stateless (UDP) or empty
171
+ :bytes_in, # cumulative bytes received on this flow; nil when nettop leaves it empty
172
+ :bytes_out # cumulative bytes sent, likewise
173
+ )
174
+
175
+ # probes/network.rb: one process's sockets in one nettop block.
176
+ NetProcess = Data.define(
177
+ :pid,
178
+ :name, # nettop's (truncated) process name
179
+ :bytes_in, # cumulative bytes received, as nettop counts them for the process
180
+ :bytes_out, # cumulative bytes sent
181
+ :flows # Array of NetFlow
182
+ )
183
+
184
+ # probes/network.rb: one complete nettop block (sample[:network]).
185
+ NetSnapshot = Data.define(
186
+ :at_mono, # monotonic seconds when the block's header arrived (rates use these)
187
+ :processes # { pid => NetProcess }; a process without sockets has no entry
188
+ )
189
+
190
+ # metrics/network.rb reading[:net_rates], per pid: between the two latest nettop snapshots.
191
+ NetRates = Data.define(:in_rate, :out_rate) # bytes/s; nil when unknown
192
+
193
+ # metrics/network.rb reading[:session_network]: one alive session's network, each pid once.
194
+ SessionNetwork = Data.define(
195
+ :session_id, :label,
196
+ :in_rate, :out_rate, # bytes/s now, summed over members (nil when no member's rate is known)
197
+ :bytes_in, :bytes_out, # cumulative bytes received / sent by members while agentmon observed them
198
+ :in_trend, :out_trend, # the last 120 known rates (bytes/s), oldest first
199
+ :connections, # count of member flows with a concrete remote (not `*`)
200
+ :remote_hosts, # count of distinct remote hosts, loopback and link-local left out
201
+ :flags # Array of Symbols from the anomaly rule (:out_spike, :in_spike, :many_hosts); [] normal
202
+ )
203
+
204
+ # metrics/network.rb reading[:connections]: one flow of a process in an agent session with a
205
+ # concrete remote (no `*`, no Listen). Fields as NetFlow; rates bytes/s, nil the first time.
206
+ ConnectionRow = Data.define(
207
+ :pid, :name, :session_id,
208
+ :session, # session label
209
+ :protocol, :local, :remote, :remote_host, :remote_port, :interface, :state,
210
+ :bytes_in, :bytes_out, # cumulative bytes on this flow
211
+ :in_rate, :out_rate # bytes/s between the two latest snapshots
212
+ )
213
+
214
+ # probes/memory.rb (story a01): the machine's memory counters. Sizes in bytes; the counters
215
+ # (swapins .. pageins) are cumulative bytes since boot (pages x page size).
216
+ MemoryStat = Data.define(
217
+ :total, # hw.memsize
218
+ :free,
219
+ :app, # anonymous (internal) pages minus purgeable
220
+ :wired,
221
+ :compressed, # pages occupied by the compressor
222
+ :compressor_stored, # uncompressed bytes held in the compressor
223
+ :cached, # file-backed + purgeable
224
+ :purgeable,
225
+ :swap_total, :swap_used,
226
+ :swapins, :swapouts, :compressions, :decompressions, :pageins,
227
+ :memorystatus_level # kern.memorystatus_level: percent of memory available (100 = no pressure)
228
+ )
229
+
230
+ # metrics/memory.rb (story a02): Activity Monitor's breakdown plus rates and pressure.
231
+ MemoryView = Data.define(
232
+ :total, :used, :app, :wired, :compressed, :cached, :free, :swap_used, :swap_total,
233
+ :compression_ratio, # compressor_stored / compressed (e.g. 3.1)
234
+ :swapin_rate, :swapout_rate, :compression_rate, :decompression_rate, # bytes/s
235
+ :pressure, # percent: 100 - memorystatus_level
236
+ :pressure_trend # recent pressure values, oldest first (Array of Float)
237
+ )
238
+
239
+ # metrics/pressure_drivers.rb (story a03): sessions ranked by how much they push memory.
240
+ PressureDriver = Data.define(
241
+ :session_id, :label,
242
+ :footprint, # bytes now
243
+ :share, # percent of used memory
244
+ :growth_rate # bytes/s of footprint change over the last minute
245
+ )
246
+
247
+ # metrics/session_memory.rb (story a20): one alive session's memory, largest footprint first.
248
+ # Sums over the session's live readable members, each process once. Compressed and swapped
249
+ # bytes per process need task_for_pid (root), so they aren't here; MemoryView has the machine's.
250
+ SessionMemory = Data.define(
251
+ :session_id, :label,
252
+ :processes, # live members
253
+ :footprint, # bytes now (Activity Monitor's Memory; includes the session's compressed pages)
254
+ :resident, # bytes now
255
+ :wired, # bytes now
256
+ :peak_footprint, # bytes: largest footprint sum over its life (Session#peak_footprint)
257
+ :share, # percent of MemoryView#used (nil while memory is unknown)
258
+ :growth_rate, # bytes/s of footprint change over the last minute (0.0 until two samples)
259
+ :pageins, # count, cumulative over live members
260
+ :pagein_rate, # pageins/s now (nil until two samples)
261
+ :fault_rate # page faults/s now (nil until two samples)
262
+ )
263
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # sample[:cwd]: { pid => working directory } for every process lsof can see.
4
+ #
5
+ # lsof costs ~0.4 s for the whole machine, so it runs at most every CWD_EVERY seconds and the
6
+ # sampler reuses the last map in between (`every:`). A process newer than the map has no cwd until
7
+ # the next run. Session labels ("claude 4242 · repo") and the Directory column come from here.
8
+ module Agentmon
9
+ module Probes
10
+ module Cwd
11
+ CWD_EVERY = 10
12
+ LSOF = %w[lsof -d cwd -Fpn].freeze
13
+
14
+ module_function
15
+
16
+ def read(text = IO.popen(LSOF, err: File::NULL, &:read)) = parse(text)
17
+
18
+ # lsof -F output: "p<pid>" then "n<path>" lines.
19
+ def parse(text)
20
+ pid = nil
21
+ text.each_line.with_object({}) do |line, cwds|
22
+ case line[0]
23
+ when "p" then pid = line[1..].to_i
24
+ when "n" then cwds[pid] = line[1..].chomp if pid
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ probe(:cwd, every: Probes::Cwd::CWD_EVERY) { Probes::Cwd.read }
32
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ # sample[:memory]: the machine's memory counters, as a MemoryStat (lib/agentmon/model.rb), in
6
+ # bytes. The same numbers Activity Monitor's Memory tab and vm_stat show, e.g. on a 32 GiB Mac:
7
+ #
8
+ # total 32.0G app 10.4G wired 4.3G compressed 9.4G (stores 24.3G) cached 5.3G
9
+ # swap 1.9G of 3.0G used memorystatus_level 55 (pressure 45%)
10
+ #
11
+ # No subprocesses: host_statistics64(HOST_VM_INFO64) through Fiddle for the page counts
12
+ # (pages x vm_kernel_page_size: 16 KiB on Apple Silicon, 4 KiB on Intel), and sysctlbyname for
13
+ # hw.memsize, vm.swapusage, vm.page_pageable_internal_count and kern.memorystatus_level. A read
14
+ # takes well under a millisecond.
15
+ #
16
+ # app = pageable internal (anonymous) pages - purgeable pages
17
+ # cached = file-backed pages + purgeable pages
18
+ # compressed = pages the compressor occupies; compressor_stored = pages stored in it
19
+ # free = free pages minus speculative ones (those are file-backed, so already in cached)
20
+ # swapins, swapouts, compressions, decompressions, pageins: cumulative bytes since boot
21
+ #
22
+ # A sysctl that fails leaves its fields nil (swap, memorystatus_level); without
23
+ # vm.page_pageable_internal_count, app uses the anonymous page count instead. Off macOS the probe
24
+ # is nil.
25
+ module Agentmon
26
+ module Probes
27
+ module Memory
28
+ HOST_VM_INFO64 = 4
29
+ # struct vm_statistics64 (xnu osfmk/mach/vm_statistics.h): 152 bytes, 38 integer_t's.
30
+ VM_SIZE = 152
31
+ VM_COUNT = VM_SIZE / 4
32
+ VM_FORMAT = "L<4Q<9L<2Q<4L<4Q<"
33
+ VM_FIELDS = %i[
34
+ free_count active_count inactive_count wire_count
35
+ zero_fill_count reactivations pageins pageouts faults cow_faults lookups hits purges
36
+ purgeable_count speculative_count
37
+ decompressions compressions swapins swapouts
38
+ compressor_page_count throttled_count external_page_count internal_page_count
39
+ total_uncompressed_pages_in_compressor
40
+ ].freeze
41
+ # struct xsw_usage: xsu_total, xsu_avail, xsu_used (uint64), xsu_pagesize, xsu_encrypted.
42
+ SWAP_SIZE = 32
43
+
44
+ module_function
45
+
46
+ # Reads the live machine. Raises when host_statistics64 fails.
47
+ def read
48
+ decode(vm: Native.vm_statistics, page_size: Native.page_size, memsize: Native.sysctl_int("hw.memsize"),
49
+ swap: Native.sysctl("vm.swapusage", SWAP_SIZE),
50
+ pageable_internal: Native.sysctl_int("vm.page_pageable_internal_count"),
51
+ memorystatus_level: Native.sysctl_int("kern.memorystatus_level"))
52
+ end
53
+
54
+ # Builds the MemoryStat from the raw kernel answers: `vm` is a vm_statistics64 as the kernel
55
+ # wrote it, `swap` an xsw_usage (or nil), the rest Integers (or nil). Pure; tests feed it
56
+ # recorded bytes.
57
+ def decode(vm:, page_size:, memsize:, swap: nil, pageable_internal: nil, memorystatus_level: nil)
58
+ raise ArgumentError, "vm_statistics64 is #{vm.bytesize} bytes, want #{VM_SIZE}" if vm.bytesize < VM_SIZE
59
+
60
+ v = VM_FIELDS.zip(vm.byteslice(0, VM_SIZE).unpack(VM_FORMAT)).to_h
61
+ bytes = ->(pages) { pages * page_size }
62
+ purgeable = v[:purgeable_count]
63
+ internal = pageable_internal || v[:internal_page_count]
64
+ swap_total, _avail, swap_used = swap&.byteslice(0, 24)&.unpack("Q<3")
65
+ MemoryStat.new(
66
+ total: memsize,
67
+ free: bytes[[v[:free_count] - v[:speculative_count], 0].max],
68
+ app: bytes[[internal - purgeable, 0].max],
69
+ wired: bytes[v[:wire_count]],
70
+ compressed: bytes[v[:compressor_page_count]],
71
+ compressor_stored: bytes[v[:total_uncompressed_pages_in_compressor]],
72
+ cached: bytes[v[:external_page_count] + purgeable],
73
+ purgeable: bytes[purgeable],
74
+ swap_total:, swap_used:,
75
+ swapins: bytes[v[:swapins]], swapouts: bytes[v[:swapouts]],
76
+ compressions: bytes[v[:compressions]], decompressions: bytes[v[:decompressions]],
77
+ pageins: bytes[v[:pageins]],
78
+ memorystatus_level:
79
+ )
80
+ end
81
+
82
+ # The Fiddle calls. Buffers are per call, so reads from several threads don't share memory.
83
+ module Native
84
+ module_function
85
+
86
+ def vm_statistics
87
+ buffer = Fiddle::Pointer.malloc(VM_SIZE, Fiddle::RUBY_FREE)
88
+ count = Fiddle::Pointer.malloc(4, Fiddle::RUBY_FREE)
89
+ count[0, 4] = [VM_COUNT].pack("L")
90
+ result = functions[:host_statistics64].call(host, HOST_VM_INFO64, buffer, count)
91
+ raise Error, "host_statistics64 failed (kern_return_t #{result})" unless result.zero?
92
+
93
+ buffer[0, VM_SIZE]
94
+ end
95
+
96
+ # vm_kernel_page_size, a libSystem global (vm_size_t).
97
+ def page_size = @page_size ||= Fiddle::Pointer.new(lib["vm_kernel_page_size"])[0, 8].unpack1("Q")
98
+
99
+ # The sysctl's raw bytes (at most `size`), or nil when it fails.
100
+ def sysctl(name, size)
101
+ buffer = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
102
+ length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
103
+ length[0, 8] = [size].pack("Q")
104
+ return nil unless functions[:sysctlbyname].call(name, buffer, length, nil, 0).zero?
105
+
106
+ buffer[0, length[0, 8].unpack1("Q")]
107
+ end
108
+
109
+ # An integer sysctl of 4 or 8 bytes (they differ by name and OS version), or nil.
110
+ def sysctl_int(name)
111
+ raw = sysctl(name, 8)
112
+ case raw&.bytesize
113
+ when 4 then raw.unpack1("l<")
114
+ when 8 then raw.unpack1("q<")
115
+ end
116
+ end
117
+
118
+ # mach_host_self() returns a send right each call; take it once.
119
+ def host = @host ||= functions[:mach_host_self].call
120
+
121
+ def lib = @lib ||= Fiddle.dlopen(nil)
122
+
123
+ def functions
124
+ @functions ||= {
125
+ mach_host_self: Fiddle::Function.new(lib["mach_host_self"], [], -Fiddle::TYPE_INT),
126
+ host_statistics64: Fiddle::Function.new(lib["host_statistics64"],
127
+ [-Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
128
+ Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT),
129
+ sysctlbyname: Fiddle::Function.new(lib["sysctlbyname"],
130
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
131
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_INT)
132
+ }
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ probe(:memory) { Darwin.available? ? Probes::Memory.read : nil }
139
+ end