1use std::collections::{HashMap, VecDeque};
8use std::sync::RwLock;
9use std::sync::atomic::AtomicBool;
10use std::time::{Duration, Instant};
11
12use serde::Serialize;
13
14#[derive(Debug, Clone, Serialize)]
16pub struct CommandTimingStats {
17 pub command: String,
19 pub count: u64,
21 pub min_ms: f64,
23 pub max_ms: f64,
25 pub avg_ms: f64,
27 pub p95_ms: f64,
29 pub total_ms: f64,
31}
32
33#[derive(Debug, Default)]
35pub struct TimingSamples {
36 pub samples: Vec<Duration>,
38}
39
40impl TimingSamples {
41 pub fn record(&mut self, duration: Duration) {
43 self.samples.push(duration);
44 }
45
46 #[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
87pub struct CommandTimings {
89 inner: RwLock<HashMap<String, TimingSamples>>,
90}
91
92impl CommandTimings {
93 #[must_use]
95 pub fn new() -> Self {
96 Self {
97 inner: RwLock::new(HashMap::new()),
98 }
99 }
100
101 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 #[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 #[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 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#[derive(Debug, Clone, Serialize)]
157pub enum FaultType {
158 Delay {
160 delay_ms: u64,
162 },
163 Error {
165 message: String,
167 },
168 Drop,
170 Corrupt,
172}
173
174#[derive(Debug, Clone, Serialize)]
176pub struct FaultConfig {
177 pub command: String,
179 pub fault_type: FaultType,
181 pub trigger_count: u64,
183 pub max_triggers: u64,
185 #[serde(skip)]
187 pub created_at: Instant,
188}
189
190pub const FAULT_TTL: Duration = Duration::from_secs(900); impl FaultConfig {
195 #[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 #[must_use]
207 pub fn should_trigger(&self) -> bool {
208 self.should_trigger_at(Instant::now())
209 }
210}
211
212pub struct FaultRegistry {
214 inner: RwLock<HashMap<String, FaultConfig>>,
215}
216
217impl FaultRegistry {
218 #[must_use]
220 pub fn new() -> Self {
221 Self {
222 inner: RwLock::new(HashMap::new()),
223 }
224 }
225
226 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 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 #[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 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 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#[derive(Debug, Clone, Serialize, PartialEq)]
292pub enum JsonShape {
293 Null,
295 Bool,
297 Number,
299 String,
301 Array(Box<Self>),
303 Object(HashMap<String, Self>),
305}
306
307impl JsonShape {
308 #[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 #[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#[derive(Debug, Clone, Serialize)]
346pub struct ContractBaseline {
347 pub command: String,
349 pub args: serde_json::Value,
351 pub shape: JsonShape,
353 pub sample: String,
355 pub recorded_at: String,
357}
358
359#[derive(Debug, Clone, Serialize)]
361pub struct ContractDrift {
362 pub command: String,
364 pub new_fields: Vec<String>,
366 pub removed_fields: Vec<String>,
368 pub type_changes: Vec<TypeChange>,
370 pub shape_matches: bool,
372}
373
374#[derive(Debug, Clone, Serialize)]
376pub struct TypeChange {
377 pub path: String,
379 pub baseline_type: String,
381 pub current_type: String,
383}
384
385#[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
474pub struct ContractStore {
476 inner: RwLock<HashMap<String, ContractBaseline>>,
477}
478
479impl ContractStore {
480 #[must_use]
482 pub fn new() -> Self {
483 Self {
484 inner: RwLock::new(HashMap::new()),
485 }
486 }
487
488 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 #[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 #[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 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#[derive(Debug, Clone, Serialize)]
539pub struct StartupPhase {
540 pub name: String,
542 pub duration_ms: f64,
544 pub cumulative_ms: f64,
546}
547
548pub struct StartupTimeline {
550 start: Instant,
551 phases: RwLock<Vec<(String, Instant)>>,
552}
553
554impl StartupTimeline {
555 #[must_use]
557 pub fn new() -> Self {
558 Self {
559 start: Instant::now(),
560 phases: RwLock::new(Vec::new()),
561 }
562 }
563
564 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 #[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 #[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#[derive(Debug, Clone, Serialize)]
621pub struct CapturedTauriEvent {
622 pub name: String,
624 pub payload: String,
626 pub timestamp: String,
628}
629
630const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000;
631
632#[derive(Clone)]
634pub struct EventBusMonitor {
635 inner: std::sync::Arc<RwLock<VecDeque<CapturedTauriEvent>>>,
636 capacity: usize,
637}
638
639impl EventBusMonitor {
640 #[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 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 #[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 #[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 #[must_use]
683 pub fn is_empty(&self) -> bool {
684 self.len() == 0
685 }
686
687 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
705pub type ProbeFn = dyn Fn() -> serde_json::Value + Send + Sync + 'static;
710
711#[derive(Clone, Default)]
721pub struct AppStateProbes {
722 inner: std::sync::Arc<RwLock<std::collections::BTreeMap<String, std::sync::Arc<ProbeFn>>>>,
723}
724
725impl AppStateProbes {
726 pub fn register(&self, name: impl Into<String>, probe: std::sync::Arc<ProbeFn>) {
728 self.inner
729 .write()
730 .unwrap_or_else(std::sync::PoisonError::into_inner)
731 .insert(name.into(), probe);
732 }
733
734 #[must_use]
736 pub fn names(&self) -> Vec<String> {
737 self.inner
738 .read()
739 .unwrap_or_else(std::sync::PoisonError::into_inner)
740 .keys()
741 .cloned()
742 .collect()
743 }
744
745 #[must_use]
748 pub fn run(&self, name: &str) -> Option<serde_json::Value> {
749 let probe = self
750 .inner
751 .read()
752 .unwrap_or_else(std::sync::PoisonError::into_inner)
753 .get(name)
754 .cloned();
755 probe.map(|p| p())
756 }
757
758 #[must_use]
760 pub fn len(&self) -> usize {
761 self.inner
762 .read()
763 .unwrap_or_else(std::sync::PoisonError::into_inner)
764 .len()
765 }
766
767 #[must_use]
769 pub fn is_empty(&self) -> bool {
770 self.len() == 0
771 }
772}
773
774#[derive(Debug, Clone, Serialize)]
778pub struct TrackedTaskInfo {
779 pub name: String,
781 pub spawned_at: String,
783 pub is_finished: bool,
785 pub uptime_secs: u64,
787}
788
789struct TrackedTaskEntry {
790 name: String,
791 spawned_at: Instant,
792 spawned_at_wall: String,
793 finished: std::sync::Arc<AtomicBool>,
794}
795
796pub struct TaskTracker {
798 tasks: RwLock<Vec<TrackedTaskEntry>>,
799}
800
801impl TaskTracker {
802 #[must_use]
804 pub fn new() -> Self {
805 Self {
806 tasks: RwLock::new(Vec::new()),
807 }
808 }
809
810 pub fn track(&self, name: &str) -> std::sync::Arc<AtomicBool> {
812 let finished = std::sync::Arc::new(AtomicBool::new(false));
813 let entry = TrackedTaskEntry {
814 name: name.to_string(),
815 spawned_at: Instant::now(),
816 spawned_at_wall: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
817 finished: finished.clone(),
818 };
819 self.tasks
820 .write()
821 .unwrap_or_else(std::sync::PoisonError::into_inner)
822 .push(entry);
823 finished
824 }
825
826 #[must_use]
828 pub fn list(&self) -> Vec<TrackedTaskInfo> {
829 let tasks = self
830 .tasks
831 .read()
832 .unwrap_or_else(std::sync::PoisonError::into_inner);
833 tasks
834 .iter()
835 .map(|t| TrackedTaskInfo {
836 name: t.name.clone(),
837 spawned_at: t.spawned_at_wall.clone(),
838 is_finished: t.finished.load(std::sync::atomic::Ordering::Relaxed),
839 uptime_secs: t.spawned_at.elapsed().as_secs(),
840 })
841 .collect()
842 }
843
844 #[must_use]
846 pub fn active_count(&self) -> usize {
847 let tasks = self
848 .tasks
849 .read()
850 .unwrap_or_else(std::sync::PoisonError::into_inner);
851 tasks
852 .iter()
853 .filter(|t| !t.finished.load(std::sync::atomic::Ordering::Relaxed))
854 .count()
855 }
856}
857
858impl Default for TaskTracker {
859 fn default() -> Self {
860 Self::new()
861 }
862}
863
864#[derive(Debug, Clone, Serialize)]
868pub struct ChildProcessInfo {
869 pub pid: u32,
871 pub ppid: u32,
873 pub name: String,
875 pub memory_bytes: Option<u64>,
877}
878
879#[must_use]
886pub fn enumerate_child_processes() -> Vec<ChildProcessInfo> {
887 let my_pid = std::process::id();
888
889 #[cfg(windows)]
890 {
891 enumerate_children_windows(my_pid)
892 }
893
894 #[cfg(target_os = "linux")]
895 {
896 enumerate_children_linux(my_pid)
897 }
898
899 #[cfg(target_os = "macos")]
900 {
901 enumerate_children_macos(my_pid)
902 }
903
904 #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
905 {
906 let _ = my_pid;
907 Vec::new()
908 }
909}
910
911#[cfg(windows)]
912#[allow(unsafe_code)]
913fn enumerate_children_windows(parent_pid: u32) -> Vec<ChildProcessInfo> {
914 use windows::Win32::Foundation::CloseHandle;
915 use windows::Win32::System::Diagnostics::ToolHelp::{
916 CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
917 };
918
919 let mut children = Vec::new();
920
921 unsafe {
926 let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
927 return children;
928 };
929
930 let mut entry: PROCESSENTRY32 = std::mem::zeroed();
931 entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
932
933 if Process32First(snapshot, &mut entry).is_ok() {
934 loop {
935 if entry.th32ParentProcessID == parent_pid && entry.th32ProcessID != parent_pid {
936 let name_bytes: Vec<u8> = entry
937 .szExeFile
938 .iter()
939 .take_while(|&&b| b != 0)
940 .map(|&b| b as u8)
941 .collect();
942 let name = String::from_utf8_lossy(&name_bytes).to_string();
943
944 let memory_bytes = get_process_memory_windows(entry.th32ProcessID);
945
946 children.push(ChildProcessInfo {
947 pid: entry.th32ProcessID,
948 ppid: entry.th32ParentProcessID,
949 name,
950 memory_bytes,
951 });
952 }
953
954 if Process32Next(snapshot, &mut entry).is_err() {
955 break;
956 }
957 }
958 }
959
960 let _ = CloseHandle(snapshot);
961 }
962
963 children
964}
965
966#[cfg(windows)]
967#[allow(unsafe_code)]
968fn get_process_memory_windows(pid: u32) -> Option<u64> {
969 use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
970 use windows::Win32::System::Threading::{
971 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
972 };
973
974 unsafe {
978 let process = OpenProcess(
979 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
980 false,
981 pid,
982 )
983 .ok()?;
984
985 let mut counters: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
986 counters.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
987
988 if GetProcessMemoryInfo(process, &mut counters, counters.cb).is_ok() {
989 Some(counters.WorkingSetSize as u64)
990 } else {
991 None
992 }
993 }
994}
995
996#[cfg(target_os = "linux")]
997fn enumerate_children_linux(parent_pid: u32) -> Vec<ChildProcessInfo> {
998 let mut children = Vec::new();
999 let Ok(entries) = std::fs::read_dir("/proc") else {
1000 return children;
1001 };
1002
1003 for entry in entries.flatten() {
1004 let file_name = entry.file_name();
1005 let Some(pid_str) = file_name.to_str() else {
1006 continue;
1007 };
1008 let Ok(pid) = pid_str.parse::<u32>() else {
1009 continue;
1010 };
1011
1012 let status_path = format!("/proc/{pid}/status");
1013 let Ok(status) = std::fs::read_to_string(&status_path) else {
1014 continue;
1015 };
1016
1017 let mut ppid: Option<u32> = None;
1018 let mut name = String::new();
1019 let mut vm_rss_kb: u64 = 0;
1020
1021 for line in status.lines() {
1022 if let Some(v) = line.strip_prefix("PPid:\t") {
1023 ppid = v.trim().parse().ok();
1024 } else if let Some(v) = line.strip_prefix("Name:\t") {
1025 name = v.trim().to_string();
1026 } else if let Some(v) = line.strip_prefix("VmRSS:") {
1027 vm_rss_kb = v
1028 .split_whitespace()
1029 .next()
1030 .and_then(|n| n.parse().ok())
1031 .unwrap_or(0);
1032 }
1033 }
1034
1035 if ppid == Some(parent_pid) {
1036 children.push(ChildProcessInfo {
1037 pid,
1038 ppid: parent_pid,
1039 name,
1040 memory_bytes: if vm_rss_kb > 0 {
1041 Some(vm_rss_kb * 1024)
1042 } else {
1043 None
1044 },
1045 });
1046 }
1047 }
1048
1049 children
1050}
1051
1052#[cfg(target_os = "macos")]
1053#[allow(unsafe_code)]
1054fn enumerate_children_macos(parent_pid: u32) -> Vec<ChildProcessInfo> {
1055 use std::mem;
1056
1057 unsafe extern "C" {
1058 fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32;
1059 fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut u8, buffersize: i32) -> i32;
1060 fn proc_name(pid: i32, buffer: *mut u8, buffersize: u32) -> i32;
1061 }
1062
1063 const PROC_PIDTASKINFO: i32 = 4;
1064
1065 #[repr(C)]
1066 struct ProcTaskInfo {
1067 pti_virtual_size: u64,
1068 pti_resident_size: u64,
1069 pti_total_user: u64,
1070 pti_total_system: u64,
1071 pti_threads_user: u64,
1072 pti_threads_system: u64,
1073 pti_policy: i32,
1074 pti_faults: i32,
1075 pti_pageins: i32,
1076 pti_cow_faults: i32,
1077 pti_messages_sent: i32,
1078 pti_messages_received: i32,
1079 pti_syscalls_mach: i32,
1080 pti_syscalls_unix: i32,
1081 pti_csw: i32,
1082 pti_threadnum: i32,
1083 pti_numrunning: i32,
1084 pti_priority: i32,
1085 }
1086
1087 let mut children = Vec::new();
1088
1089 unsafe {
1093 let ppid = parent_pid as i32;
1094 let mut cap = 256usize;
1101 let (pids, n) = loop {
1102 let mut pids = vec![0i32; cap];
1103 let buf_size = (cap * mem::size_of::<i32>()) as i32;
1104 let actual = proc_listchildpids(ppid, pids.as_mut_ptr(), buf_size);
1105 if actual <= 0 {
1106 return children;
1107 }
1108 let count = actual as usize;
1109 if count < cap || cap >= 65536 {
1112 break (pids, count.min(cap));
1113 }
1114 cap = (count + 16).max(cap * 2);
1115 };
1116 for &pid in &pids[..n] {
1117 if pid <= 0 {
1118 continue;
1119 }
1120
1121 let mut name_buf = [0u8; 256];
1122 let name_len = proc_name(pid, name_buf.as_mut_ptr(), 256);
1123 let name = if name_len > 0 {
1124 String::from_utf8_lossy(&name_buf[..name_len as usize]).to_string()
1125 } else {
1126 String::from("<unknown>")
1127 };
1128
1129 let mut task_info: ProcTaskInfo = mem::zeroed();
1130 let info_size = mem::size_of::<ProcTaskInfo>() as i32;
1131 let ret = proc_pidinfo(
1132 pid,
1133 PROC_PIDTASKINFO,
1134 0,
1135 &mut task_info as *mut _ as *mut u8,
1136 info_size,
1137 );
1138
1139 let memory_bytes = if ret == info_size {
1140 Some(task_info.pti_resident_size)
1141 } else {
1142 None
1143 };
1144
1145 children.push(ChildProcessInfo {
1146 pid: pid as u32,
1147 ppid: parent_pid,
1148 name,
1149 memory_bytes,
1150 });
1151 }
1152 }
1153
1154 children
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160
1161 #[test]
1162 fn app_state_probes_register_run_list() {
1163 let probes = AppStateProbes::default();
1164 assert!(probes.is_empty());
1165
1166 probes.register(
1167 "scoring",
1168 std::sync::Arc::new(|| serde_json::json!({ "pipeline_version": 5 })),
1169 );
1170 probes.register("queue", std::sync::Arc::new(|| serde_json::json!(0)));
1171
1172 assert_eq!(
1174 probes.names(),
1175 vec!["queue".to_string(), "scoring".to_string()]
1176 );
1177 assert_eq!(probes.len(), 2);
1178
1179 let snapshot = probes.run("scoring").expect("probe runs");
1180 assert_eq!(snapshot["pipeline_version"], 5);
1181 assert!(probes.run("missing").is_none());
1182 }
1183
1184 #[test]
1185 fn app_state_probe_reflects_live_state() {
1186 let counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
1189 let probe_counter = std::sync::Arc::clone(&counter);
1190 let probes = AppStateProbes::default();
1191 probes.register(
1192 "counter",
1193 std::sync::Arc::new(move || {
1194 serde_json::json!(probe_counter.load(std::sync::atomic::Ordering::SeqCst))
1195 }),
1196 );
1197
1198 assert_eq!(probes.run("counter").unwrap(), serde_json::json!(0));
1199 counter.store(42, std::sync::atomic::Ordering::SeqCst);
1200 assert_eq!(probes.run("counter").unwrap(), serde_json::json!(42));
1201 }
1202
1203 #[test]
1204 fn event_bus_push_and_read() {
1205 let bus = EventBusMonitor::new(3);
1206 assert!(bus.is_empty());
1207 bus.push(CapturedTauriEvent {
1208 name: "test".to_string(),
1209 payload: "{}".to_string(),
1210 timestamp: "2026-01-01T00:00:00Z".to_string(),
1211 });
1212 assert_eq!(bus.len(), 1);
1213 assert_eq!(bus.events()[0].name, "test");
1214 }
1215
1216 #[test]
1217 fn event_bus_ring_buffer_eviction() {
1218 let bus = EventBusMonitor::new(2);
1219 for i in 0..5 {
1220 bus.push(CapturedTauriEvent {
1221 name: format!("event_{i}"),
1222 payload: String::new(),
1223 timestamp: String::new(),
1224 });
1225 }
1226 assert_eq!(bus.len(), 2);
1227 assert_eq!(bus.events()[0].name, "event_3");
1228 assert_eq!(bus.events()[1].name, "event_4");
1229 }
1230
1231 #[test]
1232 fn event_bus_clear() {
1233 let bus = EventBusMonitor::new(10);
1234 bus.push(CapturedTauriEvent {
1235 name: "a".to_string(),
1236 payload: String::new(),
1237 timestamp: String::new(),
1238 });
1239 assert_eq!(bus.clear(), 1);
1240 assert!(bus.is_empty());
1241 }
1242
1243 #[test]
1244 fn task_tracker_lifecycle() {
1245 let tracker = TaskTracker::new();
1246 let flag = tracker.track("mcp_server");
1247 let tasks = tracker.list();
1248 assert_eq!(tasks.len(), 1);
1249 assert_eq!(tasks[0].name, "mcp_server");
1250 assert!(!tasks[0].is_finished);
1251 assert_eq!(tracker.active_count(), 1);
1252
1253 flag.store(true, std::sync::atomic::Ordering::Relaxed);
1254 let tasks = tracker.list();
1255 assert!(tasks[0].is_finished);
1256 assert_eq!(tracker.active_count(), 0);
1257 }
1258
1259 #[test]
1260 fn timing_samples_basic() {
1261 let mut samples = TimingSamples::default();
1262 samples.record(Duration::from_millis(10));
1263 samples.record(Duration::from_millis(20));
1264 samples.record(Duration::from_millis(30));
1265 let stats = samples.stats("test_cmd");
1266 assert_eq!(stats.count, 3);
1267 assert!((stats.min_ms - 10.0).abs() < 1.0);
1268 assert!((stats.max_ms - 30.0).abs() < 1.0);
1269 assert!((stats.avg_ms - 20.0).abs() < 1.0);
1270 }
1271
1272 #[test]
1273 fn timing_samples_empty() {
1274 let samples = TimingSamples::default();
1275 let stats = samples.stats("empty");
1276 assert_eq!(stats.count, 0);
1277 assert_eq!(stats.min_ms, 0.0);
1278 }
1279
1280 #[test]
1281 fn command_timings_thread_safe() {
1282 let timings = CommandTimings::new();
1283 timings.record("cmd_a", Duration::from_millis(5));
1284 timings.record("cmd_a", Duration::from_millis(15));
1285 timings.record("cmd_b", Duration::from_millis(100));
1286
1287 let all = timings.all_stats();
1288 assert_eq!(all.len(), 2);
1289 assert_eq!(all[0].command, "cmd_b");
1290
1291 let a = timings.stats_for("cmd_a").unwrap();
1292 assert_eq!(a.count, 2);
1293 }
1294
1295 #[test]
1296 fn fault_registry_lifecycle() {
1297 let registry = FaultRegistry::new();
1298 registry.inject(FaultConfig {
1299 command: "slow_cmd".to_string(),
1300 fault_type: FaultType::Delay { delay_ms: 500 },
1301 trigger_count: 0,
1302 max_triggers: 2,
1303 created_at: Instant::now(),
1304 });
1305
1306 assert!(registry.check_and_trigger("slow_cmd").is_some());
1307 assert!(registry.check_and_trigger("slow_cmd").is_some());
1308 assert!(registry.check_and_trigger("slow_cmd").is_none());
1309
1310 assert_eq!(registry.list().len(), 1);
1311 assert!(registry.clear("slow_cmd"));
1312 assert_eq!(registry.list().len(), 0);
1313 }
1314
1315 #[test]
1316 fn fault_registry_unlimited() {
1317 let registry = FaultRegistry::new();
1318 registry.inject(FaultConfig {
1319 command: "always_fail".to_string(),
1320 fault_type: FaultType::Error {
1321 message: "injected".to_string(),
1322 },
1323 trigger_count: 0,
1324 max_triggers: 0,
1325 created_at: Instant::now(),
1326 });
1327
1328 for _ in 0..100 {
1329 assert!(registry.check_and_trigger("always_fail").is_some());
1330 }
1331 }
1332
1333 #[test]
1334 fn fault_expires_after_ttl() {
1335 let cfg = FaultConfig {
1336 command: "x".to_string(),
1337 fault_type: FaultType::Error {
1338 message: "e".to_string(),
1339 },
1340 trigger_count: 0,
1341 max_triggers: 0, created_at: Instant::now(),
1343 };
1344 assert!(cfg.should_trigger_at(cfg.created_at));
1346 assert!(!cfg.should_trigger_at(cfg.created_at + FAULT_TTL + Duration::from_secs(1)));
1347 }
1348
1349 #[test]
1350 fn json_shape_extraction() {
1351 let value = serde_json::json!({
1352 "name": "test",
1353 "count": 42,
1354 "active": true,
1355 "items": [{"id": 1}],
1356 "meta": null
1357 });
1358 let shape = JsonShape::from_value(&value);
1359 match &shape {
1360 JsonShape::Object(fields) => {
1361 assert_eq!(fields.len(), 5);
1362 assert_eq!(*fields.get("name").unwrap(), JsonShape::String);
1363 assert_eq!(*fields.get("count").unwrap(), JsonShape::Number);
1364 assert_eq!(*fields.get("active").unwrap(), JsonShape::Bool);
1365 assert_eq!(*fields.get("meta").unwrap(), JsonShape::Null);
1366 }
1367 _ => panic!("expected object"),
1368 }
1369 }
1370
1371 #[test]
1372 fn contract_diff_detects_changes() {
1373 let baseline = serde_json::json!({"name": "old", "count": 1});
1374 let current = serde_json::json!({"name": "new", "count": "not_a_number", "extra": true});
1375
1376 let b_shape = JsonShape::from_value(&baseline);
1377 let c_shape = JsonShape::from_value(¤t);
1378 let drift = diff_shapes(&b_shape, &c_shape, "test_cmd");
1379
1380 assert!(!drift.shape_matches);
1381 assert_eq!(drift.new_fields, vec!["test_cmd.extra"]);
1382 assert_eq!(drift.type_changes.len(), 1);
1383 assert_eq!(drift.type_changes[0].path, "test_cmd.count");
1384 }
1385
1386 #[test]
1387 fn contract_store_crud() {
1388 let store = ContractStore::new();
1389 let baseline = ContractBaseline {
1390 command: "get_user".to_string(),
1391 args: serde_json::json!({}),
1392 shape: JsonShape::Object(HashMap::new()),
1393 sample: "{}".to_string(),
1394 recorded_at: "2026-05-26".to_string(),
1395 };
1396 store.record(baseline);
1397 assert!(store.get("get_user").is_some());
1398 assert_eq!(store.all().len(), 1);
1399 assert_eq!(store.clear(), 1);
1400 assert!(store.get("get_user").is_none());
1401 }
1402
1403 #[test]
1404 fn startup_timeline_records_phases() {
1405 let timeline = StartupTimeline::new();
1406 std::thread::sleep(Duration::from_millis(5));
1407 timeline.mark("phase_1");
1408 std::thread::sleep(Duration::from_millis(5));
1409 timeline.mark("phase_2");
1410
1411 let report = timeline.report();
1412 assert_eq!(report.len(), 2);
1413 assert_eq!(report[0].name, "phase_1");
1414 assert!(report[1].cumulative_ms >= report[0].cumulative_ms);
1415 assert!(timeline.total_ms() > 0.0);
1416 }
1417
1418 #[test]
1419 fn enumerate_child_processes_returns_vec() {
1420 let children = enumerate_child_processes();
1421 for child in &children {
1424 assert_ne!(child.pid, 0, "child PID should be non-zero");
1425 assert_eq!(
1426 child.ppid,
1427 std::process::id(),
1428 "parent PID should match current process"
1429 );
1430 assert!(!child.name.is_empty(), "child name should not be empty");
1431 }
1432 }
1433
1434 #[test]
1435 fn enumerate_child_processes_with_spawned_child() {
1436 let child = std::process::Command::new(if cfg!(windows) { "cmd.exe" } else { "sleep" })
1438 .args(if cfg!(windows) {
1439 &["/c", "timeout /t 10 /nobreak >nul"][..]
1440 } else {
1441 &["10"][..]
1442 })
1443 .spawn();
1444
1445 if let Ok(mut child_proc) = child {
1446 let children = enumerate_child_processes();
1447 assert!(
1448 !children.is_empty(),
1449 "should find at least one child process"
1450 );
1451
1452 let found = children.iter().any(|c| c.pid == child_proc.id());
1453 assert!(
1454 found,
1455 "spawned child (PID {}) should appear in enumeration",
1456 child_proc.id()
1457 );
1458
1459 let _ = child_proc.kill();
1460 let _ = child_proc.wait();
1461 }
1462 }
1463
1464 #[test]
1465 fn child_process_info_serializes() {
1466 let info = ChildProcessInfo {
1467 pid: 1234,
1468 ppid: 5678,
1469 name: "test-sidecar".to_string(),
1470 memory_bytes: Some(1_048_576),
1471 };
1472 let json = serde_json::to_value(&info).unwrap();
1473 assert_eq!(json["pid"], 1234);
1474 assert_eq!(json["ppid"], 5678);
1475 assert_eq!(json["name"], "test-sidecar");
1476 assert_eq!(json["memory_bytes"], 1_048_576);
1477 }
1478
1479 #[test]
1480 fn child_process_info_serializes_no_memory() {
1481 let info = ChildProcessInfo {
1482 pid: 42,
1483 ppid: 1,
1484 name: "zombie".to_string(),
1485 memory_bytes: None,
1486 };
1487 let json = serde_json::to_value(&info).unwrap();
1488 assert!(json["memory_bytes"].is_null());
1489 }
1490}