Skip to main content

victauri_plugin/
introspection.rs

1//! Backend introspection and chaos engineering types.
2//!
3//! These types support Victauri's intervention capabilities — features that exploit
4//! the plugin's position inside the Rust process to provide insights and control
5//! that browser-external tools like CDP cannot access.
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::RwLock;
9use std::sync::atomic::AtomicBool;
10use std::time::{Duration, Instant};
11
12use serde::Serialize;
13
14/// Per-command timing statistics aggregated from IPC invocations.
15#[derive(Debug, Clone, Serialize)]
16pub struct CommandTimingStats {
17    /// Command name.
18    pub command: String,
19    /// Number of invocations recorded.
20    pub count: u64,
21    /// Minimum execution time in milliseconds.
22    pub min_ms: f64,
23    /// Maximum execution time in milliseconds.
24    pub max_ms: f64,
25    /// Mean execution time in milliseconds.
26    pub avg_ms: f64,
27    /// 95th percentile execution time in milliseconds.
28    pub p95_ms: f64,
29    /// Total execution time across all invocations.
30    pub total_ms: f64,
31}
32
33/// Accumulated raw timing samples for a single command.
34#[derive(Debug, Default)]
35pub struct TimingSamples {
36    /// Duration of each invocation, in order.
37    pub samples: Vec<Duration>,
38}
39
40impl TimingSamples {
41    /// Add a timing sample.
42    pub fn record(&mut self, duration: Duration) {
43        self.samples.push(duration);
44    }
45
46    /// Compute aggregate statistics.
47    #[must_use]
48    pub fn stats(&self, command: &str) -> CommandTimingStats {
49        if self.samples.is_empty() {
50            return CommandTimingStats {
51                command: command.to_string(),
52                count: 0,
53                min_ms: 0.0,
54                max_ms: 0.0,
55                avg_ms: 0.0,
56                p95_ms: 0.0,
57                total_ms: 0.0,
58            };
59        }
60        let mut sorted: Vec<f64> = self
61            .samples
62            .iter()
63            .map(|d| d.as_secs_f64() * 1000.0)
64            .collect();
65        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
66
67        let count = sorted.len() as u64;
68        let total: f64 = sorted.iter().sum();
69        let min = sorted[0];
70        let max = sorted[sorted.len() - 1];
71        let avg = total / sorted.len() as f64;
72        let p95_idx = ((sorted.len() as f64) * 0.95).ceil() as usize;
73        let p95 = sorted[p95_idx.min(sorted.len() - 1)];
74
75        CommandTimingStats {
76            command: command.to_string(),
77            count,
78            min_ms: (min * 100.0).round() / 100.0,
79            max_ms: (max * 100.0).round() / 100.0,
80            avg_ms: (avg * 100.0).round() / 100.0,
81            p95_ms: (p95 * 100.0).round() / 100.0,
82            total_ms: (total * 100.0).round() / 100.0,
83        }
84    }
85}
86
87/// Thread-safe store for per-command timing data.
88pub struct CommandTimings {
89    inner: RwLock<HashMap<String, TimingSamples>>,
90}
91
92impl CommandTimings {
93    /// Create a new empty store.
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            inner: RwLock::new(HashMap::new()),
98        }
99    }
100
101    /// Record a timing sample for a command.
102    pub fn record(&self, command: &str, duration: Duration) {
103        let mut map = self
104            .inner
105            .write()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        map.entry(command.to_string()).or_default().record(duration);
108    }
109
110    /// Get stats for all commands, sorted by total time descending.
111    #[must_use]
112    pub fn all_stats(&self) -> Vec<CommandTimingStats> {
113        let map = self
114            .inner
115            .read()
116            .unwrap_or_else(std::sync::PoisonError::into_inner);
117        let mut stats: Vec<CommandTimingStats> =
118            map.iter().map(|(name, s)| s.stats(name)).collect();
119        stats.sort_by(|a, b| {
120            b.total_ms
121                .partial_cmp(&a.total_ms)
122                .unwrap_or(std::cmp::Ordering::Equal)
123        });
124        stats
125    }
126
127    /// Get stats for a single command.
128    #[must_use]
129    pub fn stats_for(&self, command: &str) -> Option<CommandTimingStats> {
130        let map = self
131            .inner
132            .read()
133            .unwrap_or_else(std::sync::PoisonError::into_inner);
134        map.get(command).map(|s| s.stats(command))
135    }
136
137    /// Clear all timing data.
138    pub fn clear(&self) {
139        let mut map = self
140            .inner
141            .write()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        map.clear();
144    }
145}
146
147impl Default for CommandTimings {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153// ── Fault Injection ─────────────────────────────────────────────────────────
154
155/// The type of fault to inject into a command.
156#[derive(Debug, Clone, Serialize)]
157pub enum FaultType {
158    /// Add artificial latency before command execution.
159    Delay {
160        /// Delay in milliseconds.
161        delay_ms: u64,
162    },
163    /// Return an error without executing the command.
164    Error {
165        /// Error message to return.
166        message: String,
167    },
168    /// Drop the response entirely (return empty/timeout-like response).
169    Drop,
170    /// Execute normally but corrupt the response (randomize field values).
171    Corrupt,
172}
173
174/// Configuration for a single fault injection rule.
175#[derive(Debug, Clone, Serialize)]
176pub struct FaultConfig {
177    /// Target command name.
178    pub command: String,
179    /// Type of fault to inject.
180    pub fault_type: FaultType,
181    /// Number of times this fault has been triggered.
182    pub trigger_count: u64,
183    /// Maximum number of times to trigger (0 = unlimited).
184    pub max_triggers: u64,
185    /// When this fault was created.
186    #[serde(skip)]
187    pub created_at: Instant,
188}
189
190/// Faults auto-expire this long after creation, so a forgotten fault cannot
191/// silently sabotage or mask a later test run (audit #34). Re-inject to refresh.
192pub const FAULT_TTL: Duration = Duration::from_secs(900); // 15 minutes
193
194impl FaultConfig {
195    /// Whether this fault should still trigger, evaluated at `now`. A fault is
196    /// inert once it is older than [`FAULT_TTL`] or has hit `max_triggers`.
197    #[must_use]
198    pub fn should_trigger_at(&self, now: Instant) -> bool {
199        if now.saturating_duration_since(self.created_at) >= FAULT_TTL {
200            return false;
201        }
202        self.max_triggers == 0 || self.trigger_count < self.max_triggers
203    }
204
205    /// Check if this fault should still trigger right now.
206    #[must_use]
207    pub fn should_trigger(&self) -> bool {
208        self.should_trigger_at(Instant::now())
209    }
210}
211
212/// Thread-safe registry of active fault injection rules.
213pub struct FaultRegistry {
214    inner: RwLock<HashMap<String, FaultConfig>>,
215}
216
217impl FaultRegistry {
218    /// Create an empty registry.
219    #[must_use]
220    pub fn new() -> Self {
221        Self {
222            inner: RwLock::new(HashMap::new()),
223        }
224    }
225
226    /// Register a fault for a command.
227    pub fn inject(&self, config: FaultConfig) {
228        let mut map = self
229            .inner
230            .write()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        map.insert(config.command.clone(), config);
233    }
234
235    /// Look up and optionally trigger a fault for a command.
236    /// Returns the fault type if one is active and should trigger.
237    pub fn check_and_trigger(&self, command: &str) -> Option<FaultType> {
238        let mut map = self
239            .inner
240            .write()
241            .unwrap_or_else(std::sync::PoisonError::into_inner);
242        if let Some(config) = map.get_mut(command)
243            && config.should_trigger()
244        {
245            config.trigger_count += 1;
246            return Some(config.fault_type.clone());
247        }
248        None
249    }
250
251    /// List all active fault rules.
252    #[must_use]
253    pub fn list(&self) -> Vec<FaultConfig> {
254        let map = self
255            .inner
256            .read()
257            .unwrap_or_else(std::sync::PoisonError::into_inner);
258        map.values().cloned().collect()
259    }
260
261    /// Remove a fault rule for a command.
262    pub fn clear(&self, command: &str) -> bool {
263        let mut map = self
264            .inner
265            .write()
266            .unwrap_or_else(std::sync::PoisonError::into_inner);
267        map.remove(command).is_some()
268    }
269
270    /// Remove all fault rules.
271    pub fn clear_all(&self) -> usize {
272        let mut map = self
273            .inner
274            .write()
275            .unwrap_or_else(std::sync::PoisonError::into_inner);
276        let count = map.len();
277        map.clear();
278        count
279    }
280}
281
282impl Default for FaultRegistry {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288// ── IPC Contract Testing ────────────────────────────────────────────────────
289
290/// Describes the shape of a JSON value for contract comparison.
291#[derive(Debug, Clone, Serialize, PartialEq)]
292pub enum JsonShape {
293    /// null
294    Null,
295    /// boolean
296    Bool,
297    /// number (integer or float)
298    Number,
299    /// string
300    String,
301    /// array with element shape (from first element, or Null if empty)
302    Array(Box<Self>),
303    /// object with field names and their shapes
304    Object(HashMap<String, Self>),
305}
306
307impl JsonShape {
308    /// Extract the shape of a JSON value.
309    #[must_use]
310    pub fn from_value(value: &serde_json::Value) -> Self {
311        match value {
312            serde_json::Value::Null => Self::Null,
313            serde_json::Value::Bool(_) => Self::Bool,
314            serde_json::Value::Number(_) => Self::Number,
315            serde_json::Value::String(_) => Self::String,
316            serde_json::Value::Array(arr) => {
317                let elem = arr.first().map_or(Self::Null, Self::from_value);
318                Self::Array(Box::new(elem))
319            }
320            serde_json::Value::Object(obj) => {
321                let fields: HashMap<String, Self> = obj
322                    .iter()
323                    .map(|(k, v)| (k.clone(), Self::from_value(v)))
324                    .collect();
325                Self::Object(fields)
326            }
327        }
328    }
329
330    /// Human-readable type name.
331    #[must_use]
332    pub fn type_name(&self) -> &'static str {
333        match self {
334            Self::Null => "null",
335            Self::Bool => "bool",
336            Self::Number => "number",
337            Self::String => "string",
338            Self::Array(_) => "array",
339            Self::Object(_) => "object",
340        }
341    }
342}
343
344/// A recorded contract baseline for a command's response.
345#[derive(Debug, Clone, Serialize)]
346pub struct ContractBaseline {
347    /// Command name.
348    pub command: String,
349    /// Arguments used when recording.
350    pub args: serde_json::Value,
351    /// Shape of the response.
352    pub shape: JsonShape,
353    /// Raw sample response (first 4KB).
354    pub sample: String,
355    /// When this baseline was recorded.
356    pub recorded_at: String,
357}
358
359/// Differences found when checking a contract against baseline.
360#[derive(Debug, Clone, Serialize)]
361pub struct ContractDrift {
362    /// Command name.
363    pub command: String,
364    /// Fields present in current but not in baseline.
365    pub new_fields: Vec<String>,
366    /// Fields present in baseline but not in current.
367    pub removed_fields: Vec<String>,
368    /// Fields whose type changed.
369    pub type_changes: Vec<TypeChange>,
370    /// Whether the overall shape matches.
371    pub shape_matches: bool,
372}
373
374/// A single field type change between baseline and current.
375#[derive(Debug, Clone, Serialize)]
376pub struct TypeChange {
377    /// Dot-separated field path.
378    pub path: String,
379    /// Type in the baseline.
380    pub baseline_type: String,
381    /// Type in the current response.
382    pub current_type: String,
383}
384
385/// Compare two JSON shapes and report differences.
386#[must_use]
387pub fn diff_shapes(baseline: &JsonShape, current: &JsonShape, prefix: &str) -> ContractDrift {
388    let mut new_fields = Vec::new();
389    let mut removed_fields = Vec::new();
390    let mut type_changes = Vec::new();
391
392    diff_shapes_inner(
393        baseline,
394        current,
395        prefix,
396        &mut new_fields,
397        &mut removed_fields,
398        &mut type_changes,
399    );
400
401    let shape_matches =
402        new_fields.is_empty() && removed_fields.is_empty() && type_changes.is_empty();
403    ContractDrift {
404        command: prefix.to_string(),
405        new_fields,
406        removed_fields,
407        type_changes,
408        shape_matches,
409    }
410}
411
412fn diff_shapes_inner(
413    baseline: &JsonShape,
414    current: &JsonShape,
415    prefix: &str,
416    new_fields: &mut Vec<String>,
417    removed_fields: &mut Vec<String>,
418    type_changes: &mut Vec<TypeChange>,
419) {
420    match (baseline, current) {
421        (JsonShape::Object(b_fields), JsonShape::Object(c_fields)) => {
422            for (key, b_shape) in b_fields {
423                let path = if prefix.is_empty() {
424                    key.clone()
425                } else {
426                    format!("{prefix}.{key}")
427                };
428                if let Some(c_shape) = c_fields.get(key) {
429                    diff_shapes_inner(
430                        b_shape,
431                        c_shape,
432                        &path,
433                        new_fields,
434                        removed_fields,
435                        type_changes,
436                    );
437                } else {
438                    removed_fields.push(path);
439                }
440            }
441            for key in c_fields.keys() {
442                if !b_fields.contains_key(key) {
443                    let path = if prefix.is_empty() {
444                        key.clone()
445                    } else {
446                        format!("{prefix}.{key}")
447                    };
448                    new_fields.push(path);
449                }
450            }
451        }
452        (JsonShape::Array(b_elem), JsonShape::Array(c_elem)) => {
453            let path = format!("{prefix}[]");
454            diff_shapes_inner(
455                b_elem,
456                c_elem,
457                &path,
458                new_fields,
459                removed_fields,
460                type_changes,
461            );
462        }
463        (b, c) if b.type_name() != c.type_name() => {
464            type_changes.push(TypeChange {
465                path: prefix.to_string(),
466                baseline_type: b.type_name().to_string(),
467                current_type: c.type_name().to_string(),
468            });
469        }
470        _ => {}
471    }
472}
473
474/// Thread-safe store for IPC contract baselines.
475pub struct ContractStore {
476    inner: RwLock<HashMap<String, ContractBaseline>>,
477}
478
479impl ContractStore {
480    /// Create an empty store.
481    #[must_use]
482    pub fn new() -> Self {
483        Self {
484            inner: RwLock::new(HashMap::new()),
485        }
486    }
487
488    /// Record a baseline for a command.
489    pub fn record(&self, baseline: ContractBaseline) {
490        let mut map = self
491            .inner
492            .write()
493            .unwrap_or_else(std::sync::PoisonError::into_inner);
494        map.insert(baseline.command.clone(), baseline);
495    }
496
497    /// Get the baseline for a command.
498    #[must_use]
499    pub fn get(&self, command: &str) -> Option<ContractBaseline> {
500        let map = self
501            .inner
502            .read()
503            .unwrap_or_else(std::sync::PoisonError::into_inner);
504        map.get(command).cloned()
505    }
506
507    /// Get all baselines.
508    #[must_use]
509    pub fn all(&self) -> Vec<ContractBaseline> {
510        let map = self
511            .inner
512            .read()
513            .unwrap_or_else(std::sync::PoisonError::into_inner);
514        map.values().cloned().collect()
515    }
516
517    /// Clear all baselines.
518    pub fn clear(&self) -> usize {
519        let mut map = self
520            .inner
521            .write()
522            .unwrap_or_else(std::sync::PoisonError::into_inner);
523        let count = map.len();
524        map.clear();
525        count
526    }
527}
528
529impl Default for ContractStore {
530    fn default() -> Self {
531        Self::new()
532    }
533}
534
535// ── Startup Profiling ───────────────────────────────────────────────────────
536
537/// A single phase in the startup timeline.
538#[derive(Debug, Clone, Serialize)]
539pub struct StartupPhase {
540    /// Phase name.
541    pub name: String,
542    /// Duration of this phase in milliseconds.
543    pub duration_ms: f64,
544    /// Cumulative time from plugin init start.
545    pub cumulative_ms: f64,
546}
547
548/// Records timestamps at key phases during plugin initialization.
549pub struct StartupTimeline {
550    start: Instant,
551    phases: RwLock<Vec<(String, Instant)>>,
552}
553
554impl StartupTimeline {
555    /// Begin recording from now.
556    #[must_use]
557    pub fn new() -> Self {
558        Self {
559            start: Instant::now(),
560            phases: RwLock::new(Vec::new()),
561        }
562    }
563
564    /// Mark a phase as completed.
565    pub fn mark(&self, name: &str) {
566        let mut phases = self
567            .phases
568            .write()
569            .unwrap_or_else(std::sync::PoisonError::into_inner);
570        phases.push((name.to_string(), Instant::now()));
571    }
572
573    /// Get the timeline as a list of phases with durations.
574    #[must_use]
575    pub fn report(&self) -> Vec<StartupPhase> {
576        let phases = self
577            .phases
578            .read()
579            .unwrap_or_else(std::sync::PoisonError::into_inner);
580        let mut result = Vec::new();
581        let mut prev = self.start;
582
583        for (name, instant) in phases.iter() {
584            let duration = instant.duration_since(prev);
585            let cumulative = instant.duration_since(self.start);
586            result.push(StartupPhase {
587                name: name.clone(),
588                duration_ms: (duration.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
589                cumulative_ms: (cumulative.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
590            });
591            prev = *instant;
592        }
593        result
594    }
595
596    /// Total time from start to last recorded phase.
597    #[must_use]
598    pub fn total_ms(&self) -> f64 {
599        let phases = self
600            .phases
601            .read()
602            .unwrap_or_else(std::sync::PoisonError::into_inner);
603        if let Some((_, last)) = phases.last() {
604            (last.duration_since(self.start).as_secs_f64() * 1000.0 * 100.0).round() / 100.0
605        } else {
606            0.0
607        }
608    }
609}
610
611impl Default for StartupTimeline {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617// ── Tauri Event Bus Monitor ─────────────────────────────────────────────
618
619/// A Tauri event captured from the application's native event bus.
620#[derive(Debug, Clone, Serialize)]
621pub struct CapturedTauriEvent {
622    /// Event name (e.g. "notification-added", `tauri://focus`).
623    pub name: String,
624    /// Serialized event payload.
625    pub payload: String,
626    /// ISO 8601 timestamp.
627    pub timestamp: String,
628}
629
630const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000;
631
632/// Thread-safe ring buffer for captured Tauri events.
633#[derive(Clone)]
634pub struct EventBusMonitor {
635    inner: std::sync::Arc<RwLock<VecDeque<CapturedTauriEvent>>>,
636    capacity: usize,
637}
638
639impl EventBusMonitor {
640    /// Create a new monitor with the given capacity.
641    #[must_use]
642    pub fn new(capacity: usize) -> Self {
643        Self {
644            inner: std::sync::Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
645            capacity,
646        }
647    }
648
649    /// Record a captured event.
650    pub fn push(&self, event: CapturedTauriEvent) {
651        let mut buf = self
652            .inner
653            .write()
654            .unwrap_or_else(std::sync::PoisonError::into_inner);
655        if buf.len() >= self.capacity {
656            buf.pop_front();
657        }
658        buf.push_back(event);
659    }
660
661    /// Get all captured events.
662    #[must_use]
663    pub fn events(&self) -> Vec<CapturedTauriEvent> {
664        self.inner
665            .read()
666            .unwrap_or_else(std::sync::PoisonError::into_inner)
667            .iter()
668            .cloned()
669            .collect()
670    }
671
672    /// Get the number of captured events.
673    #[must_use]
674    pub fn len(&self) -> usize {
675        self.inner
676            .read()
677            .unwrap_or_else(std::sync::PoisonError::into_inner)
678            .len()
679    }
680
681    /// Returns true if no events have been captured.
682    #[must_use]
683    pub fn is_empty(&self) -> bool {
684        self.len() == 0
685    }
686
687    /// Clear all captured events, returning how many were removed.
688    pub fn clear(&self) -> usize {
689        let mut buf = self
690            .inner
691            .write()
692            .unwrap_or_else(std::sync::PoisonError::into_inner);
693        let count = buf.len();
694        buf.clear();
695        count
696    }
697}
698
699impl Default for EventBusMonitor {
700    fn default() -> Self {
701        Self::new(DEFAULT_EVENT_BUS_CAPACITY)
702    }
703}
704
705// ── Internal Task Tracker ──────────────────────────────────────────────
706
707/// Info about a tracked async task spawned by Victauri.
708#[derive(Debug, Clone, Serialize)]
709pub struct TrackedTaskInfo {
710    /// Human-readable task name.
711    pub name: String,
712    /// ISO 8601 timestamp when the task was spawned.
713    pub spawned_at: String,
714    /// Whether the task has finished (completed or errored).
715    pub is_finished: bool,
716    /// How long the task has been running in seconds.
717    pub uptime_secs: u64,
718}
719
720struct TrackedTaskEntry {
721    name: String,
722    spawned_at: Instant,
723    spawned_at_wall: String,
724    finished: std::sync::Arc<AtomicBool>,
725}
726
727/// Tracks Victauri's own spawned async tasks for observability.
728pub struct TaskTracker {
729    tasks: RwLock<Vec<TrackedTaskEntry>>,
730}
731
732impl TaskTracker {
733    /// Create a new empty tracker.
734    #[must_use]
735    pub fn new() -> Self {
736        Self {
737            tasks: RwLock::new(Vec::new()),
738        }
739    }
740
741    /// Register a new task. Returns a flag that the task should set to `true` when it finishes.
742    pub fn track(&self, name: &str) -> std::sync::Arc<AtomicBool> {
743        let finished = std::sync::Arc::new(AtomicBool::new(false));
744        let entry = TrackedTaskEntry {
745            name: name.to_string(),
746            spawned_at: Instant::now(),
747            spawned_at_wall: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
748            finished: finished.clone(),
749        };
750        self.tasks
751            .write()
752            .unwrap_or_else(std::sync::PoisonError::into_inner)
753            .push(entry);
754        finished
755    }
756
757    /// List all tracked tasks with their current status.
758    #[must_use]
759    pub fn list(&self) -> Vec<TrackedTaskInfo> {
760        let tasks = self
761            .tasks
762            .read()
763            .unwrap_or_else(std::sync::PoisonError::into_inner);
764        tasks
765            .iter()
766            .map(|t| TrackedTaskInfo {
767                name: t.name.clone(),
768                spawned_at: t.spawned_at_wall.clone(),
769                is_finished: t.finished.load(std::sync::atomic::Ordering::Relaxed),
770                uptime_secs: t.spawned_at.elapsed().as_secs(),
771            })
772            .collect()
773    }
774
775    /// Count of active (non-finished) tasks.
776    #[must_use]
777    pub fn active_count(&self) -> usize {
778        let tasks = self
779            .tasks
780            .read()
781            .unwrap_or_else(std::sync::PoisonError::into_inner);
782        tasks
783            .iter()
784            .filter(|t| !t.finished.load(std::sync::atomic::Ordering::Relaxed))
785            .count()
786    }
787}
788
789impl Default for TaskTracker {
790    fn default() -> Self {
791        Self::new()
792    }
793}
794
795// ── Child Process Enumeration ──────────────────────────────────────────
796
797/// Information about a child process of the Tauri application.
798#[derive(Debug, Clone, Serialize)]
799pub struct ChildProcessInfo {
800    /// Process ID.
801    pub pid: u32,
802    /// Parent process ID.
803    pub ppid: u32,
804    /// Executable name (not full path).
805    pub name: String,
806    /// Memory usage in bytes (working set / RSS), if available.
807    pub memory_bytes: Option<u64>,
808}
809
810/// Enumerate child processes of the current process.
811///
812/// Uses platform-native APIs:
813/// - Windows: `CreateToolhelp32Snapshot` + `Process32First/Next`
814/// - Linux: `/proc/` filesystem
815/// - macOS: `proc_listpids` + `proc_pidinfo`
816#[must_use]
817pub fn enumerate_child_processes() -> Vec<ChildProcessInfo> {
818    let my_pid = std::process::id();
819
820    #[cfg(windows)]
821    {
822        enumerate_children_windows(my_pid)
823    }
824
825    #[cfg(target_os = "linux")]
826    {
827        enumerate_children_linux(my_pid)
828    }
829
830    #[cfg(target_os = "macos")]
831    {
832        enumerate_children_macos(my_pid)
833    }
834
835    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
836    {
837        let _ = my_pid;
838        Vec::new()
839    }
840}
841
842#[cfg(windows)]
843#[allow(unsafe_code)]
844fn enumerate_children_windows(parent_pid: u32) -> Vec<ChildProcessInfo> {
845    use windows::Win32::Foundation::CloseHandle;
846    use windows::Win32::System::Diagnostics::ToolHelp::{
847        CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
848    };
849
850    let mut children = Vec::new();
851
852    // SAFETY: `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` creates a
853    // read-only snapshot of all running processes. The returned handle is
854    // closed via `CloseHandle` when we're done. `Process32First/Next` iterate
855    // the snapshot entries.
856    unsafe {
857        let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
858            return children;
859        };
860
861        let mut entry: PROCESSENTRY32 = std::mem::zeroed();
862        entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
863
864        if Process32First(snapshot, &mut entry).is_ok() {
865            loop {
866                if entry.th32ParentProcessID == parent_pid && entry.th32ProcessID != parent_pid {
867                    let name_bytes: Vec<u8> = entry
868                        .szExeFile
869                        .iter()
870                        .take_while(|&&b| b != 0)
871                        .map(|&b| b as u8)
872                        .collect();
873                    let name = String::from_utf8_lossy(&name_bytes).to_string();
874
875                    let memory_bytes = get_process_memory_windows(entry.th32ProcessID);
876
877                    children.push(ChildProcessInfo {
878                        pid: entry.th32ProcessID,
879                        ppid: entry.th32ParentProcessID,
880                        name,
881                        memory_bytes,
882                    });
883                }
884
885                if Process32Next(snapshot, &mut entry).is_err() {
886                    break;
887                }
888            }
889        }
890
891        let _ = CloseHandle(snapshot);
892    }
893
894    children
895}
896
897#[cfg(windows)]
898#[allow(unsafe_code)]
899fn get_process_memory_windows(pid: u32) -> Option<u64> {
900    use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
901    use windows::Win32::System::Threading::{
902        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
903    };
904
905    // SAFETY: `OpenProcess` with `PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ`
906    // opens a limited handle for reading memory stats. The process handle is closed
907    // automatically when dropped (windows crate handles this).
908    unsafe {
909        let process = OpenProcess(
910            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
911            false,
912            pid,
913        )
914        .ok()?;
915
916        let mut counters: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
917        counters.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
918
919        if GetProcessMemoryInfo(process, &mut counters, counters.cb).is_ok() {
920            Some(counters.WorkingSetSize as u64)
921        } else {
922            None
923        }
924    }
925}
926
927#[cfg(target_os = "linux")]
928fn enumerate_children_linux(parent_pid: u32) -> Vec<ChildProcessInfo> {
929    let mut children = Vec::new();
930    let Ok(entries) = std::fs::read_dir("/proc") else {
931        return children;
932    };
933
934    for entry in entries.flatten() {
935        let file_name = entry.file_name();
936        let Some(pid_str) = file_name.to_str() else {
937            continue;
938        };
939        let Ok(pid) = pid_str.parse::<u32>() else {
940            continue;
941        };
942
943        let status_path = format!("/proc/{pid}/status");
944        let Ok(status) = std::fs::read_to_string(&status_path) else {
945            continue;
946        };
947
948        let mut ppid: Option<u32> = None;
949        let mut name = String::new();
950        let mut vm_rss_kb: u64 = 0;
951
952        for line in status.lines() {
953            if let Some(v) = line.strip_prefix("PPid:\t") {
954                ppid = v.trim().parse().ok();
955            } else if let Some(v) = line.strip_prefix("Name:\t") {
956                name = v.trim().to_string();
957            } else if let Some(v) = line.strip_prefix("VmRSS:") {
958                vm_rss_kb = v
959                    .split_whitespace()
960                    .next()
961                    .and_then(|n| n.parse().ok())
962                    .unwrap_or(0);
963            }
964        }
965
966        if ppid == Some(parent_pid) {
967            children.push(ChildProcessInfo {
968                pid,
969                ppid: parent_pid,
970                name,
971                memory_bytes: if vm_rss_kb > 0 {
972                    Some(vm_rss_kb * 1024)
973                } else {
974                    None
975                },
976            });
977        }
978    }
979
980    children
981}
982
983#[cfg(target_os = "macos")]
984#[allow(unsafe_code)]
985fn enumerate_children_macos(parent_pid: u32) -> Vec<ChildProcessInfo> {
986    use std::mem;
987
988    unsafe extern "C" {
989        fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32;
990        fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut u8, buffersize: i32) -> i32;
991        fn proc_name(pid: i32, buffer: *mut u8, buffersize: u32) -> i32;
992    }
993
994    const PROC_PIDTASKINFO: i32 = 4;
995
996    #[repr(C)]
997    struct ProcTaskInfo {
998        pti_virtual_size: u64,
999        pti_resident_size: u64,
1000        pti_total_user: u64,
1001        pti_total_system: u64,
1002        pti_threads_user: u64,
1003        pti_threads_system: u64,
1004        pti_policy: i32,
1005        pti_faults: i32,
1006        pti_pageins: i32,
1007        pti_cow_faults: i32,
1008        pti_messages_sent: i32,
1009        pti_messages_received: i32,
1010        pti_syscalls_mach: i32,
1011        pti_syscalls_unix: i32,
1012        pti_csw: i32,
1013        pti_threadnum: i32,
1014        pti_numrunning: i32,
1015        pti_priority: i32,
1016    }
1017
1018    let mut children = Vec::new();
1019
1020    // SAFETY: `proc_listchildpids` populates a buffer of child PIDs for the given
1021    // parent PID. We first call with a zero buffer to get the count, then allocate
1022    // and call again. `proc_name` and `proc_pidinfo` read metadata for a given PID.
1023    unsafe {
1024        let ppid = parent_pid as i32;
1025        // `proc_listchildpids` returns the COUNT of child PIDs written, not bytes —
1026        // verified empirically on macOS 26 / arm64 (1 child → returns 1, pids[0] is
1027        // the child). The old code divided the return by `size_of::<i32>()`, so a
1028        // single child (1/4 = 0) always enumerated as zero. Allocate a generous
1029        // buffer and call directly; if the returned count meets our capacity the list
1030        // may be truncated, so grow and retry.
1031        let mut cap = 256usize;
1032        let (pids, n) = loop {
1033            let mut pids = vec![0i32; cap];
1034            let buf_size = (cap * mem::size_of::<i32>()) as i32;
1035            let actual = proc_listchildpids(ppid, pids.as_mut_ptr(), buf_size);
1036            if actual <= 0 {
1037                return children;
1038            }
1039            let count = actual as usize;
1040            // `count` is clamped to `cap` for the slice below (stays in bounds even if
1041            // the syscall ever reports a total larger than the buffer it filled).
1042            if count < cap || cap >= 65536 {
1043                break (pids, count.min(cap));
1044            }
1045            cap = (count + 16).max(cap * 2);
1046        };
1047        for &pid in &pids[..n] {
1048            if pid <= 0 {
1049                continue;
1050            }
1051
1052            let mut name_buf = [0u8; 256];
1053            let name_len = proc_name(pid, name_buf.as_mut_ptr(), 256);
1054            let name = if name_len > 0 {
1055                String::from_utf8_lossy(&name_buf[..name_len as usize]).to_string()
1056            } else {
1057                String::from("<unknown>")
1058            };
1059
1060            let mut task_info: ProcTaskInfo = mem::zeroed();
1061            let info_size = mem::size_of::<ProcTaskInfo>() as i32;
1062            let ret = proc_pidinfo(
1063                pid,
1064                PROC_PIDTASKINFO,
1065                0,
1066                &mut task_info as *mut _ as *mut u8,
1067                info_size,
1068            );
1069
1070            let memory_bytes = if ret == info_size {
1071                Some(task_info.pti_resident_size)
1072            } else {
1073                None
1074            };
1075
1076            children.push(ChildProcessInfo {
1077                pid: pid as u32,
1078                ppid: parent_pid,
1079                name,
1080                memory_bytes,
1081            });
1082        }
1083    }
1084
1085    children
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091
1092    #[test]
1093    fn event_bus_push_and_read() {
1094        let bus = EventBusMonitor::new(3);
1095        assert!(bus.is_empty());
1096        bus.push(CapturedTauriEvent {
1097            name: "test".to_string(),
1098            payload: "{}".to_string(),
1099            timestamp: "2026-01-01T00:00:00Z".to_string(),
1100        });
1101        assert_eq!(bus.len(), 1);
1102        assert_eq!(bus.events()[0].name, "test");
1103    }
1104
1105    #[test]
1106    fn event_bus_ring_buffer_eviction() {
1107        let bus = EventBusMonitor::new(2);
1108        for i in 0..5 {
1109            bus.push(CapturedTauriEvent {
1110                name: format!("event_{i}"),
1111                payload: String::new(),
1112                timestamp: String::new(),
1113            });
1114        }
1115        assert_eq!(bus.len(), 2);
1116        assert_eq!(bus.events()[0].name, "event_3");
1117        assert_eq!(bus.events()[1].name, "event_4");
1118    }
1119
1120    #[test]
1121    fn event_bus_clear() {
1122        let bus = EventBusMonitor::new(10);
1123        bus.push(CapturedTauriEvent {
1124            name: "a".to_string(),
1125            payload: String::new(),
1126            timestamp: String::new(),
1127        });
1128        assert_eq!(bus.clear(), 1);
1129        assert!(bus.is_empty());
1130    }
1131
1132    #[test]
1133    fn task_tracker_lifecycle() {
1134        let tracker = TaskTracker::new();
1135        let flag = tracker.track("mcp_server");
1136        let tasks = tracker.list();
1137        assert_eq!(tasks.len(), 1);
1138        assert_eq!(tasks[0].name, "mcp_server");
1139        assert!(!tasks[0].is_finished);
1140        assert_eq!(tracker.active_count(), 1);
1141
1142        flag.store(true, std::sync::atomic::Ordering::Relaxed);
1143        let tasks = tracker.list();
1144        assert!(tasks[0].is_finished);
1145        assert_eq!(tracker.active_count(), 0);
1146    }
1147
1148    #[test]
1149    fn timing_samples_basic() {
1150        let mut samples = TimingSamples::default();
1151        samples.record(Duration::from_millis(10));
1152        samples.record(Duration::from_millis(20));
1153        samples.record(Duration::from_millis(30));
1154        let stats = samples.stats("test_cmd");
1155        assert_eq!(stats.count, 3);
1156        assert!((stats.min_ms - 10.0).abs() < 1.0);
1157        assert!((stats.max_ms - 30.0).abs() < 1.0);
1158        assert!((stats.avg_ms - 20.0).abs() < 1.0);
1159    }
1160
1161    #[test]
1162    fn timing_samples_empty() {
1163        let samples = TimingSamples::default();
1164        let stats = samples.stats("empty");
1165        assert_eq!(stats.count, 0);
1166        assert_eq!(stats.min_ms, 0.0);
1167    }
1168
1169    #[test]
1170    fn command_timings_thread_safe() {
1171        let timings = CommandTimings::new();
1172        timings.record("cmd_a", Duration::from_millis(5));
1173        timings.record("cmd_a", Duration::from_millis(15));
1174        timings.record("cmd_b", Duration::from_millis(100));
1175
1176        let all = timings.all_stats();
1177        assert_eq!(all.len(), 2);
1178        assert_eq!(all[0].command, "cmd_b");
1179
1180        let a = timings.stats_for("cmd_a").unwrap();
1181        assert_eq!(a.count, 2);
1182    }
1183
1184    #[test]
1185    fn fault_registry_lifecycle() {
1186        let registry = FaultRegistry::new();
1187        registry.inject(FaultConfig {
1188            command: "slow_cmd".to_string(),
1189            fault_type: FaultType::Delay { delay_ms: 500 },
1190            trigger_count: 0,
1191            max_triggers: 2,
1192            created_at: Instant::now(),
1193        });
1194
1195        assert!(registry.check_and_trigger("slow_cmd").is_some());
1196        assert!(registry.check_and_trigger("slow_cmd").is_some());
1197        assert!(registry.check_and_trigger("slow_cmd").is_none());
1198
1199        assert_eq!(registry.list().len(), 1);
1200        assert!(registry.clear("slow_cmd"));
1201        assert_eq!(registry.list().len(), 0);
1202    }
1203
1204    #[test]
1205    fn fault_registry_unlimited() {
1206        let registry = FaultRegistry::new();
1207        registry.inject(FaultConfig {
1208            command: "always_fail".to_string(),
1209            fault_type: FaultType::Error {
1210                message: "injected".to_string(),
1211            },
1212            trigger_count: 0,
1213            max_triggers: 0,
1214            created_at: Instant::now(),
1215        });
1216
1217        for _ in 0..100 {
1218            assert!(registry.check_and_trigger("always_fail").is_some());
1219        }
1220    }
1221
1222    #[test]
1223    fn fault_expires_after_ttl() {
1224        let cfg = FaultConfig {
1225            command: "x".to_string(),
1226            fault_type: FaultType::Error {
1227                message: "e".to_string(),
1228            },
1229            trigger_count: 0,
1230            max_triggers: 0, // unlimited by count...
1231            created_at: Instant::now(),
1232        };
1233        // ...but still inert once older than the TTL (audit #34).
1234        assert!(cfg.should_trigger_at(cfg.created_at));
1235        assert!(!cfg.should_trigger_at(cfg.created_at + FAULT_TTL + Duration::from_secs(1)));
1236    }
1237
1238    #[test]
1239    fn json_shape_extraction() {
1240        let value = serde_json::json!({
1241            "name": "test",
1242            "count": 42,
1243            "active": true,
1244            "items": [{"id": 1}],
1245            "meta": null
1246        });
1247        let shape = JsonShape::from_value(&value);
1248        match &shape {
1249            JsonShape::Object(fields) => {
1250                assert_eq!(fields.len(), 5);
1251                assert_eq!(*fields.get("name").unwrap(), JsonShape::String);
1252                assert_eq!(*fields.get("count").unwrap(), JsonShape::Number);
1253                assert_eq!(*fields.get("active").unwrap(), JsonShape::Bool);
1254                assert_eq!(*fields.get("meta").unwrap(), JsonShape::Null);
1255            }
1256            _ => panic!("expected object"),
1257        }
1258    }
1259
1260    #[test]
1261    fn contract_diff_detects_changes() {
1262        let baseline = serde_json::json!({"name": "old", "count": 1});
1263        let current = serde_json::json!({"name": "new", "count": "not_a_number", "extra": true});
1264
1265        let b_shape = JsonShape::from_value(&baseline);
1266        let c_shape = JsonShape::from_value(&current);
1267        let drift = diff_shapes(&b_shape, &c_shape, "test_cmd");
1268
1269        assert!(!drift.shape_matches);
1270        assert_eq!(drift.new_fields, vec!["test_cmd.extra"]);
1271        assert_eq!(drift.type_changes.len(), 1);
1272        assert_eq!(drift.type_changes[0].path, "test_cmd.count");
1273    }
1274
1275    #[test]
1276    fn contract_store_crud() {
1277        let store = ContractStore::new();
1278        let baseline = ContractBaseline {
1279            command: "get_user".to_string(),
1280            args: serde_json::json!({}),
1281            shape: JsonShape::Object(HashMap::new()),
1282            sample: "{}".to_string(),
1283            recorded_at: "2026-05-26".to_string(),
1284        };
1285        store.record(baseline);
1286        assert!(store.get("get_user").is_some());
1287        assert_eq!(store.all().len(), 1);
1288        assert_eq!(store.clear(), 1);
1289        assert!(store.get("get_user").is_none());
1290    }
1291
1292    #[test]
1293    fn startup_timeline_records_phases() {
1294        let timeline = StartupTimeline::new();
1295        std::thread::sleep(Duration::from_millis(5));
1296        timeline.mark("phase_1");
1297        std::thread::sleep(Duration::from_millis(5));
1298        timeline.mark("phase_2");
1299
1300        let report = timeline.report();
1301        assert_eq!(report.len(), 2);
1302        assert_eq!(report[0].name, "phase_1");
1303        assert!(report[1].cumulative_ms >= report[0].cumulative_ms);
1304        assert!(timeline.total_ms() > 0.0);
1305    }
1306
1307    #[test]
1308    fn enumerate_child_processes_returns_vec() {
1309        let children = enumerate_child_processes();
1310        // The test process itself may or may not have children, but the
1311        // function must not panic and must return a well-formed Vec.
1312        for child in &children {
1313            assert_ne!(child.pid, 0, "child PID should be non-zero");
1314            assert_eq!(
1315                child.ppid,
1316                std::process::id(),
1317                "parent PID should match current process"
1318            );
1319            assert!(!child.name.is_empty(), "child name should not be empty");
1320        }
1321    }
1322
1323    #[test]
1324    fn enumerate_child_processes_with_spawned_child() {
1325        // Spawn a short-lived child process and verify we can enumerate it.
1326        let child = std::process::Command::new(if cfg!(windows) { "cmd.exe" } else { "sleep" })
1327            .args(if cfg!(windows) {
1328                &["/c", "timeout /t 10 /nobreak >nul"][..]
1329            } else {
1330                &["10"][..]
1331            })
1332            .spawn();
1333
1334        if let Ok(mut child_proc) = child {
1335            let children = enumerate_child_processes();
1336            assert!(
1337                !children.is_empty(),
1338                "should find at least one child process"
1339            );
1340
1341            let found = children.iter().any(|c| c.pid == child_proc.id());
1342            assert!(
1343                found,
1344                "spawned child (PID {}) should appear in enumeration",
1345                child_proc.id()
1346            );
1347
1348            let _ = child_proc.kill();
1349            let _ = child_proc.wait();
1350        }
1351    }
1352
1353    #[test]
1354    fn child_process_info_serializes() {
1355        let info = ChildProcessInfo {
1356            pid: 1234,
1357            ppid: 5678,
1358            name: "test-sidecar".to_string(),
1359            memory_bytes: Some(1_048_576),
1360        };
1361        let json = serde_json::to_value(&info).unwrap();
1362        assert_eq!(json["pid"], 1234);
1363        assert_eq!(json["ppid"], 5678);
1364        assert_eq!(json["name"], "test-sidecar");
1365        assert_eq!(json["memory_bytes"], 1_048_576);
1366    }
1367
1368    #[test]
1369    fn child_process_info_serializes_no_memory() {
1370        let info = ChildProcessInfo {
1371            pid: 42,
1372            ppid: 1,
1373            name: "zombie".to_string(),
1374            memory_bytes: None,
1375        };
1376        let json = serde_json::to_value(&info).unwrap();
1377        assert!(json["memory_bytes"].is_null());
1378    }
1379}