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().expect("lock poisoned");
48    let entry = map.get(&pid)?;
49    if !ttl.is_zero() && entry.inserted_at.elapsed() >= ttl {
50        let info = map.remove(&pid).expect("entry should exist").value;
51        // Update children index
52        let mut index = children_index.lock().expect("lock poisoned");
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().expect("lock poisoned");
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().expect("lock poisoned").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().expect("lock poisoned").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().expect("lock poisoned");
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().expect("lock poisoned");
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().expect("lock poisoned");
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().expect("lock poisoned");
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
190            .lock()
191            .expect("lock poisoned")
192            .keys()
193            .copied()
194            .collect()
195    }
196
197    fn for_each_child(&self, pid: u32, f: &mut dyn FnMut(u32)) {
198        // Collect children first, then release lock before calling f.
199        // f may call insert_process which also locks children_index.
200        let children: Vec<u32> = {
201            let index = self.children_index.lock().expect("lock poisoned");
202            index.get(&pid).cloned().unwrap_or_default()
203        };
204        for child in children {
205            f(child);
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn default_store_insert_get() {
216        let store = DefaultStore::new(0);
217        store.insert_process(
218            1,
219            ProcessInfo {
220                ppid: 0,
221                cmd: "init".into(),
222                user: "root".into(),
223                tgid: 1,
224                start_time_ns: 0,
225            },
226        );
227        let info = store.get_process(1).unwrap();
228        assert_eq!(info.ppid, 0);
229        assert_eq!(info.cmd, "init");
230    }
231
232    #[test]
233    fn default_store_ttl_expired() {
234        let store = DefaultStore::new(0); // ttl=0 means no expiry
235        store.insert_process(
236            1,
237            ProcessInfo {
238                ppid: 0,
239                cmd: "init".into(),
240                user: "root".into(),
241                tgid: 1,
242                start_time_ns: 0,
243            },
244        );
245        assert!(store.get_process(1).is_some());
246
247        // With ttl=1, entry expires after 1 second
248        let store = DefaultStore::new(1);
249        store.insert_process(
250            1,
251            ProcessInfo {
252                ppid: 0,
253                cmd: "init".into(),
254                user: "root".into(),
255                tgid: 1,
256                start_time_ns: 0,
257            },
258        );
259        assert!(store.get_process(1).is_some());
260        std::thread::sleep(Duration::from_millis(1100));
261        assert!(store.get_process(1).is_none());
262    }
263
264    #[test]
265    fn clone_shares_data() {
266        let store = DefaultStore::new(0);
267        store.insert_process(
268            1,
269            ProcessInfo {
270                ppid: 0,
271                cmd: "init".into(),
272                user: "root".into(),
273                tgid: 1,
274                start_time_ns: 0,
275            },
276        );
277        let store2 = store.clone();
278        assert!(store2.get_process(1).is_some());
279        store2.insert_process(
280            2,
281            ProcessInfo {
282                ppid: 1,
283                cmd: "bash".into(),
284                user: "root".into(),
285                tgid: 2,
286                start_time_ns: 0,
287            },
288        );
289        assert!(store.get_process(2).is_some());
290    }
291
292    #[test]
293    fn len_and_contains() {
294        let store = DefaultStore::new(0);
295        assert_eq!(store.len(), 0);
296        store.insert_process(
297            1,
298            ProcessInfo {
299                ppid: 0,
300                cmd: "a".into(),
301                user: "u".into(),
302                tgid: 1,
303                start_time_ns: 0,
304            },
305        );
306        assert_eq!(store.len(), 1);
307        assert!(store.contains_key(1));
308        assert!(!store.contains_key(999));
309    }
310
311    #[test]
312    fn is_empty_default() {
313        let store = DefaultStore::new(0);
314        assert!(store.is_empty());
315        store.insert_process(
316            1,
317            ProcessInfo {
318                ppid: 0,
319                cmd: "a".into(),
320                user: "u".into(),
321                tgid: 1,
322                start_time_ns: 0,
323            },
324        );
325        assert!(!store.is_empty());
326    }
327
328    #[test]
329    fn all_pids_returns_keys() {
330        let store = DefaultStore::new(0);
331        store.insert_process(
332            1,
333            ProcessInfo {
334                ppid: 0,
335                cmd: "a".into(),
336                user: "u".into(),
337                tgid: 1,
338                start_time_ns: 0,
339            },
340        );
341        store.insert_process(
342            2,
343            ProcessInfo {
344                ppid: 1,
345                cmd: "b".into(),
346                user: "u".into(),
347                tgid: 2,
348                start_time_ns: 0,
349            },
350        );
351        store.insert_process(
352            3,
353            ProcessInfo {
354                ppid: 1,
355                cmd: "c".into(),
356                user: "u".into(),
357                tgid: 3,
358                start_time_ns: 0,
359            },
360        );
361        let mut pids = store.all_pids();
362        pids.sort();
363        assert_eq!(pids, vec![1, 2, 3]);
364    }
365
366    #[test]
367    fn ttl_contains_key_does_not_expire() {
368        let store = DefaultStore::new(1);
369        store.insert_process(
370            1,
371            ProcessInfo {
372                ppid: 0,
373                cmd: "a".into(),
374                user: "u".into(),
375                tgid: 1,
376                start_time_ns: 0,
377            },
378        );
379        assert!(store.contains_key(1));
380        std::thread::sleep(Duration::from_millis(1100));
381        // contains_key does not trigger TTL eviction
382        assert!(store.contains_key(1));
383        // but get_process does
384        assert!(store.get_process(1).is_none());
385    }
386
387    #[test]
388    fn remove_process() {
389        let store = DefaultStore::new(0);
390        store.insert_process(
391            1,
392            ProcessInfo {
393                ppid: 0,
394                cmd: "init".into(),
395                user: "root".into(),
396                tgid: 1,
397                start_time_ns: 0,
398            },
399        );
400        store.insert_process(
401            2,
402            ProcessInfo {
403                ppid: 1,
404                cmd: "bash".into(),
405                user: "root".into(),
406                tgid: 2,
407                start_time_ns: 0,
408            },
409        );
410
411        assert_eq!(store.len(), 2);
412        assert!(store.contains_key(2));
413
414        let removed = store.remove_process(2);
415        assert!(removed.is_some());
416        assert_eq!(store.len(), 1);
417        assert!(!store.contains_key(2));
418    }
419
420    #[test]
421    fn children_of() {
422        let store = DefaultStore::new(0);
423        store.insert_process(
424            1,
425            ProcessInfo {
426                ppid: 0,
427                cmd: "init".into(),
428                user: "root".into(),
429                tgid: 1,
430                start_time_ns: 0,
431            },
432        );
433        store.insert_process(
434            100,
435            ProcessInfo {
436                ppid: 1,
437                cmd: "a".into(),
438                user: "root".into(),
439                tgid: 100,
440                start_time_ns: 0,
441            },
442        );
443        store.insert_process(
444            200,
445            ProcessInfo {
446                ppid: 1,
447                cmd: "b".into(),
448                user: "root".into(),
449                tgid: 200,
450                start_time_ns: 0,
451            },
452        );
453        store.insert_process(
454            300,
455            ProcessInfo {
456                ppid: 100,
457                cmd: "c".into(),
458                user: "root".into(),
459                tgid: 300,
460                start_time_ns: 0,
461            },
462        );
463
464        let mut kids = store.children_of(1);
465        kids.sort();
466        assert_eq!(kids, vec![100, 200]);
467
468        let kids_100 = store.children_of(100);
469        assert_eq!(kids_100, vec![300]);
470
471        assert!(store.children_of(999).is_empty());
472    }
473
474    #[test]
475    fn insert_ppid_change_removes_from_old_parent() {
476        let store = DefaultStore::new(0);
477        store.insert_process(
478            1,
479            ProcessInfo {
480                ppid: 0,
481                cmd: "init".into(),
482                user: "root".into(),
483                tgid: 1,
484                start_time_ns: 0,
485            },
486        );
487        store.insert_process(
488            100,
489            ProcessInfo {
490                ppid: 0,
491                cmd: "other".into(),
492                user: "root".into(),
493                tgid: 100,
494                start_time_ns: 0,
495            },
496        );
497        store.insert_process(
498            200,
499            ProcessInfo {
500                ppid: 100,
501                cmd: "child".into(),
502                user: "root".into(),
503                tgid: 200,
504                start_time_ns: 0,
505            },
506        );
507
508        // child 200 is under parent 100
509        assert_eq!(store.children_of(100), vec![200]);
510        assert!(store.children_of(1).is_empty());
511
512        // Re-parent 200 from 100 to 1
513        store.insert_process(
514            200,
515            ProcessInfo {
516                ppid: 1,
517                cmd: "child".into(),
518                user: "root".into(),
519                tgid: 200,
520                start_time_ns: 0,
521            },
522        );
523
524        // 200 should be removed from old parent's index
525        assert!(
526            store.children_of(100).is_empty(),
527            "old parent should have no children"
528        );
529        // 200 should be in new parent's index
530        assert_eq!(store.children_of(1), vec![200]);
531    }
532
533    #[test]
534    fn insert_same_ppid_no_duplicate() {
535        let store = DefaultStore::new(0);
536        store.insert_process(
537            1,
538            ProcessInfo {
539                ppid: 0,
540                cmd: "init".into(),
541                user: "root".into(),
542                tgid: 1,
543                start_time_ns: 0,
544            },
545        );
546        store.insert_process(
547            100,
548            ProcessInfo {
549                ppid: 1,
550                cmd: "a".into(),
551                user: "root".into(),
552                tgid: 100,
553                start_time_ns: 0,
554            },
555        );
556        // Insert same pid with same ppid again
557        store.insert_process(
558            100,
559            ProcessInfo {
560                ppid: 1,
561                cmd: "a".into(),
562                user: "root".into(),
563                tgid: 100,
564                start_time_ns: 0,
565            },
566        );
567        // Should not duplicate
568        assert_eq!(store.children_of(1), vec![100]);
569    }
570
571    #[test]
572    fn remove_process_cleans_own_children_index() {
573        let store = DefaultStore::new(0);
574        store.insert_process(
575            1,
576            ProcessInfo {
577                ppid: 0,
578                cmd: "init".into(),
579                user: "root".into(),
580                tgid: 1,
581                start_time_ns: 0,
582            },
583        );
584        store.insert_process(
585            100,
586            ProcessInfo {
587                ppid: 1,
588                cmd: "parent".into(),
589                user: "root".into(),
590                tgid: 100,
591                start_time_ns: 0,
592            },
593        );
594        store.insert_process(
595            200,
596            ProcessInfo {
597                ppid: 100,
598                cmd: "child".into(),
599                user: "root".into(),
600                tgid: 200,
601                start_time_ns: 0,
602            },
603        );
604
605        assert_eq!(store.children_of(100), vec![200]);
606
607        // Remove parent 100
608        store.remove_process(100);
609
610        // children_of(100) should return empty, not stale [200]
611        assert!(
612            store.children_of(100).is_empty(),
613            "removed process should have no children index"
614        );
615        // Child 200 still exists in store but parent is gone
616        assert!(store.get_process(200).is_some());
617    }
618
619    #[test]
620    fn ttl_expiration_cleans_own_children_index() {
621        let store = DefaultStore::new(1); // 1 second TTL
622        store.insert_process(
623            1,
624            ProcessInfo {
625                ppid: 0,
626                cmd: "init".into(),
627                user: "root".into(),
628                tgid: 1,
629                start_time_ns: 0,
630            },
631        );
632        store.insert_process(
633            100,
634            ProcessInfo {
635                ppid: 1,
636                cmd: "parent".into(),
637                user: "root".into(),
638                tgid: 100,
639                start_time_ns: 0,
640            },
641        );
642        store.insert_process(
643            200,
644            ProcessInfo {
645                ppid: 100,
646                cmd: "child".into(),
647                user: "root".into(),
648                tgid: 200,
649                start_time_ns: 0,
650            },
651        );
652
653        assert_eq!(store.children_of(100), vec![200]);
654
655        // Wait for parent to expire
656        std::thread::sleep(Duration::from_millis(1100));
657
658        // Accessing expired parent triggers eviction
659        assert!(store.get_process(100).is_none());
660
661        // children_of(100) should return empty, not stale [200]
662        assert!(
663            store.children_of(100).is_empty(),
664            "expired process should have no children index"
665        );
666    }
667}