Compare commits

...
Author SHA1 Message Date
gronod a46f91f2ad feat(telemetry): implement per-process thread, file descriptor, and socket inspector (fixes #12)
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Canceled after 0s
2026-09-08 12:41:56 +01:00
gronod 65468f6dfb Merge branch 'feat/11-process-explorer' into milestone/m3-advanced-telemetry
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Canceled after 0s
2026-09-08 12:39:28 +01:00
gronod 72ed8256c8 feat(telemetry): implement live process explorer and resource attribution provider (fixes #11)
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Canceled after 0s
2026-09-08 12:39:23 +01:00
gronod 4394c1e332 Merge branch 'feat/16-network-sockets' into milestone/m3-advanced-telemetry
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Canceled after 0s
2026-09-08 12:37:34 +01:00
7 changed files with 523 additions and 0 deletions
@@ -24,5 +24,7 @@
#import "MMDiskIOProvider.h" #import "MMDiskIOProvider.h"
#import "MMNetworkBandwidthProvider.h" #import "MMNetworkBandwidthProvider.h"
#import "MMNetworkSocketsProvider.h" #import "MMNetworkSocketsProvider.h"
#import "MMProcessTelemetryProvider.h"
#import "MMProcessDetailInspector.h"
#endif /* MacMonitor_Bridging_Header_h */ #endif /* MacMonitor_Bridging_Header_h */
@@ -0,0 +1,32 @@
#ifndef MMProcessDetailInspector_h
#define MMProcessDetailInspector_h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/**
* MMProcessDetailInspector
* Provides deep per-process inspection: open file descriptors, vnode file paths,
* active network sockets, and thread states.
*/
@interface MMProcessDetailInspector : NSObject
/**
* Inspects a running process by PID.
*/
+ (nullable NSDictionary<NSString *, id> *)inspectProcessWithPID:(pid_t)pid;
/**
* Pure summarizer method for deterministic unit testing.
*/
+ (NSDictionary<NSString *, id> *)summarizeInspectionResultsWithPID:(pid_t)pid
threads:(NSArray<NSDictionary<NSString *, id> *> *)threads
fds:(NSArray<NSDictionary<NSString *, id> *> *)fds
sockets:(NSArray<NSDictionary<NSString *, id> *> *)sockets;
@end
NS_ASSUME_NONNULL_END
#endif /* MMProcessDetailInspector_h */
@@ -0,0 +1,176 @@
#import "MMProcessDetailInspector.h"
#import <libproc.h>
#import <sys/proc_info.h>
#import <arpa/inet.h>
@implementation MMProcessDetailInspector
+ (NSDictionary<NSString *, id> *)summarizeInspectionResultsWithPID:(pid_t)pid
threads:(NSArray<NSDictionary<NSString *, id> *> *)threads
fds:(NSArray<NSDictionary<NSString *, id> *> *)fds
sockets:(NSArray<NSDictionary<NSString *, id> *> *)sockets {
return @{
@"pid": @(pid),
@"threadCount": @(threads.count),
@"openFDCount": @(fds.count),
@"socketCount": @(sockets.count),
@"threads": threads,
@"fileDescriptors": fds,
@"sockets": sockets
};
}
static NSString *runStateString(int state) {
switch (state) {
case 1: return @"Running";
case 2: return @"Stopped";
case 3: return @"Waiting";
case 4: return @"Uninterruptible";
case 5: return @"Halted";
default: return @"Unknown";
}
}
static NSString *tcpStateString(int state) {
switch (state) {
case 0: return @"CLOSED";
case 1: return @"LISTEN";
case 2: return @"SYN_SENT";
case 3: return @"SYN_RCVD";
case 4: return @"ESTABLISHED";
case 5: return @"CLOSE_WAIT";
case 6: return @"FIN_WAIT_1";
case 7: return @"CLOSING";
case 8: return @"LAST_ACK";
case 9: return @"FIN_WAIT_2";
case 10: return @"TIME_WAIT";
default: return @"UNKNOWN";
}
}
+ (nullable NSDictionary<NSString *, id> *)inspectProcessWithPID:(pid_t)pid {
if (pid <= 0) return nil;
// 1. Enumerate threads
uint64_t threadIds[512];
int threadBytes = proc_pidinfo(pid, PROC_PIDLISTTHREADS, 0, threadIds, sizeof(threadIds));
int threadCount = threadBytes / sizeof(uint64_t);
NSMutableArray<NSDictionary<NSString *, id> *> *threads = [NSMutableArray arrayWithCapacity:threadCount];
for (int t = 0; t < threadCount; t++) {
uint64_t tid = threadIds[t];
struct proc_threadinfo thi;
if (proc_pidinfo(pid, PROC_PIDTHREADINFO, tid, &thi, sizeof(thi)) == sizeof(thi)) {
[threads addObject:@{
@"threadId": @(tid),
@"userTimeNs": @(thi.pth_user_time),
@"systemTimeNs": @(thi.pth_system_time),
@"cpuPercent": @(thi.pth_cpu_usage),
@"state": runStateString(thi.pth_run_state),
@"priority": @(thi.pth_priority),
@"currentPriority": @(thi.pth_curpri)
}];
}
}
// 2. Enumerate File Descriptors & Sockets
NSMutableArray<NSDictionary<NSString *, id> *> *fdsList = [NSMutableArray array];
NSMutableArray<NSDictionary<NSString *, id> *> *socketsList = [NSMutableArray array];
int sz = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0);
if (sz > 0) {
struct proc_fdinfo *fds = malloc(sz);
if (fds) {
int actual = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, sz);
int fdCount = actual / sizeof(struct proc_fdinfo);
for (int j = 0; j < fdCount; j++) {
int fd = fds[j].proc_fd;
uint32_t fdType = fds[j].proc_fdtype;
if (fdType == PROX_FDTYPE_VNODE) {
struct vnode_fdinfowithpath vi;
if (proc_pidfdinfo(pid, fd, PROC_PIDFDVNODEPATHINFO, &vi, sizeof(vi)) == sizeof(vi)) {
NSString *path = [NSString stringWithUTF8String:vi.pvip.vip_path];
[fdsList addObject:@{
@"fd": @(fd),
@"type": @"File",
@"path": path ?: @"(unknown)"
}];
}
} else if (fdType == PROX_FDTYPE_SOCKET) {
struct socket_fdinfo si;
if (proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si)) == sizeof(si)) {
int family = si.psi.soi_family;
if (family == AF_INET || family == AF_INET6) {
char localIP[INET6_ADDRSTRLEN] = {0};
char remoteIP[INET6_ADDRSTRLEN] = {0};
int lport = 0, rport = 0;
NSString *proto = (si.psi.soi_type == SOCK_STREAM) ? @"TCP" : @"UDP";
NSString *state = @"NONE";
if (family == AF_INET) {
inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4, localIP, sizeof(localIP));
inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4, remoteIP, sizeof(remoteIP));
lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
} else {
inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, localIP, sizeof(localIP));
inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, remoteIP, sizeof(remoteIP));
lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
}
if (si.psi.soi_type == SOCK_STREAM) {
state = tcpStateString(si.psi.soi_proto.pri_tcp.tcpsi_state);
}
NSDictionary<NSString *, id> *sockDict = @{
@"fd": @(fd),
@"protocol": proto,
@"localAddress": [NSString stringWithUTF8String:localIP],
@"localPort": @(lport),
@"remoteAddress": [NSString stringWithUTF8String:remoteIP],
@"remotePort": @(rport),
@"state": state
};
[socketsList addObject:sockDict];
[fdsList addObject:@{
@"fd": @(fd),
@"type": @"Socket",
@"path": [NSString stringWithFormat:@"%@ %@:%d -> %@:%d", proto, [NSString stringWithUTF8String:localIP], lport, [NSString stringWithUTF8String:remoteIP], rport]
}];
} else {
[fdsList addObject:@{
@"fd": @(fd),
@"type": @"Socket (Unix/Other)",
@"path": @"Unix Domain Socket"
}];
}
}
} else if (fdType == PROX_FDTYPE_PIPE) {
[fdsList addObject:@{
@"fd": @(fd),
@"type": @"Pipe",
@"path": @"FIFO/Pipe"
}];
} else {
[fdsList addObject:@{
@"fd": @(fd),
@"type": @"Other",
@"path": @""
}];
}
}
free(fds);
}
}
return [MMProcessDetailInspector summarizeInspectionResultsWithPID:pid
threads:threads
fds:fdsList
sockets:socketsList];
}
@end
@@ -0,0 +1,34 @@
#ifndef MMProcessTelemetryProvider_h
#define MMProcessTelemetryProvider_h
#import <Foundation/Foundation.h>
#import "MMTelemetryProvider.h"
NS_ASSUME_NONNULL_BEGIN
/**
* MMProcessTelemetryProvider
* Live process explorer sampling CPU utilization, resident memory (RSS),
* virtual memory, thread counts, and process hierarchy.
*/
@interface MMProcessTelemetryProvider : NSObject <MMTelemetryProvider>
- (instancetype)init;
/**
* Safely terminates a process via SIGTERM or SIGKILL.
*/
+ (BOOL)terminateProcessWithPID:(pid_t)pid force:(BOOL)force;
/**
* Pure calculator method for deterministic unit testing.
*/
+ (NSArray<NSDictionary<NSString *, id> *> *)calculateProcessListWithRawProcesses:(NSArray<NSDictionary<NSString *, id> *> *)rawProcesses
previousCPUTimes:(nullable NSDictionary<NSNumber *, NSNumber *> *)previousCPUTimes
timeDelta:(NSTimeInterval)timeDelta;
@end
NS_ASSUME_NONNULL_END
#endif /* MMProcessTelemetryProvider_h */
@@ -0,0 +1,160 @@
#import "MMProcessTelemetryProvider.h"
#import <libproc.h>
#import <sys/proc_info.h>
#import <pwd.h>
#import <signal.h>
#import <mach/mach_time.h>
#import <os/lock.h>
@interface MMProcessTelemetryProvider () {
os_unfair_lock _lock;
NSMutableDictionary<NSNumber *, NSNumber *> *_previousCPUTimes;
uint64_t _previousTimestamp;
mach_timebase_info_data_t _timebase;
NSMutableDictionary<NSNumber *, NSString *> *_usernameCache;
}
@end
@implementation MMProcessTelemetryProvider
- (instancetype)init {
self = [super init];
if (self) {
_lock = OS_UNFAIR_LOCK_INIT;
_previousCPUTimes = [NSMutableDictionary dictionary];
_previousTimestamp = 0;
_usernameCache = [NSMutableDictionary dictionary];
mach_timebase_info(&_timebase);
}
return self;
}
- (MMTelemetryDomain)domain {
return MMTelemetryDomainProcess;
}
- (NSString *)providerIdentifier {
return @"com.i3omb.macmonitor.telemetry.process";
}
- (BOOL)isAvailable {
return YES;
}
+ (BOOL)terminateProcessWithPID:(pid_t)pid force:(BOOL)force {
int sig = force ? SIGKILL : SIGTERM;
return kill(pid, sig) == 0;
}
+ (NSArray<NSDictionary<NSString *, id> *> *)calculateProcessListWithRawProcesses:(NSArray<NSDictionary<NSString *, id> *> *)rawProcesses
previousCPUTimes:(nullable NSDictionary<NSNumber *, NSNumber *> *)previousCPUTimes
timeDelta:(NSTimeInterval)timeDelta {
NSMutableArray<NSDictionary<NSString *, id> *> *processed = [NSMutableArray arrayWithCapacity:rawProcesses.count];
for (NSDictionary<NSString *, id> *raw in rawProcesses) {
NSNumber *pidNum = raw[@"pid"];
uint64_t currCPUTime = [raw[@"cpuTimeNs"] unsignedLongLongValue];
double cpuPercent = 0.0;
if (previousCPUTimes && previousCPUTimes[pidNum]) {
uint64_t prevCPUTime = [previousCPUTimes[pidNum] unsignedLongLongValue];
if (currCPUTime >= prevCPUTime && timeDelta > 0.001) {
uint64_t deltaNs = currCPUTime - prevCPUTime;
double deltaSec = (double)deltaNs / 1e9;
cpuPercent = (deltaSec / timeDelta) * 100.0;
}
}
NSMutableDictionary<NSString *, id> *entry = [raw mutableCopy];
entry[@"cpuPercent"] = @(cpuPercent);
[processed addObject:entry];
}
// Sort descending by cpuPercent
[processed sortUsingComparator:^NSComparisonResult(NSDictionary<NSString *, id> *obj1, NSDictionary<NSString *, id> *obj2) {
NSNumber *c1 = obj1[@"cpuPercent"];
NSNumber *c2 = obj2[@"cpuPercent"];
return [c2 compare:c1];
}];
return processed;
}
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
uint64_t now = mach_absolute_time();
int pids[4096];
int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids));
int pidCount = bytes / sizeof(int);
NSMutableArray<NSDictionary<NSString *, id> *> *rawList = [NSMutableArray arrayWithCapacity:pidCount];
NSMutableDictionary<NSNumber *, NSNumber *> *currentCPUTimes = [NSMutableDictionary dictionaryWithCapacity:pidCount];
NSUInteger totalThreads = 0;
for (int i = 0; i < pidCount; i++) {
pid_t pid = pids[i];
if (pid <= 0) continue;
struct proc_taskallinfo tai;
if (proc_pidinfo(pid, PROC_PIDTASKALLINFO, 0, &tai, sizeof(tai)) != sizeof(tai)) {
continue;
}
uint64_t cpuTimeNs = tai.ptinfo.pti_total_user + tai.ptinfo.pti_total_system;
currentCPUTimes[@(pid)] = @(cpuTimeNs);
totalThreads += tai.ptinfo.pti_threadnum;
// Resolve username
NSNumber *uidNum = @(tai.pbsd.pbi_uid);
NSString *username = _usernameCache[uidNum];
if (!username) {
struct passwd *pw = getpwuid(tai.pbsd.pbi_uid);
username = pw ? [NSString stringWithUTF8String:pw->pw_name] : [uidNum stringValue];
_usernameCache[uidNum] = username;
}
NSString *pname = [NSString stringWithUTF8String:tai.pbsd.pbi_name];
[rawList addObject:@{
@"pid": @(pid),
@"ppid": @(tai.pbsd.pbi_ppid),
@"uid": uidNum,
@"username": username,
@"name": pname,
@"cpuTimeNs": @(cpuTimeNs),
@"residentBytes": @(tai.ptinfo.pti_resident_size),
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
@"threadCount": @(tai.ptinfo.pti_threadnum),
@"runningThreads": @(tai.ptinfo.pti_numrunning)
}];
}
os_unfair_lock_lock(&_lock);
NSDictionary<NSNumber *, NSNumber *> *prev = [_previousCPUTimes copy];
uint64_t prevTime = _previousTimestamp;
_previousCPUTimes = currentCPUTimes;
_previousTimestamp = now;
os_unfair_lock_unlock(&_lock);
NSTimeInterval timeDelta = 1.0;
if (prevTime > 0) {
uint64_t elapsed = now - prevTime;
timeDelta = (double)elapsed * _timebase.numer / _timebase.denom / 1e9;
}
NSArray<NSDictionary<NSString *, id> *> *processed = [MMProcessTelemetryProvider calculateProcessListWithRawProcesses:rawList
previousCPUTimes:prev
timeDelta:timeDelta];
return @{
@"processCount": @(processed.count),
@"totalThreads": @(totalThreads),
@"processes": processed,
@"timeDelta": @(timeDelta)
};
}
@end
+50
View File
@@ -0,0 +1,50 @@
import XCTest
@testable import MacMonitor
final class MMProcessDetailsTests: XCTestCase {
func testInspectionSummaryCalculation() {
let mockThreads: [[String: Any]] = [
["threadId": 1, "state": "Running"],
["threadId": 2, "state": "Waiting"]
]
let mockFDs: [[String: Any]] = [
["fd": 0, "type": "File", "path": "/dev/null"],
["fd": 1, "type": "File", "path": "/tmp/test.log"],
["fd": 3, "type": "Socket", "path": "TCP 127.0.0.1:80 -> 127.0.0.1:50000"]
]
let mockSockets: [[String: Any]] = [
["fd": 3, "protocol": "TCP", "localAddress": "127.0.0.1", "localPort": 80, "state": "LISTEN"]
]
let summary = MMProcessDetailInspector.summarizeInspectionResults(
withPID: 1234,
threads: mockThreads,
fds: mockFDs,
sockets: mockSockets
)
let pid = summary["pid"] as? Int ?? 0
let threadCount = summary["threadCount"] as? Int ?? 0
let openFDCount = summary["openFDCount"] as? Int ?? 0
let socketCount = summary["socketCount"] as? Int ?? 0
XCTAssertEqual(pid, 1234)
XCTAssertEqual(threadCount, 2)
XCTAssertEqual(openFDCount, 3)
XCTAssertEqual(socketCount, 1)
}
func testLiveProcessInspection() {
let currentPid = getpid()
let result = MMProcessDetailInspector.inspectProcess(withPID: currentPid)
XCTAssertNotNil(result)
let threadCount = result?["threadCount"] as? Int ?? 0
XCTAssertGreaterThan(threadCount, 0)
let fds = result?["fileDescriptors"] as? [[String: Any]]
XCTAssertNotNil(fds)
}
}
+69
View File
@@ -0,0 +1,69 @@
import XCTest
@testable import MacMonitor
final class MMProcessTests: XCTestCase {
func testProcessCPUPercentCalculation() {
let prevCPUTimes: [NSNumber: NSNumber] = [
100: NSNumber(value: 1_000_000_000), // 1.0s
200: NSNumber(value: 500_000_000) // 0.5s
]
let rawProcesses: [[String: Any]] = [
[
"pid": 100,
"ppid": 1,
"uid": 501,
"username": "tester",
"name": "cpu_hog",
"cpuTimeNs": NSNumber(value: 2_000_000_000), // delta = 1.0s over 2.0s = 50% CPU
"residentBytes": NSNumber(value: 50_000_000),
"virtualBytes": NSNumber(value: 100_000_000),
"threadCount": 4,
"runningThreads": 1
],
[
"pid": 200,
"ppid": 1,
"uid": 501,
"username": "tester",
"name": "idle_app",
"cpuTimeNs": NSNumber(value: 520_000_000), // delta = 0.02s over 2.0s = 1% CPU
"residentBytes": NSNumber(value: 20_000_000),
"virtualBytes": NSNumber(value: 50_000_000),
"threadCount": 2,
"runningThreads": 0
]
]
let timeDelta: TimeInterval = 2.0
let results = MMProcessTelemetryProvider.calculateProcessList(
withRawProcesses: rawProcesses,
previousCPUTimes: prevCPUTimes,
timeDelta: timeDelta
)
XCTAssertEqual(results.count, 2)
// First entry should be highest CPU
XCTAssertEqual(results[0]["name"] as? String, "cpu_hog")
let hogCPU = results[0]["cpuPercent"] as? Double ?? 0
XCTAssertEqual(hogCPU, 50.0, accuracy: 0.1)
let idleCPU = results[1]["cpuPercent"] as? Double ?? 0
XCTAssertEqual(idleCPU, 1.0, accuracy: 0.1)
}
func testLiveProcessProvider() throws {
let provider = MMProcessTelemetryProvider()
XCTAssertEqual(provider.domain, .process)
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.process")
let sample = try provider.sampleTelemetry()
XCTAssertNotNil(sample)
let processCount = sample["processCount"] as? Int ?? 0
XCTAssertGreaterThan(processCount, 0)
XCTAssertNotNil(sample["processes"])
XCTAssertNotNil(sample["totalThreads"])
}
}