Skip to main content

proc_daemon/
resources.rs

1// Copyright 2023 James Gober. All rights reserved.
2// Use of this source code is governed by Apache License
3// that can be found in the LICENSE file.
4
5//! # Resource Usage Tracking
6//!
7//! This module provides functionality for tracking process resource usage,
8//! including memory and CPU utilization.
9//!
10//! ## Features
11//!
12//! - Cross-platform memory usage tracking
13//! - CPU usage monitoring with percentage calculations
14//! - Sampling at configurable intervals
15//! - Historical data collection with time-series support
16//!
17//! ## Example
18//!
19//! ```ignore
20//! use proc_daemon::resources::{ResourceTracker, ResourceUsage};
21//! use std::time::Duration;
22//!
23//! // Create a new resource tracker sampling every second
24//! let mut tracker = ResourceTracker::new(Duration::from_secs(1));
25//!
26//! // Get the current resource usage
27//! let usage = tracker.current_usage();
28//! println!("Memory: {}MB, CPU: {}%", usage.memory_mb(), usage.cpu_percent());
29//! ```
30//!
31//! With tokio runtime:
32//!
33//! ```ignore
34//! # use proc_daemon::resources::ResourceTracker;
35//! # use std::time::Duration;
36//! # let mut tracker = ResourceTracker::new(Duration::from_secs(1));
37//! #[cfg(feature = "tokio")]
38//! async {
39//!     // Start tracking
40//!     tracker.start().unwrap();
41//!     
42//!     // ... use the tracker ...
43//!     
44//!     // Stop tracking when done
45//!     tracker.stop().await;
46//! };
47//! ```
48//!
49//! With async-std runtime:
50//!
51//! ```ignore
52//! # use proc_daemon::resources::ResourceTracker;
53//! # use std::time::Duration;
54//! # let mut tracker = ResourceTracker::new(Duration::from_secs(1));
55//! #[cfg(all(feature = "async-std", not(feature = "tokio")))]
56//! async {
57//!     // Start tracking
58//!     tracker.start().unwrap();
59//!     
60//!     // ... use the tracker ...
61//!     
62//!     // Stop tracking when done
63//!     tracker.stop();
64//! };
65//! ```
66
67#[allow(unused_imports)]
68use crate::error::{Error, Result};
69#[cfg(feature = "metrics")]
70use crate::metrics::MetricsCollector;
71use arc_swap::ArcSwap;
72use std::collections::VecDeque;
73use std::sync::{Arc, RwLock};
74use std::time::{Duration, Instant};
75
76// Runtime-specific JoinHandle types
77#[cfg(all(feature = "async-std", not(feature = "tokio")))]
78#[allow(unused_imports)]
79use async_std::task::JoinHandle as AsyncJoinHandle;
80#[cfg(not(any(feature = "tokio", feature = "async-std")))]
81#[cfg(feature = "tokio")]
82#[allow(unused_imports)]
83use tokio::task::JoinHandle;
84#[cfg(feature = "tokio")]
85#[allow(unused_imports)]
86use tokio::time;
87
88// OS-specific imports
89#[cfg(target_os = "linux")]
90use std::fs::File;
91#[cfg(target_os = "linux")]
92use std::io::{BufRead, BufReader};
93#[cfg(target_os = "linux")]
94use std::num::NonZeroI64;
95
96#[cfg(target_os = "macos")]
97use std::process::Command;
98
99#[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
100use windows::Win32::System::Diagnostics::ToolHelp::{
101    CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
102};
103#[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
104use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
105#[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
106use windows::Win32::System::Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION};
107
108#[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
109use windows::Win32::Foundation::{CloseHandle, FILETIME};
110
111/// Represents the current resource usage of the process
112#[derive(Debug, Clone)]
113pub struct ResourceUsage {
114    /// Timestamp when the usage was recorded
115    timestamp: Instant,
116
117    /// Memory usage in bytes
118    memory_bytes: u64,
119
120    /// CPU usage as a percentage (0-100)
121    cpu_percent: f64,
122
123    /// Number of threads in the process
124    thread_count: u32,
125}
126
127/// Monitoring alerts emitted by `ResourceTracker`.
128#[derive(Debug, Clone)]
129pub enum Alert {
130    /// Soft memory limit exceeded (informational)
131    MemorySoftLimit {
132        /// The configured soft memory limit in bytes
133        limit_bytes: u64,
134        /// The current memory usage in bytes when the alert was triggered
135        current_bytes: u64,
136    },
137}
138
139impl ResourceUsage {
140    /// Creates a new `ResourceUsage` with the current time
141    #[must_use]
142    pub fn new(memory_bytes: u64, cpu_percent: f64, thread_count: u32) -> Self {
143        Self {
144            timestamp: Instant::now(),
145            memory_bytes,
146            cpu_percent,
147            thread_count,
148        }
149    }
150
151    /// Returns the memory usage in bytes
152    #[must_use]
153    pub const fn memory_bytes(&self) -> u64 {
154        self.memory_bytes
155    }
156
157    /// Returns the memory usage in megabytes
158    #[must_use]
159    #[allow(clippy::cast_precision_loss)]
160    pub fn memory_mb(&self) -> f64 {
161        // Simplify calculation for better accuracy
162        self.memory_bytes as f64 / 1_048_576.0
163    }
164
165    /// Returns the CPU usage as a percentage (0-100)
166    #[must_use]
167    pub const fn cpu_percent(&self) -> f64 {
168        self.cpu_percent
169    }
170
171    /// Returns the number of threads in the process
172    #[must_use]
173    pub const fn thread_count(&self) -> u32 {
174        self.thread_count
175    }
176
177    /// Returns the time elapsed since this usage was recorded
178    #[must_use]
179    pub fn age(&self) -> Duration {
180        self.timestamp.elapsed()
181    }
182}
183
184/// Provides resource tracking functionality for the current process
185#[allow(dead_code)]
186pub struct ResourceTracker {
187    /// The interval at which to sample resource usage
188    sample_interval: Duration,
189
190    /// The current resource usage (lock-free reads with arc-swap)
191    current_usage: Arc<ArcSwap<ResourceUsage>>,
192
193    /// Historical usage data with timestamps
194    history: Arc<RwLock<VecDeque<ResourceUsage>>>,
195
196    /// Maximum history entries to keep
197    max_history: usize,
198
199    /// Background task handle
200    #[cfg(feature = "tokio")]
201    task_handle: Option<tokio::task::JoinHandle<()>>,
202    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
203    task_handle: Option<async_std::task::JoinHandle<()>>,
204    #[cfg(not(any(feature = "tokio", feature = "async-std")))]
205    task_handle: Option<std::thread::JoinHandle<()>>,
206
207    /// The process ID being monitored (usually self)
208    pid: u32,
209
210    /// Optional soft memory limit in bytes. If exceeded, an alert is emitted.
211    memory_soft_limit_bytes: Option<u64>,
212
213    /// Optional alert handler callback
214    #[allow(clippy::type_complexity)]
215    on_alert: Option<Arc<dyn Fn(Alert) + Send + Sync + 'static>>,
216
217    /// Optional metrics collector (feature-gated)
218    #[cfg(feature = "metrics")]
219    metrics: Option<MetricsCollector>,
220}
221
222impl ResourceTracker {
223    /// Creates a new `ResourceTracker` with the given sampling interval.
224    ///
225    /// # Security
226    ///
227    /// By default, tracks the current process. To track a different process,
228    /// use `with_pid()` but note this may fail if the process doesn't exist
229    /// or you lack permissions.
230    #[must_use]
231    pub fn new(sample_interval: Duration) -> Self {
232        // Initialize with default values
233        let initial_usage = ResourceUsage::new(0, 0.0, 0);
234        let current_pid = std::process::id();
235
236        Self {
237            sample_interval,
238            current_usage: Arc::new(ArcSwap::from_pointee(initial_usage)),
239            history: Arc::new(RwLock::new(VecDeque::new())),
240            max_history: 60, // Default to 1 minute at 1 second intervals
241            task_handle: None,
242            pid: current_pid,
243            memory_soft_limit_bytes: None,
244            on_alert: None,
245            #[cfg(feature = "metrics")]
246            metrics: None,
247        }
248    }
249
250    /// Set a specific PID to monitor (defaults to current process).
251    ///
252    /// # Security
253    ///
254    /// Monitoring arbitrary processes may fail due to OS permissions.
255    /// Only use this if you have explicit authorization.
256    #[must_use]
257    pub const fn with_pid(mut self, pid: u32) -> Self {
258        self.pid = pid;
259        self
260    }
261
262    /// Sets the maximum history entries to keep
263    #[must_use]
264    pub const fn with_max_history(mut self, max_entries: usize) -> Self {
265        self.max_history = max_entries;
266        self
267    }
268
269    /// Sets a soft memory limit in bytes. When exceeded, an alert is emitted via `on_alert`.
270    #[must_use]
271    pub const fn with_memory_soft_limit_bytes(mut self, bytes: u64) -> Self {
272        self.memory_soft_limit_bytes = Some(bytes);
273        self
274    }
275
276    /// Sets an alert handler callback for monitoring alerts.
277    #[must_use]
278    pub fn with_alert_handler<F>(mut self, f: F) -> Self
279    where
280        F: Fn(Alert) + Send + Sync + 'static,
281    {
282        self.on_alert = Some(Arc::new(f));
283        self
284    }
285
286    /// Attaches a metrics collector for reporting resource metrics.
287    #[cfg(feature = "metrics")]
288    #[must_use]
289    pub fn with_metrics(mut self, metrics: MetricsCollector) -> Self {
290        self.metrics = Some(metrics);
291        self
292    }
293
294    /// Convenience: route alerts to tracing logs.
295    ///
296    /// Logs as `tracing::warn!` with structured fields per alert type.
297    #[must_use]
298    pub fn with_alert_to_tracing(mut self) -> Self {
299        self.on_alert = Some(Arc::new(|alert| match alert {
300            Alert::MemorySoftLimit {
301                limit_bytes,
302                current_bytes,
303            } => {
304                tracing::warn!(
305                    target: "proc_daemon::resources",
306                    limit_bytes,
307                    current_bytes,
308                    "Resource alert: soft memory limit exceeded"
309                );
310            }
311        }));
312        self
313    }
314
315    /// Starts the resource tracking in the background
316    /// Starts the resource tracker's background sampling task
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the process ID cannot be determined or
321    /// if there's an issue with the system APIs when gathering resource metrics
322    #[cfg(all(feature = "tokio", not(feature = "async-std")))]
323    #[allow(clippy::missing_errors_doc)]
324    pub fn start(&mut self) -> Result<()> {
325        if self.task_handle.is_some() {
326            return Ok(()); // Already started
327        }
328
329        let sample_interval = self.sample_interval;
330        let usage_history = Arc::clone(&self.history);
331        let current_usage = Arc::clone(&self.current_usage);
332        let pid = self.pid;
333        let max_history = self.max_history;
334        let memory_soft_limit_bytes = self.memory_soft_limit_bytes;
335        let on_alert = self.on_alert.clone();
336        #[cfg(feature = "metrics")]
337        let metrics = self.metrics.clone();
338
339        let handle = tokio::spawn(async move {
340            let mut interval_timer = time::interval(sample_interval);
341            let mut last_cpu_time = 0.0;
342            let mut last_timestamp = Instant::now();
343            #[cfg(feature = "metrics")]
344            let mut last_tick = Instant::now();
345
346            loop {
347                interval_timer.tick().await;
348                #[cfg(feature = "metrics")]
349                let tick_now = Instant::now();
350
351                // Get current resource usage
352                if let Ok(usage) =
353                    Self::sample_resource_usage(pid, &mut last_cpu_time, &mut last_timestamp)
354                {
355                    // Update current usage (lock-free store)
356                    current_usage.store(Arc::new(usage.clone()));
357
358                    // Update history with minimal lock time
359                    {
360                        let mut hist = usage_history.write().unwrap();
361                        hist.push_back(usage.clone());
362                        // Trim excess entries in one loop
363                        while hist.len() > max_history {
364                            hist.pop_front();
365                        }
366                    } // Drop lock immediately
367
368                    // Soft memory limit alert
369                    if let Some(limit) = memory_soft_limit_bytes {
370                        if usage.memory_bytes() > limit {
371                            if let Some(cb) = on_alert.as_ref() {
372                                cb(Alert::MemorySoftLimit {
373                                    limit_bytes: limit,
374                                    current_bytes: usage.memory_bytes(),
375                                });
376                            }
377                        }
378                    }
379
380                    // Metrics reporting (feature-gated)
381                    #[cfg(feature = "metrics")]
382                    if let Some(m) = metrics.as_ref() {
383                        m.set_gauge("proc.memory_bytes", usage.memory_bytes());
384                        let cpu_milli = (usage.cpu_percent() * 1000.0).max(0.0).round() as u64;
385                        m.set_gauge("proc.cpu_milli_percent", cpu_milli);
386                        m.set_gauge("proc.thread_count", u64::from(usage.thread_count()));
387                        m.increment_counter("proc.samples_total", 1);
388                        m.record_histogram(
389                            "proc.sample_interval",
390                            tick_now.saturating_duration_since(last_tick),
391                        );
392                        last_tick = tick_now;
393                    }
394                }
395            }
396        });
397
398        self.task_handle = Some(handle);
399        Ok(())
400    }
401
402    /// Starts the resource tracking
403    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
404    #[allow(clippy::missing_errors_doc)]
405    pub fn start(&mut self) -> Result<()> {
406        if self.task_handle.is_some() {
407            return Ok(()); // Already started
408        }
409
410        let sample_interval = self.sample_interval;
411        let usage_history = Arc::clone(&self.history);
412        let current_usage = Arc::clone(&self.current_usage);
413        let pid = self.pid;
414        let max_history = self.max_history; // Clone max_history to use inside async block
415        let memory_soft_limit_bytes = self.memory_soft_limit_bytes;
416        let on_alert = self.on_alert.clone();
417        #[cfg(feature = "metrics")]
418        let metrics = self.metrics.clone();
419
420        let handle = async_std::task::spawn(async move {
421            let mut last_cpu_time = 0.0;
422            let mut last_timestamp = Instant::now();
423            #[cfg(feature = "metrics")]
424            let mut last_tick = Instant::now();
425
426            loop {
427                async_std::task::sleep(sample_interval).await;
428                #[cfg(feature = "metrics")]
429                let tick_now = Instant::now();
430
431                // Get current resource usage
432                if let Ok(usage) =
433                    Self::sample_resource_usage(pid, &mut last_cpu_time, &mut last_timestamp)
434                {
435                    // Update current usage (lock-free store via ArcSwap)
436                    // Reuse Arc allocation by swapping instead of always allocating new
437                    let new_arc = Arc::new(usage.clone());
438                    current_usage.store(new_arc);
439
440                    // Update history with minimal lock time
441                    {
442                        let mut hist = usage_history.write().unwrap();
443                        hist.push_back(usage.clone());
444                        // Trim excess entries in one loop
445                        while hist.len() > max_history {
446                            hist.pop_front();
447                        }
448                    } // Drop lock immediately
449
450                    // Soft memory limit alert
451                    if let Some(limit) = memory_soft_limit_bytes {
452                        if usage.memory_bytes() > limit {
453                            if let Some(cb) = on_alert.as_ref() {
454                                cb(Alert::MemorySoftLimit {
455                                    limit_bytes: limit,
456                                    current_bytes: usage.memory_bytes(),
457                                });
458                            }
459                        }
460                    }
461
462                    // Metrics reporting (feature-gated)
463                    #[cfg(feature = "metrics")]
464                    if let Some(m) = metrics.as_ref() {
465                        m.set_gauge("proc.memory_bytes", usage.memory_bytes());
466                        let cpu_milli = (usage.cpu_percent() * 1000.0).max(0.0).round() as u64;
467                        m.set_gauge("proc.cpu_milli_percent", cpu_milli);
468                        m.set_gauge("proc.thread_count", u64::from(usage.thread_count()));
469                        m.increment_counter("proc.samples_total", 1);
470                        m.record_histogram(
471                            "proc.sample_interval",
472                            tick_now.saturating_duration_since(last_tick),
473                        );
474                    }
475                    #[cfg(feature = "metrics")]
476                    {
477                        last_tick = tick_now;
478                    }
479                }
480            }
481        });
482
483        self.task_handle = Some(handle);
484        Ok(())
485    }
486
487    /// Stops the resource tracker, cancelling any ongoing monitoring task.
488    ///
489    /// For tokio, this aborts the task and awaits its completion.
490    #[cfg(all(feature = "tokio", not(feature = "async-std")))]
491    pub async fn stop(&mut self) {
492        if let Some(handle) = self.task_handle.take() {
493            handle.abort();
494            let _ = handle.await;
495        }
496    }
497
498    /// Stops the resource tracker, cancelling any ongoing monitoring task.
499    ///
500    /// For async-std, this simply drops the `JoinHandle` which cancels the task.
501    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
502    pub fn stop(&mut self) {
503        // Just drop the handle, which will cancel the task on async-std
504        self.task_handle.take();
505    }
506
507    /// Returns the current resource usage
508    #[must_use]
509    pub fn current_usage(&self) -> ResourceUsage {
510        self.current_usage.load_full().as_ref().clone()
511    }
512
513    /// Returns a copy of the resource usage history
514    #[must_use]
515    pub fn history(&self) -> Vec<ResourceUsage> {
516        self.history
517            .read()
518            .map_or_else(|_| Vec::new(), |history| history.iter().cloned().collect())
519    }
520
521    /// Samples the resource usage for the given process ID
522    #[allow(unused_variables, dead_code)]
523    #[allow(clippy::needless_pass_by_ref_mut)]
524    fn sample_resource_usage(
525        pid: u32,
526        last_cpu_time: &mut f64,
527        last_timestamp: &mut Instant,
528    ) -> Result<ResourceUsage> {
529        #[cfg(target_os = "linux")]
530        {
531            // On Linux, read from /proc filesystem
532            let memory = Self::get_memory_linux(pid)?;
533            let (cpu, threads) = Self::get_cpu_linux(pid, last_cpu_time, last_timestamp)?;
534            Ok(ResourceUsage::new(memory, cpu, threads))
535        }
536
537        #[cfg(target_os = "macos")]
538        {
539            // On macOS, use ps command
540            let memory = Self::get_memory_macos(pid)?;
541            let (cpu, threads) = Self::get_cpu_macos(pid)?;
542            Ok(ResourceUsage::new(memory, cpu, threads))
543        }
544
545        #[cfg(target_os = "windows")]
546        {
547            // On Windows, use Windows API
548            let memory = Self::get_memory_windows(pid)?;
549            let (cpu, threads) = Self::get_cpu_windows(pid, last_cpu_time, last_timestamp)?;
550            Ok(ResourceUsage::new(memory, cpu, threads))
551        }
552
553        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
554        {
555            // Default placeholder for unsupported platforms
556            Ok(ResourceUsage::new(0, 0.0, 0))
557        }
558    }
559
560    #[cfg(target_os = "linux")]
561    fn get_memory_linux(pid: u32) -> Result<u64> {
562        // Read memory information from /proc/[pid]/status
563        let path = format!("/proc/{pid}/status");
564        let file = File::open(&path).map_err(|e| {
565            Error::io_with_source(format!("Failed to open {path} for memory stats"), e)
566        })?;
567
568        let reader = BufReader::new(file);
569        let mut memory_bytes = 0;
570
571        for line in reader.lines() {
572            let line = line.map_err(|e| {
573                Error::io_with_source("Failed to read process memory stats".to_string(), e)
574            })?;
575
576            // VmRSS gives the resident set size
577            if line.starts_with("VmRSS:") {
578                let parts: Vec<&str> = line.split_whitespace().collect();
579                if !parts.is_empty() {
580                    if let Ok(kb) = parts[1].parse::<u64>() {
581                        memory_bytes = kb * 1024;
582                        break;
583                    }
584                }
585            }
586        }
587
588        Ok(memory_bytes)
589    }
590
591    #[cfg(target_os = "linux")]
592    #[allow(
593        clippy::cast_precision_loss,
594        clippy::cast_possible_truncation,
595        clippy::similar_names
596    )]
597    fn get_cpu_linux(
598        pid: u32,
599        last_cpu_time: &mut f64,
600        last_timestamp: &mut Instant,
601    ) -> Result<(f64, u32)> {
602        // Read CPU information from /proc/[pid]/stat
603        let path = format!("/proc/{pid}/stat");
604        let file = File::open(&path).map_err(|e| {
605            Error::io_with_source(format!("Failed to open {path} for CPU stats"), e)
606        })?;
607
608        let reader = BufReader::new(file);
609        let mut cpu_percent = 0.0;
610        let mut thread_count: u32 = 0;
611
612        if let Ok(line) = reader.lines().next().ok_or_else(|| {
613            Error::runtime("Failed to read CPU stats from proc filesystem".to_string())
614        }) {
615            let line = line.map_err(|e| {
616                Error::io_with_source("Failed to read process CPU stats".to_string(), e)
617            })?;
618
619            if let Some((cpu_time, threads)) = Self::parse_proc_stat(&line) {
620                thread_count = threads;
621
622                let now = Instant::now();
623                if *last_timestamp != now {
624                    let time_diff = now.duration_since(*last_timestamp).as_secs_f64();
625                    if time_diff > 0.0 {
626                        let num_cores = num_cpus::get() as f64;
627                        let cpu_time_diff = cpu_time - *last_cpu_time;
628                        let ticks = Self::linux_clk_tck();
629
630                        cpu_percent = (cpu_time_diff / ticks) / time_diff * 100.0 / num_cores;
631                    }
632                }
633
634                *last_cpu_time = cpu_time;
635                *last_timestamp = now;
636            }
637        }
638
639        Ok((cpu_percent, thread_count))
640    }
641
642    #[cfg(target_os = "linux")]
643    fn parse_proc_stat(line: &str) -> Option<(f64, u32)> {
644        let open = line.find('(')?;
645        let close = line.rfind(')')?;
646        if close <= open {
647            return None;
648        }
649
650        // Everything after the closing ')' begins with state (field 3).
651        let rest = line.get((close + 1)..)?;
652        let parts: Vec<&str> = rest.split_whitespace().collect();
653        // Need up to field 20 (num_threads) => index 17 in this slice.
654        if parts.len() <= 17 {
655            return None;
656        }
657
658        let utime = parts.get(11)?.parse::<f64>().unwrap_or(0.0);
659        let stime = parts.get(12)?.parse::<f64>().unwrap_or(0.0);
660        let child_user_time = parts.get(13)?.parse::<f64>().unwrap_or(0.0);
661        let child_system_time = parts.get(14)?.parse::<f64>().unwrap_or(0.0);
662        let thread_count = parts.get(17)?.parse::<u32>().unwrap_or(0);
663
664        let current_cpu_time = utime + stime + child_user_time + child_system_time;
665        Some((current_cpu_time, thread_count))
666    }
667
668    #[cfg(target_os = "linux")]
669    #[allow(clippy::cast_precision_loss)]
670    #[allow(unsafe_code)]
671    fn linux_clk_tck() -> f64 {
672        // SAFETY: This is a read-only system configuration query that's guaranteed to be safe.
673        // sysconf(_SC_CLK_TCK) returns the number of clock ticks per second, which is a
674        // system constant that cannot cause memory safety issues.
675        #[cfg_attr(not(target_os = "linux"), allow(unused_unsafe))]
676        let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
677        NonZeroI64::new(ticks).map_or(100.0, |v| v.get() as f64)
678    }
679
680    #[cfg(target_os = "macos")]
681    #[allow(dead_code)]
682    fn get_memory_macos(pid: u32) -> Result<u64> {
683        // Use ps command to get memory usage on macOS
684        let output = Command::new("/bin/ps")
685            .args(["-xo", "rss=", "-p", &pid.to_string()])
686            .output()
687            .map_err(|e| {
688                Error::io_with_source(
689                    "Failed to execute ps command for memory stats".to_string(),
690                    e,
691                )
692            })?;
693
694        let memory_kb = String::from_utf8_lossy(&output.stdout)
695            .trim()
696            .parse::<u64>()
697            .unwrap_or(0);
698
699        Ok(memory_kb * 1024)
700    }
701
702    #[cfg(target_os = "macos")]
703    #[allow(dead_code)]
704    fn get_cpu_macos(pid: u32) -> Result<(f64, u32)> {
705        // Get CPU percentage using ps
706        let output = Command::new("/bin/ps")
707            .args(["-xo", "%cpu,thcount=", "-p", &pid.to_string()])
708            .output()
709            .map_err(|e| {
710                Error::io_with_source("Failed to execute ps command for CPU stats".to_string(), e)
711            })?;
712
713        let stats = String::from_utf8_lossy(&output.stdout);
714        let parts: Vec<&str> = stats.split_whitespace().collect();
715
716        let cpu_percent = if parts.is_empty() {
717            0.0
718        } else {
719            parts[0].parse::<f64>().unwrap_or(0.0)
720        };
721
722        let thread_count = if parts.len() > 1 {
723            parts[1].parse::<u32>().unwrap_or(0)
724        } else {
725            0
726        };
727
728        Ok((cpu_percent, thread_count))
729    }
730
731    #[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
732    #[allow(unsafe_code)]
733    fn get_memory_windows(pid: u32) -> Result<u64> {
734        let mut pmc = PROCESS_MEMORY_COUNTERS::default();
735        let handle =
736            unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, false, pid) }.map_err(|e| {
737                Error::runtime_with_source(
738                    format!("Failed to open process {} for memory stats", pid),
739                    e,
740                )
741            })?;
742
743        let result = unsafe {
744            GetProcessMemoryInfo(
745                handle,
746                &mut pmc,
747                std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
748            )
749        }
750        .map_err(|e| {
751            Error::runtime_with_source("Failed to get process memory info".to_string(), e)
752        });
753
754        unsafe { CloseHandle(handle) };
755        result?;
756
757        Ok(u64::try_from(pmc.WorkingSetSize).unwrap_or(pmc.WorkingSetSize as u64))
758    }
759
760    #[cfg(all(target_os = "windows", not(feature = "windows-monitoring")))]
761    fn get_memory_windows(_pid: u32) -> Result<u64> {
762        Err(Error::runtime(
763            "Windows monitoring not enabled. Enable the 'windows-monitoring' feature".to_string(),
764        ))
765    }
766
767    #[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
768    #[allow(unsafe_code, clippy::cast_precision_loss)]
769    fn get_cpu_windows(
770        pid: u32,
771        last_cpu_time: &mut f64,
772        last_timestamp: &mut Instant,
773    ) -> Result<(f64, u32)> {
774        let mut cpu_percent = 0.0;
775        let mut thread_count = 0;
776
777        let handle =
778            unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, false, pid) }.map_err(|e| {
779                Error::runtime_with_source(
780                    format!("Failed to open process {} for CPU stats", pid),
781                    e,
782                )
783            })?;
784
785        // Get thread count by enumerating threads using ToolHelp snapshot
786        unsafe {
787            let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(|e| {
788                Error::runtime_with_source(
789                    "Failed to create ToolHelp snapshot for threads".to_string(),
790                    e,
791                )
792            })?;
793
794            let mut entry: THREADENTRY32 = std::mem::zeroed();
795            entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
796
797            if Thread32First(snapshot, &mut entry).is_ok() {
798                loop {
799                    if entry.th32OwnerProcessID == pid {
800                        thread_count += 1;
801                    }
802                    if Thread32Next(snapshot, &mut entry).is_err() {
803                        break;
804                    }
805                }
806            }
807
808            let _ = CloseHandle(snapshot);
809        }
810
811        // Get CPU times
812        let mut creation_time = FILETIME::default();
813        let mut exit_time = FILETIME::default();
814        let mut kernel_time = FILETIME::default();
815        let mut user_time = FILETIME::default();
816
817        let result = unsafe {
818            GetProcessTimes(
819                handle,
820                &mut creation_time,
821                &mut exit_time,
822                &mut kernel_time,
823                &mut user_time,
824            )
825        };
826
827        if result.is_ok() {
828            let kernel_ns = Self::filetime_to_ns(&kernel_time);
829            let user_ns = Self::filetime_to_ns(&user_time);
830            let total_time = (kernel_ns + user_ns) as f64 / 1_000_000_000.0; // Convert to seconds
831
832            let now = Instant::now();
833            if *last_timestamp != now {
834                let time_diff = now.duration_since(*last_timestamp).as_secs_f64();
835                if time_diff > 0.0 {
836                    let time_diff_cpu = total_time - *last_cpu_time;
837                    let num_cores = num_cpus::get() as f64;
838
839                    // Calculate CPU percentage
840                    cpu_percent = (time_diff_cpu / time_diff) * 100.0 / num_cores;
841                }
842            }
843
844            // Update last values
845            *last_cpu_time = total_time;
846            *last_timestamp = now;
847        }
848
849        unsafe { CloseHandle(handle) };
850
851        Ok((cpu_percent, thread_count))
852    }
853
854    #[cfg(all(target_os = "windows", not(feature = "windows-monitoring")))]
855    fn get_cpu_windows(
856        _pid: u32,
857        _last_cpu_time: &mut f64,
858        _last_timestamp: &mut Instant,
859    ) -> Result<(f64, u32)> {
860        Err(Error::runtime(
861            "Windows monitoring not enabled. Enable the 'windows-monitoring' feature".to_string(),
862        ))
863    }
864
865    #[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
866    fn filetime_to_ns(ft: &windows::Win32::Foundation::FILETIME) -> u64 {
867        // Convert Windows FILETIME to nanoseconds
868        let high = (ft.dwHighDateTime as u64) << 32;
869        let low = ft.dwLowDateTime as u64;
870        // Windows ticks are 100ns intervals
871        (high + low) * 100
872    }
873}
874
875impl Drop for ResourceTracker {
876    fn drop(&mut self) {
877        if let Some(handle) = self.task_handle.take() {
878            #[cfg(feature = "tokio")]
879            handle.abort();
880            // For async-std, dropping the handle is sufficient
881            // as it cancels the associated task
882            #[cfg(all(feature = "async-std", not(feature = "tokio")))]
883            drop(handle); // Explicitly drop handle to ensure it's used
884        }
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use std::time::Duration;
892
893    #[cfg(feature = "tokio")]
894    #[cfg_attr(miri, ignore)]
895    #[tokio::test]
896    async fn test_resource_tracker_creation() {
897        let tracker = ResourceTracker::new(Duration::from_secs(1));
898        assert_eq!(tracker.max_history, 60);
899        assert_eq!(tracker.sample_interval, Duration::from_secs(1));
900    }
901
902    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
903    #[async_std::test]
904    async fn test_resource_tracker_creation() {
905        let tracker = ResourceTracker::new(Duration::from_secs(1));
906        assert_eq!(tracker.max_history, 60);
907        assert_eq!(tracker.sample_interval, Duration::from_secs(1));
908    }
909
910    #[cfg(feature = "tokio")]
911    #[cfg_attr(miri, ignore)]
912    #[tokio::test]
913    async fn test_resource_usage_methods() {
914        let usage = ResourceUsage::new(1_048_576, 5.5, 4);
915        assert_eq!(usage.memory_bytes(), 1_048_576);
916        // Use a more reasonable epsilon for floating point comparisons
917        let epsilon: f64 = 1e-6;
918        assert!((usage.memory_mb() - 1.0).abs() < epsilon);
919        assert!((usage.cpu_percent() - 5.5).abs() < epsilon);
920        assert_eq!(usage.thread_count(), 4);
921        assert!(usage.age() >= Duration::from_nanos(0));
922    }
923
924    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
925    #[async_std::test]
926    async fn test_resource_usage_methods() {
927        let usage = ResourceUsage::new(1_048_576, 5.5, 4);
928        assert_eq!(usage.memory_bytes(), 1_048_576);
929        // Use a more reasonable epsilon for floating point comparisons
930        let epsilon: f64 = 1e-6;
931        assert!((usage.memory_mb() - 1.0).abs() < epsilon);
932        assert!((usage.cpu_percent() - 5.5).abs() < epsilon);
933        assert_eq!(usage.thread_count(), 4);
934        assert!(usage.age() >= Duration::from_nanos(0));
935    }
936
937    #[cfg(feature = "tokio")]
938    #[cfg_attr(miri, ignore)]
939    #[tokio::test]
940    async fn test_tracker_with_max_history() {
941        let tracker = ResourceTracker::new(Duration::from_secs(1)).with_max_history(100);
942        assert_eq!(tracker.max_history, 100);
943    }
944
945    #[cfg(all(target_os = "windows", feature = "windows-monitoring"))]
946    #[cfg_attr(miri, ignore)]
947    #[test]
948    fn test_windows_toolhelp_thread_count_path() {
949        let pid = std::process::id();
950        let mut last_cpu_time = 0.0;
951        let mut last_timestamp = Instant::now();
952
953        // Exercise the ToolHelp-based sampling path
954        let usage = ResourceTracker::sample_resource_usage(
955            pid,
956            &mut last_cpu_time,
957            &mut last_timestamp,
958        )
959        .expect(
960            "Windows sample_resource_usage should succeed with windows-monitoring feature enabled",
961        );
962
963        // A running process should have at least one thread
964        assert!(
965            usage.thread_count() >= 1,
966            "expected at least 1 thread, got {}",
967            usage.thread_count()
968        );
969    }
970
971    #[cfg(all(feature = "async-std", not(feature = "tokio")))]
972    #[async_std::test]
973    async fn test_tracker_with_max_history() {
974        let tracker = ResourceTracker::new(Duration::from_secs(1)).with_max_history(100);
975        assert_eq!(tracker.max_history, 100);
976    }
977}