collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Lightweight process metrics (memory, CPU) without external crate dependencies.
//!
//! Uses platform-native APIs:
//! - macOS: `mach_task_basic_info` for memory, `getrusage` for CPU
//! - Linux: `/proc/self/statm` for memory, `/proc/self/stat` for CPU
//! - Other: returns zeroes gracefully

/// Snapshot of current process metrics.
#[derive(Debug, Clone, Default)]
pub struct ProcessMetrics {
    /// Resident set size in bytes.
    pub rss_bytes: u64,
    /// CPU usage as a percentage (0.0 - 100.0+).
    /// Computed as delta of user+sys time since last call.
    pub cpu_percent: f32,
}

/// Stateful collector that computes CPU% as a delta between calls.
pub struct MetricsCollector {
    last_cpu_time_us: u64,
    last_wall_time: std::time::Instant,
}

impl Default for MetricsCollector {
    fn default() -> Self {
        Self::new()
    }
}

impl MetricsCollector {
    pub fn new() -> Self {
        Self {
            last_cpu_time_us: current_cpu_time_us(),
            last_wall_time: std::time::Instant::now(),
        }
    }

    /// Sample current metrics. Call periodically (e.g. every 1s).
    pub fn sample(&mut self) -> ProcessMetrics {
        let rss_bytes = current_rss_bytes();
        let now_cpu = current_cpu_time_us();
        let now_wall = std::time::Instant::now();

        let cpu_delta_us = now_cpu.saturating_sub(self.last_cpu_time_us);
        let wall_delta_us = now_wall.duration_since(self.last_wall_time).as_micros() as u64;

        let cpu_percent = if wall_delta_us > 0 {
            (cpu_delta_us as f64 / wall_delta_us as f64 * 100.0) as f32
        } else {
            0.0
        };

        self.last_cpu_time_us = now_cpu;
        self.last_wall_time = now_wall;

        ProcessMetrics {
            rss_bytes,
            cpu_percent,
        }
    }
}

// ── Platform-specific implementations ────────────────────────────────────

// Declare mach_task_self_ directly to avoid the deprecated libc wrappers.
// Both libc::mach_task_self() and libc::mach_task_self_ are deprecated in
// favor of the mach2 crate; using the raw extern avoids pulling in a new dep.
#[cfg(target_os = "macos")]
unsafe extern "C" {
    static mach_task_self_: libc::mach_port_t;
}

#[cfg(target_os = "macos")]
fn current_rss_bytes() -> u64 {
    use std::mem;

    // Use task_vm_info to get phys_footprint — matches Activity Monitor's "Memory" column.
    // phys_footprint = internal + compressed, excluding shared/mapped regions.
    #[repr(C)]
    struct TaskVmInfo {
        // We only need the first 18 fields up to phys_footprint.
        // See <mach/task_info.h> for the full struct.
        virtual_size: u64,
        region_count: i32,
        page_size: i32,
        resident_size: u64,
        resident_size_peak: u64,
        device: u64,
        device_peak: u64,
        internal: u64,
        internal_peak: u64,
        external: u64,
        external_peak: u64,
        reusable: u64,
        reusable_peak: u64,
        purgeable_volatile_pmap: u64,
        purgeable_volatile_resident: u64,
        purgeable_volatile_virtual: u64,
        compressed: u64,
        compressed_peak: u64,
        phys_footprint: u64,
    }

    const TASK_VM_INFO: u32 = 22;

    unsafe {
        let mut info: TaskVmInfo = mem::zeroed();
        let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<libc::natural_t>())
            as libc::mach_msg_type_number_t;
        let kr = libc::task_info(
            mach_task_self_,
            TASK_VM_INFO,
            &mut info as *mut _ as libc::task_info_t,
            &mut count,
        );
        if kr == libc::KERN_SUCCESS {
            // phys_footprint matches Activity Monitor; fall back to resident_size
            // in environments where phys_footprint is not populated (e.g. VMs).
            if info.phys_footprint > 0 {
                info.phys_footprint
            } else {
                info.resident_size
            }
        } else {
            0
        }
    }
}

#[cfg(target_os = "linux")]
fn page_size() -> u64 {
    // Query the system page size at runtime instead of hardcoding 4096.
    // ARM64 kernels may use 16KB pages, PowerPC up to 64KB.
    unsafe { libc::sysconf(libc::_SC_PAGESIZE) as u64 }
}

#[cfg(target_os = "linux")]
fn current_rss_bytes() -> u64 {
    // /proc/self/statm: pages — field 1 is RSS in pages
    let ps = page_size();
    std::fs::read_to_string("/proc/self/statm")
        .ok()
        .and_then(|s| {
            let mut parts = s.split_whitespace();
            parts.next(); // total
            parts
                .next()
                .and_then(|rss| rss.parse::<u64>().ok())
                .map(|pages| pages * ps)
        })
        .unwrap_or(0)
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn current_rss_bytes() -> u64 {
    0
}

// ── Per-PID RSS measurement (for child processes) ─────────────────────────

/// Measure RSS (in bytes) of an external process by PID.
/// Returns 0 if the process doesn't exist or measurement fails.
pub fn child_rss_bytes(pid: u32) -> u64 {
    child_rss_bytes_impl(pid)
}

#[cfg(target_os = "macos")]
fn child_rss_bytes_impl(pid: u32) -> u64 {
    use std::mem;

    #[repr(C)]
    struct ProcTaskInfo {
        pti_virtual_size: u64,
        pti_resident_size: u64,
        // sizeof(proc_taskinfo) = 136; first two fields = 16 bytes
        _padding: [u8; 120],
    }

    const PROC_PIDTASKINFO: i32 = 4;

    unsafe extern "C" {
        fn proc_pidinfo(
            pid: i32,
            flavor: i32,
            arg: u64,
            buffer: *mut std::ffi::c_void,
            buffersize: i32,
        ) -> i32;
    }

    unsafe {
        let mut info: ProcTaskInfo = mem::zeroed();
        let size = mem::size_of::<ProcTaskInfo>() as i32;
        let ret = proc_pidinfo(
            pid as i32,
            PROC_PIDTASKINFO,
            0,
            &mut info as *mut _ as *mut std::ffi::c_void,
            size,
        );
        if ret > 0 { info.pti_resident_size } else { 0 }
    }
}

#[cfg(target_os = "linux")]
fn child_rss_bytes_impl(pid: u32) -> u64 {
    let ps = page_size();
    std::fs::read_to_string(format!("/proc/{pid}/statm"))
        .ok()
        .and_then(|s| {
            let mut parts = s.split_whitespace();
            parts.next(); // total
            parts
                .next()
                .and_then(|rss| rss.parse::<u64>().ok())
                .map(|pages| pages * ps)
        })
        .unwrap_or(0)
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn child_rss_bytes_impl(_pid: u32) -> u64 {
    0
}

#[cfg(unix)]
fn current_cpu_time_us() -> u64 {
    unsafe {
        let mut usage: libc::rusage = std::mem::zeroed();
        if libc::getrusage(libc::RUSAGE_SELF, &mut usage) == 0 {
            let user_us = usage.ru_utime.tv_sec as u64 * 1_000_000 + usage.ru_utime.tv_usec as u64;
            let sys_us = usage.ru_stime.tv_sec as u64 * 1_000_000 + usage.ru_stime.tv_usec as u64;
            user_us + sys_us
        } else {
            0
        }
    }
}

#[cfg(not(unix))]
fn current_cpu_time_us() -> u64 {
    0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_metrics_collector_produces_values() {
        let mut collector = MetricsCollector::new();
        // Allocate heap memory so phys_footprint is non-zero
        let data: Vec<u8> = std::hint::black_box(vec![1u8; 1024 * 1024]); // 1MB
        std::hint::black_box(&data);

        let metrics = collector.sample();
        // RSS should be non-zero on macOS/Linux
        #[cfg(any(target_os = "macos", target_os = "linux"))]
        assert!(metrics.rss_bytes > 0, "RSS should be non-zero");
        // CPU percent can be 0 for very short intervals, but shouldn't panic
        assert!(metrics.cpu_percent >= 0.0);
    }

    #[test]
    fn test_format_rss() {
        let m = ProcessMetrics {
            rss_bytes: 52_428_800, // 50 MB
            cpu_percent: 12.5,
        };
        assert_eq!(m.rss_bytes / 1_048_576, 50);
    }
}