Collection
Which kernel interface each figure comes from, and what each one gets wrong if read carelessly. Every call here is unprivileged.
Processes
sysctl(CTL_KERN, KERN_PROC, KERN_PROC_ALL) enumerates. The obvious alternative,
proc_listallpids, silently omits the processes that deny access rather than listing
them — on this machine it returned 315 of 1,245. The whole point is to list the denied
ones, so sysctl it is.
Per-process counters then come from two calls that can be refused independently:
| Call | Gives |
|---|---|
proc_pidinfo(PROC_PIDTASKINFO) |
CPU time (Mach ticks), resident size, thread count |
proc_pid_rusage(RUSAGE_INFO_V4) |
physical footprint, disk bytes, interrupt wake-ups |
proc_pidpath gives the full executable path, from which the display name is taken —
p_comm stops at 16 characters, and for a bundled app the bundle name is what a person
recognises.
kinfo_proc is not in libc
So sys/ffi.rs addresses the five fields it needs by offset. Those offsets came from
compiling offsetof against the installed SDK headers. verify_layout re-checks them at
every enumeration by finding Starf's own record and comparing PID, parent, owner and start
time against values already known. If a future macOS moves them, the process list reports
that it cannot read the table rather than printing whatever the bytes say.
Mach ticks, and Rosetta
proc_taskinfo reports CPU time in Mach absolute ticks, converted with the machine's
timebase. Under Rosetta the caller's mach_timebase_info is 1:1 while the counters remain
host ticks, so an x86_64 build reads hw.tbfrequency instead. proc_threadinfo, by
contrast, reports nanoseconds directly — applying the timebase to those again would
inflate them by roughly forty.
Host
| Figure | Source |
|---|---|
| CPU ticks | host_statistics(HOST_CPU_LOAD_INFO); nice time counts as user time |
| Memory pages | host_statistics64(HOST_VM_INFO64) |
| Physical memory, swap, pressure | sysctlbyname |
| Interface traffic | sysctl(NET_RT_IFLIST2), non-loopback only |
NET_RT_IFLIST2 carries 64-bit counters. getifaddrs would wrap at 32 bits.
Memory used is active + wired + compressed. Inactive and speculative pages are shown separately as cached, because they are reclaimable.
Logical processors
host_processor_info(PROCESSOR_CPU_LOAD_INFO) gives per-processor user, nice, system and
idle ticks; PROCESSOR_BASIC_INFO gives slot numbers. A reading is only compared with one
taken under the same identification — without slot numbers two readings may describe
different processors, and the difference between them would mean nothing.
Performance and efficiency processors are read, not guessed
sysctl hw.perflevel* gives the counts. The mapping from a slot to a kind comes from
explicit logical-cpu-id and cluster-type properties on the IODeviceTree CPU nodes:
cpu0–cpu3 cluster-type "E" Efficiency
cpu4–cpu15 cluster-type "P" Performance
which agrees with hw.perflevel0 (12 performance) and hw.perflevel1 (4 efficiency).
Array order is never used as a source. A slot claimed by two nodes is dropped rather
than guessed — an ambiguous answer is worse than none, and those processors keep the
neutral label "Logical processor". Intel SMT threads appear as logical processors; no
physical-core affinity is implied.
Threads
PROC_PIDLISTTHREADIDS (flavour 28, from XNU's private proc_info_private.h) gives
unique kernel thread IDs. Where a kernel does not know it, PROC_PIDLISTTHREADS gives
cthread handles the kernel may reuse, and the interface says so — a thread that exits can
then be replaced by an unrelated one under the same label. Per-thread counters come from
PROC_PIDTHREADID64INFO or PROC_PIDTHREADINFO to match.
Collection is opt-in per process, runs at most every two seconds on the existing sampling turn, and creates no timer of its own.
GPU
IOServiceMatching("IOAccelerator") enumerates devices. Each entry's
PerformanceStatistics dictionary carries the counters:
| Key | Becomes |
|---|---|
Device Utilization %, or GPU Activity(%) |
device utilisation |
Renderer Utilization % |
renderer |
Tiler Utilization % |
tiler |
In use system memory, Alloc system memory |
driver memory |
Per-process GPU time comes from the accelerator's client entries: IOUserClientCreator
reads "pid 399, WindowServer", and AppUsage[].accumulatedGPUTime counts nanoseconds.
Metal is not linked. The device name comes from the driver's own model property —
"Apple M4 Max" rather than "AGXAcceleratorG16X" — falling back up the registry and then to
the class name. Metal would add little beyond a second source for the same string.
Registry properties are driver-defined, not a documented cross-vendor contract, so every
GPU counter is optional. A driver that publishes none still lists its device, with —
everywhere.
Observed, not lifetime
The counters are lifetime totals, but Starf reports only time observed during this session. A client seen for the first time, one whose counters reset or changed shape, and one belonging to a reused PID each establish a new baseline instead of contributing their history as a spike. Work by clients that both appear and disappear between two samples cannot be recovered. A partially readable counter array is discarded whole rather than summed into a number that would understate the process.
Power
AppleSmartBattery in the IORegistry: charge, cycle count, charge against design
capacity, voltage, current, temperature, and the adapter's AdapterDetails.
Two traps:
- Current is signed. Read unsigned, a discharge of 2,227 mA arrives as
18446744073709543389and the wattage comes out positive. The watt figure is negative while discharging. 65535means "no estimate". It is the controller's sentinel, not a value. Read raw, "time remaining" would show forty-five days.
The watt figure is the battery's own charge or discharge power. It is not the
machine's total draw: on mains the battery current falls to zero while the machine keeps
drawing from the adapter, and measuring that needs SMC keys or powermetrics. The adapter
figure is its rating, not its measured output.
Applications
AppKit would answer "does this have a Dock presence?" with
NSRunningApplication.activationPolicy, but that means a GUI framework in the sampling
path. The same fact is in the bundle's own Info.plist: LSUIElement or
LSBackgroundOnly marks a bundle with no Dock presence. Results are cached per bundle,
since bundles do not change while running.
Nested helper bundles declare it and classify correctly without special-casing. The limit
is real: a bundle that sets its policy at runtime cannot be seen this way. On this
machine AppSSOAgent, UserNotificationCenter and privatecloudcomputed declare neither
key and so classify as applications. Excluding them by path would be a guess.
Per-process diagnostics
libc carries none of the structures these flavours return, so sys/diagnostics.rs
addresses their fields by offset, produced the same way as the kinfo_proc offsets and
documented beside each one.
| Page | Source |
|---|---|
| Overview | PROC_PIDTASKINFO, PROC_PIDVNODEPATHINFO, proc_pidpath, proc_pid_rusage |
| Open files | PROC_PIDLISTFDS, then PROC_PIDFD*INFO per descriptor |
| Memory map | PROC_PIDREGIONPATHINFO, walked one mapping at a time |
| Mach ports | task_name_for_pid then mach_port_names |
| Fileports | PROC_PIDLISTFILEPORTS |
| Reports | sysctl(KERN_PROCARGS2) for arguments; sample, lsof, vmmap and codesign run directly, without a shell |
The region walk ends when the kernel reports nothing at or after the next address, which
is EINVAL — not a failure once anything has been read. A mapping that does not advance,
or whose end would wrap, stops the walk rather than turning it into a loop.
Mach-port enumeration needs a task-name port, which macOS refuses for most processes you do not own. That refusal is reported as a refusal.
proc_pidinfo reports "none" as a return of zero with errno untouched, so errno is
cleared before the descriptor and fileport calls. Without that, a stale value left by an
earlier syscall makes an empty list look like a denial.
Per-process network
macOS exposes no unprivileged per-process network accounting through a library call.
nettop is Apple's own bundled client for it, so Starf runs it — with DNS disabled so it
never blocks on a lookup, a three-second deadline, and one run at a time. It refreshes
every five seconds on a background thread, because a subprocess must never delay the
one-second sampler, and these counters change far more slowly than that anyway.
Two things in the output are easy to get wrong:
- The columns do not arrive in the order they were requested. Asking for
bytes_in,bytes_out,packets_in,packets_outreturns a header reading,packets_in,bytes_in,packets_out,bytes_out,. Reading by position would report packet counts as byte counts. They are matched by the header's own names. - The identity field is
name.pid, and names contain dots —com.apple.WebKit.GPU.4949. The PID is whatever follows the last one.
The figures are cumulative byte totals for the process, not rates, and they follow the
connections nettop can see, so they need not add up to the interface totals. A process
it reported nothing for shows —.