Skip to main content

proc_tree/
default_store.rs

1//! Default storage implementation using standard library types.
2//!
3//! [`DefaultStore`] is a `HashMap<Mutex>` store with optional TTL-based eviction.
4//!
5//! # Example
6//!
7//! ```rust
8//! use proc_tree::{DefaultStore, snapshot};
9//!
10//! let store = DefaultStore::new(600);
11//! snapshot(&store);
12//! ```
13
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use crate::traits::ProcessStore;
19use crate::types::ProcessInfo;
20
21// ---- Internal entry with optional TTL ----
22
23struct Entry {
24    value: ProcessInfo,
25    inserted_at: Instant,
26}
27
28impl Clone for Entry {
29    fn clone(&self) -> Self {
30        Self {
31            value: self.value.clone(),
32            inserted_at: self.inserted_at,
33        }
34    }
35}
36
37// ---- Shared inner ----
38
39type Inner = Arc<Mutex<HashMap<u32, Entry>>>;
40
41fn get_inner(
42    inner: &Inner,
43    pid: u32,
44    ttl: Duration,
45    children_index: &Arc<Mutex<HashMap<u32, Vec<u32>>>>,
46) -> Option<ProcessInfo> {
47    let mut map = inner.lock().unwrap();
48    let entry = map.get(&pid)?;
49    if !ttl.is_zero() && entry.inserted_at.elapsed() >= ttl {
50        let info = map.remove(&pid).unwrap().value;
51        // Update children index
52        let mut index = children_index.lock().unwrap();
53        if let Some(children) = index.get_mut(&info.ppid) {
54            children.retain(|&c| c != pid);
55        }
56        // Clean up this process's own children index entry
57        index.remove(&pid);
58        return None;
59    }
60    Some(entry.value.clone())
61}
62
63fn insert_inner(inner: &Inner, pid: u32, value: ProcessInfo) {
64    let mut map = inner.lock().unwrap();
65    map.insert(
66        pid,
67        Entry {
68            value,
69            inserted_at: Instant::now(),
70        },
71    );
72}
73
74// ---- DefaultStore ----
75
76/// Process tree store backed by `HashMap<Mutex>` with optional TTL eviction.
77///
78/// Thread-safe via `Arc<Mutex<...>>`. Cloning shares the same data.
79pub struct DefaultStore {
80    inner: Inner,
81    children_index: Arc<Mutex<HashMap<u32, Vec<u32>>>>,
82    ttl: Duration,
83}
84
85impl DefaultStore {
86    /// Create a new store with the given TTL in seconds.
87    /// `ttl_secs = 0` means no expiration.
88    pub fn new(ttl_secs: u64) -> Self {
89        Self {
90            inner: Arc::new(Mutex::new(HashMap::new())),
91            children_index: Arc::new(Mutex::new(HashMap::new())),
92            ttl: Duration::from_secs(ttl_secs),
93        }
94    }
95
96    /// Number of entries (including possibly-expired ones not yet evicted).
97    pub fn len(&self) -> usize {
98        self.inner.lock().unwrap().len()
99    }
100
101    /// Returns `true` if the store contains no entries.
102    pub fn is_empty(&self) -> bool {
103        self.len() == 0
104    }
105
106    /// Check if a PID exists in the store.
107    ///
108    /// Does not trigger TTL eviction — use [`get_process`](ProcessStore::get_process)
109    /// to check existence with TTL enforcement.
110    pub fn contains_key(&self, pid: u32) -> bool {
111        self.inner.lock().unwrap().contains_key(&pid)
112    }
113}
114
115impl Clone for DefaultStore {
116    fn clone(&self) -> Self {
117        Self {
118            inner: Arc::clone(&self.inner),
119            children_index: Arc::clone(&self.children_index),
120            ttl: self.ttl,
121        }
122    }
123}
124
125impl Default for DefaultStore {
126    /// Creates a store with no TTL.
127    fn default() -> Self {
128        Self::new(0)
129    }
130}
131
132impl ProcessStore for DefaultStore {
133    fn get_process(&self, pid: u32) -> Option<ProcessInfo> {
134        get_inner(&self.inner, pid, self.ttl, &self.children_index)
135    }
136
137    fn insert_process(&self, pid: u32, info: ProcessInfo) {
138        let new_ppid = info.ppid;
139
140        // Check if process already exists with different ppid
141        let old_ppid = {
142            let map = self.inner.lock().unwrap();
143            map.get(&pid).map(|e| e.value.ppid)
144        };
145
146        // Insert the process
147        insert_inner(&self.inner, pid, info);
148
149        // Update children index
150        let mut index = self.children_index.lock().unwrap();
151
152        // Remove from old parent's index if ppid changed
153        if let Some(old_ppid) = old_ppid
154            && old_ppid != new_ppid
155            && let Some(children) = index.get_mut(&old_ppid)
156        {
157            children.retain(|&c| c != pid);
158        }
159
160        // Add to new parent's index (avoid duplicates)
161        let children = index.entry(new_ppid).or_default();
162        if !children.contains(&pid) {
163            children.push(pid);
164        }
165    }
166
167    fn remove_process(&self, pid: u32) -> Option<ProcessInfo> {
168        // Remove from inner
169        let info = {
170            let mut map = self.inner.lock().unwrap();
171            map.remove(&pid).map(|e| e.value)
172        };
173
174        // Remove from children index
175        if let Some(ref p) = info {
176            let mut index = self.children_index.lock().unwrap();
177            // Remove from parent's index
178            if let Some(children) = index.get_mut(&p.ppid) {
179                children.retain(|&c| c != pid);
180            }
181            // Clean up this process's own children index entry
182            index.remove(&pid);
183        }
184
185        info
186    }
187
188    fn all_pids(&self) -> Vec<u32> {
189        self.inner.lock().unwrap().keys().copied().collect()
190    }
191
192    fn children_of(&self, pid: u32) -> Vec<u32> {
193        // O(1) lookup from index
194        self.children_index
195            .lock()
196            .unwrap()
197            .get(&pid)
198            .cloned()
199            .unwrap_or_default()
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn default_store_insert_get() {
209        let store = DefaultStore::new(0);
210        store.insert_process(
211            1,
212            ProcessInfo {
213                ppid: 0,
214                cmd: "init".into(),
215                user: "root".into(),
216                tgid: 1,
217                start_time_ns: 0,
218            },
219        );
220        let info = store.get_process(1).unwrap();
221        assert_eq!(info.ppid, 0);
222        assert_eq!(info.cmd, "init");
223    }
224
225    #[test]
226    fn default_store_ttl_expired() {
227        let store = DefaultStore::new(0); // ttl=0 means no expiry
228        store.insert_process(
229            1,
230            ProcessInfo {
231                ppid: 0,
232                cmd: "init".into(),
233                user: "root".into(),
234                tgid: 1,
235                start_time_ns: 0,
236            },
237        );
238        assert!(store.get_process(1).is_some());
239
240        // With ttl=1, entry expires after 1 second
241        let store = DefaultStore::new(1);
242        store.insert_process(
243            1,
244            ProcessInfo {
245                ppid: 0,
246                cmd: "init".into(),
247                user: "root".into(),
248                tgid: 1,
249                start_time_ns: 0,
250            },
251        );
252        assert!(store.get_process(1).is_some());
253        std::thread::sleep(Duration::from_millis(1100));
254        assert!(store.get_process(1).is_none());
255    }
256
257    #[test]
258    fn clone_shares_data() {
259        let store = DefaultStore::new(0);
260        store.insert_process(
261            1,
262            ProcessInfo {
263                ppid: 0,
264                cmd: "init".into(),
265                user: "root".into(),
266                tgid: 1,
267                start_time_ns: 0,
268            },
269        );
270        let store2 = store.clone();
271        assert!(store2.get_process(1).is_some());
272        store2.insert_process(
273            2,
274            ProcessInfo {
275                ppid: 1,
276                cmd: "bash".into(),
277                user: "root".into(),
278                tgid: 2,
279                start_time_ns: 0,
280            },
281        );
282        assert!(store.get_process(2).is_some());
283    }
284
285    #[test]
286    fn len_and_contains() {
287        let store = DefaultStore::new(0);
288        assert_eq!(store.len(), 0);
289        store.insert_process(
290            1,
291            ProcessInfo {
292                ppid: 0,
293                cmd: "a".into(),
294                user: "u".into(),
295                tgid: 1,
296                start_time_ns: 0,
297            },
298        );
299        assert_eq!(store.len(), 1);
300        assert!(store.contains_key(1));
301        assert!(!store.contains_key(999));
302    }
303
304    #[test]
305    fn is_empty_default() {
306        let store = DefaultStore::new(0);
307        assert!(store.is_empty());
308        store.insert_process(
309            1,
310            ProcessInfo {
311                ppid: 0,
312                cmd: "a".into(),
313                user: "u".into(),
314                tgid: 1,
315                start_time_ns: 0,
316            },
317        );
318        assert!(!store.is_empty());
319    }
320
321    #[test]
322    fn all_pids_returns_keys() {
323        let store = DefaultStore::new(0);
324        store.insert_process(
325            1,
326            ProcessInfo {
327                ppid: 0,
328                cmd: "a".into(),
329                user: "u".into(),
330                tgid: 1,
331                start_time_ns: 0,
332            },
333        );
334        store.insert_process(
335            2,
336            ProcessInfo {
337                ppid: 1,
338                cmd: "b".into(),
339                user: "u".into(),
340                tgid: 2,
341                start_time_ns: 0,
342            },
343        );
344        store.insert_process(
345            3,
346            ProcessInfo {
347                ppid: 1,
348                cmd: "c".into(),
349                user: "u".into(),
350                tgid: 3,
351                start_time_ns: 0,
352            },
353        );
354        let mut pids = store.all_pids();
355        pids.sort();
356        assert_eq!(pids, vec![1, 2, 3]);
357    }
358
359    #[test]
360    fn ttl_contains_key_does_not_expire() {
361        let store = DefaultStore::new(1);
362        store.insert_process(
363            1,
364            ProcessInfo {
365                ppid: 0,
366                cmd: "a".into(),
367                user: "u".into(),
368                tgid: 1,
369                start_time_ns: 0,
370            },
371        );
372        assert!(store.contains_key(1));
373        std::thread::sleep(Duration::from_millis(1100));
374        // contains_key does not trigger TTL eviction
375        assert!(store.contains_key(1));
376        // but get_process does
377        assert!(store.get_process(1).is_none());
378    }
379
380    #[test]
381    fn remove_process() {
382        let store = DefaultStore::new(0);
383        store.insert_process(
384            1,
385            ProcessInfo {
386                ppid: 0,
387                cmd: "init".into(),
388                user: "root".into(),
389                tgid: 1,
390                start_time_ns: 0,
391            },
392        );
393        store.insert_process(
394            2,
395            ProcessInfo {
396                ppid: 1,
397                cmd: "bash".into(),
398                user: "root".into(),
399                tgid: 2,
400                start_time_ns: 0,
401            },
402        );
403
404        assert_eq!(store.len(), 2);
405        assert!(store.contains_key(2));
406
407        let removed = store.remove_process(2);
408        assert!(removed.is_some());
409        assert_eq!(store.len(), 1);
410        assert!(!store.contains_key(2));
411    }
412
413    #[test]
414    fn children_of() {
415        let store = DefaultStore::new(0);
416        store.insert_process(
417            1,
418            ProcessInfo {
419                ppid: 0,
420                cmd: "init".into(),
421                user: "root".into(),
422                tgid: 1,
423                start_time_ns: 0,
424            },
425        );
426        store.insert_process(
427            100,
428            ProcessInfo {
429                ppid: 1,
430                cmd: "a".into(),
431                user: "root".into(),
432                tgid: 100,
433                start_time_ns: 0,
434            },
435        );
436        store.insert_process(
437            200,
438            ProcessInfo {
439                ppid: 1,
440                cmd: "b".into(),
441                user: "root".into(),
442                tgid: 200,
443                start_time_ns: 0,
444            },
445        );
446        store.insert_process(
447            300,
448            ProcessInfo {
449                ppid: 100,
450                cmd: "c".into(),
451                user: "root".into(),
452                tgid: 300,
453                start_time_ns: 0,
454            },
455        );
456
457        let mut kids = store.children_of(1);
458        kids.sort();
459        assert_eq!(kids, vec![100, 200]);
460
461        let kids_100 = store.children_of(100);
462        assert_eq!(kids_100, vec![300]);
463
464        assert!(store.children_of(999).is_empty());
465    }
466
467    #[test]
468    fn insert_ppid_change_removes_from_old_parent() {
469        let store = DefaultStore::new(0);
470        store.insert_process(
471            1,
472            ProcessInfo {
473                ppid: 0,
474                cmd: "init".into(),
475                user: "root".into(),
476                tgid: 1,
477                start_time_ns: 0,
478            },
479        );
480        store.insert_process(
481            100,
482            ProcessInfo {
483                ppid: 0,
484                cmd: "other".into(),
485                user: "root".into(),
486                tgid: 100,
487                start_time_ns: 0,
488            },
489        );
490        store.insert_process(
491            200,
492            ProcessInfo {
493                ppid: 100,
494                cmd: "child".into(),
495                user: "root".into(),
496                tgid: 200,
497                start_time_ns: 0,
498            },
499        );
500
501        // child 200 is under parent 100
502        assert_eq!(store.children_of(100), vec![200]);
503        assert!(store.children_of(1).is_empty());
504
505        // Re-parent 200 from 100 to 1
506        store.insert_process(
507            200,
508            ProcessInfo {
509                ppid: 1,
510                cmd: "child".into(),
511                user: "root".into(),
512                tgid: 200,
513                start_time_ns: 0,
514            },
515        );
516
517        // 200 should be removed from old parent's index
518        assert!(
519            store.children_of(100).is_empty(),
520            "old parent should have no children"
521        );
522        // 200 should be in new parent's index
523        assert_eq!(store.children_of(1), vec![200]);
524    }
525
526    #[test]
527    fn insert_same_ppid_no_duplicate() {
528        let store = DefaultStore::new(0);
529        store.insert_process(
530            1,
531            ProcessInfo {
532                ppid: 0,
533                cmd: "init".into(),
534                user: "root".into(),
535                tgid: 1,
536                start_time_ns: 0,
537            },
538        );
539        store.insert_process(
540            100,
541            ProcessInfo {
542                ppid: 1,
543                cmd: "a".into(),
544                user: "root".into(),
545                tgid: 100,
546                start_time_ns: 0,
547            },
548        );
549        // Insert same pid with same ppid again
550        store.insert_process(
551            100,
552            ProcessInfo {
553                ppid: 1,
554                cmd: "a".into(),
555                user: "root".into(),
556                tgid: 100,
557                start_time_ns: 0,
558            },
559        );
560        // Should not duplicate
561        assert_eq!(store.children_of(1), vec![100]);
562    }
563
564    #[test]
565    fn remove_process_cleans_own_children_index() {
566        let store = DefaultStore::new(0);
567        store.insert_process(
568            1,
569            ProcessInfo {
570                ppid: 0,
571                cmd: "init".into(),
572                user: "root".into(),
573                tgid: 1,
574                start_time_ns: 0,
575            },
576        );
577        store.insert_process(
578            100,
579            ProcessInfo {
580                ppid: 1,
581                cmd: "parent".into(),
582                user: "root".into(),
583                tgid: 100,
584                start_time_ns: 0,
585            },
586        );
587        store.insert_process(
588            200,
589            ProcessInfo {
590                ppid: 100,
591                cmd: "child".into(),
592                user: "root".into(),
593                tgid: 200,
594                start_time_ns: 0,
595            },
596        );
597
598        assert_eq!(store.children_of(100), vec![200]);
599
600        // Remove parent 100
601        store.remove_process(100);
602
603        // children_of(100) should return empty, not stale [200]
604        assert!(
605            store.children_of(100).is_empty(),
606            "removed process should have no children index"
607        );
608        // Child 200 still exists in store but parent is gone
609        assert!(store.get_process(200).is_some());
610    }
611
612    #[test]
613    fn ttl_expiration_cleans_own_children_index() {
614        let store = DefaultStore::new(1); // 1 second TTL
615        store.insert_process(
616            1,
617            ProcessInfo {
618                ppid: 0,
619                cmd: "init".into(),
620                user: "root".into(),
621                tgid: 1,
622                start_time_ns: 0,
623            },
624        );
625        store.insert_process(
626            100,
627            ProcessInfo {
628                ppid: 1,
629                cmd: "parent".into(),
630                user: "root".into(),
631                tgid: 100,
632                start_time_ns: 0,
633            },
634        );
635        store.insert_process(
636            200,
637            ProcessInfo {
638                ppid: 100,
639                cmd: "child".into(),
640                user: "root".into(),
641                tgid: 200,
642                start_time_ns: 0,
643            },
644        );
645
646        assert_eq!(store.children_of(100), vec![200]);
647
648        // Wait for parent to expire
649        std::thread::sleep(Duration::from_millis(1100));
650
651        // Accessing expired parent triggers eviction
652        assert!(store.get_process(100).is_none());
653
654        // children_of(100) should return empty, not stale [200]
655        assert!(
656            store.children_of(100).is_empty(),
657            "expired process should have no children index"
658        );
659    }
660}