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)]
16#[non_exhaustive]
17pub struct CommandTimingStats {
18 pub command: String,
20 pub count: u64,
22 pub min_ms: f64,
24 pub max_ms: f64,
26 pub avg_ms: f64,
28 pub p95_ms: f64,
30 pub total_ms: f64,
32}
33
34const MAX_TIMING_SAMPLES: usize = 1024;
41
42#[derive(Debug, Default)]
47pub(crate) struct TimingSamples {
48 recent: VecDeque<Duration>,
50 count: u64,
52 total: Duration,
54 min: Option<Duration>,
56 max: Option<Duration>,
58}
59
60impl TimingSamples {
61 pub fn record(&mut self, duration: Duration) {
63 self.count += 1;
64 self.total = self.total.saturating_add(duration);
65 self.min = Some(self.min.map_or(duration, |m| m.min(duration)));
66 self.max = Some(self.max.map_or(duration, |m| m.max(duration)));
67 if self.recent.len() == MAX_TIMING_SAMPLES {
68 self.recent.pop_front();
69 }
70 self.recent.push_back(duration);
71 }
72
73 #[must_use]
77 pub fn stats(&self, command: &str) -> CommandTimingStats {
78 if self.count == 0 {
79 return CommandTimingStats {
80 command: command.to_string(),
81 count: 0,
82 min_ms: 0.0,
83 max_ms: 0.0,
84 avg_ms: 0.0,
85 p95_ms: 0.0,
86 total_ms: 0.0,
87 };
88 }
89 let to_ms = |d: Duration| d.as_secs_f64() * 1000.0;
90 let round2 = |v: f64| (v * 100.0).round() / 100.0;
91
92 let total_ms = to_ms(self.total);
93 let avg_ms = total_ms / self.count as f64;
94 let min_ms = self.min.map_or(0.0, to_ms);
95 let max_ms = self.max.map_or(0.0, to_ms);
96
97 let mut sorted: Vec<f64> = self.recent.iter().copied().map(to_ms).collect();
99 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
100 let p95 = if sorted.is_empty() {
101 0.0
102 } else {
103 let idx = (((sorted.len() as f64) * 0.95).ceil() as usize).saturating_sub(1);
106 sorted[idx.min(sorted.len() - 1)]
107 };
108
109 CommandTimingStats {
110 command: command.to_string(),
111 count: self.count,
112 min_ms: round2(min_ms),
113 max_ms: round2(max_ms),
114 avg_ms: round2(avg_ms),
115 p95_ms: round2(p95),
116 total_ms: round2(total_ms),
117 }
118 }
119}
120
121const MAX_TIMED_COMMANDS: usize = 1024;
125
126pub struct CommandTimings {
128 inner: RwLock<HashMap<String, TimingSamples>>,
129}
130
131impl CommandTimings {
132 #[must_use]
134 pub fn new() -> Self {
135 Self {
136 inner: RwLock::new(HashMap::new()),
137 }
138 }
139
140 pub fn record(&self, command: &str, duration: Duration) {
142 let mut map = self
143 .inner
144 .write()
145 .unwrap_or_else(std::sync::PoisonError::into_inner);
146 if let Some(samples) = map.get_mut(command) {
147 samples.record(duration);
148 } else if map.len() < MAX_TIMED_COMMANDS {
149 map.entry(command.to_string()).or_default().record(duration);
150 }
151 }
152
153 #[must_use]
155 pub fn all_stats(&self) -> Vec<CommandTimingStats> {
156 let map = self
157 .inner
158 .read()
159 .unwrap_or_else(std::sync::PoisonError::into_inner);
160 let mut stats: Vec<CommandTimingStats> =
161 map.iter().map(|(name, s)| s.stats(name)).collect();
162 stats.sort_by(|a, b| {
163 b.total_ms
164 .partial_cmp(&a.total_ms)
165 .unwrap_or(std::cmp::Ordering::Equal)
166 });
167 stats
168 }
169
170 #[must_use]
172 pub fn stats_for(&self, command: &str) -> Option<CommandTimingStats> {
173 let map = self
174 .inner
175 .read()
176 .unwrap_or_else(std::sync::PoisonError::into_inner);
177 map.get(command).map(|s| s.stats(command))
178 }
179
180 pub fn clear(&self) {
182 let mut map = self
183 .inner
184 .write()
185 .unwrap_or_else(std::sync::PoisonError::into_inner);
186 map.clear();
187 }
188}
189
190impl Default for CommandTimings {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196#[derive(Debug, Clone, Serialize)]
200#[non_exhaustive]
201pub enum FaultType {
202 Delay {
204 delay_ms: u64,
206 },
207 Error {
209 message: String,
211 },
212 Drop,
214 Corrupt,
216}
217
218#[derive(Debug, Clone, Serialize)]
220#[non_exhaustive]
221pub struct FaultConfig {
222 pub command: String,
224 pub fault_type: FaultType,
226 pub trigger_count: u64,
228 pub max_triggers: u64,
230 #[serde(skip)]
232 pub created_at: Instant,
233}
234
235pub const FAULT_TTL: Duration = Duration::from_secs(900); impl FaultConfig {
240 #[must_use]
243 pub fn new(command: impl Into<String>, fault_type: FaultType, max_triggers: u64) -> Self {
244 Self {
245 command: command.into(),
246 fault_type,
247 trigger_count: 0,
248 max_triggers,
249 created_at: Instant::now(),
250 }
251 }
252
253 #[must_use]
256 pub fn should_trigger_at(&self, now: Instant) -> bool {
257 if now.saturating_duration_since(self.created_at) >= FAULT_TTL {
258 return false;
259 }
260 self.max_triggers == 0 || self.trigger_count < self.max_triggers
261 }
262
263 #[must_use]
265 pub fn should_trigger(&self) -> bool {
266 self.should_trigger_at(Instant::now())
267 }
268}
269
270pub(crate) const MAX_FAULT_RULES: usize = 256;
273
274pub struct FaultRegistry {
276 inner: RwLock<FaultRules>,
277}
278
279#[derive(Default)]
280struct FaultRules {
281 next_seq: u64,
283 map: HashMap<String, (u64, FaultConfig)>,
284}
285
286impl FaultRules {
287 fn evict_expired(&mut self, now: Instant) {
290 self.map
291 .retain(|_, (_, c)| now.saturating_duration_since(c.created_at) < FAULT_TTL);
292 }
293}
294
295impl FaultRegistry {
296 #[must_use]
298 pub fn new() -> Self {
299 Self {
300 inner: RwLock::new(FaultRules::default()),
301 }
302 }
303
304 pub fn inject(&self, config: FaultConfig) {
308 let mut rules = self
309 .inner
310 .write()
311 .unwrap_or_else(std::sync::PoisonError::into_inner);
312 rules.evict_expired(Instant::now());
313 if !rules.map.contains_key(&config.command) && rules.map.len() >= MAX_FAULT_RULES {
314 let oldest = rules
315 .map
316 .iter()
317 .min_by_key(|(_, (seq, _))| *seq)
318 .map(|(k, _)| k.clone());
319 if let Some(oldest) = oldest {
320 rules.map.remove(&oldest);
321 }
322 }
323 let seq = rules.next_seq;
324 rules.next_seq += 1;
325 rules.map.insert(config.command.clone(), (seq, config));
326 }
327
328 pub fn check_and_trigger(&self, command: &str) -> Option<FaultType> {
331 let mut rules = self
332 .inner
333 .write()
334 .unwrap_or_else(std::sync::PoisonError::into_inner);
335 if let Some((_, config)) = rules.map.get_mut(command)
336 && config.should_trigger()
337 {
338 config.trigger_count += 1;
339 return Some(config.fault_type.clone());
340 }
341 None
342 }
343
344 #[must_use]
346 pub fn list(&self) -> Vec<FaultConfig> {
347 let mut rules = self
348 .inner
349 .write()
350 .unwrap_or_else(std::sync::PoisonError::into_inner);
351 rules.evict_expired(Instant::now());
352 rules.map.values().map(|(_, c)| c.clone()).collect()
353 }
354
355 pub fn clear(&self, command: &str) -> bool {
357 let mut rules = self
358 .inner
359 .write()
360 .unwrap_or_else(std::sync::PoisonError::into_inner);
361 rules.map.remove(command).is_some()
362 }
363
364 pub fn clear_all(&self) -> usize {
366 let mut rules = self
367 .inner
368 .write()
369 .unwrap_or_else(std::sync::PoisonError::into_inner);
370 let count = rules.map.len();
371 rules.map.clear();
372 count
373 }
374}
375
376impl Default for FaultRegistry {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382#[derive(Debug, Clone, Serialize, PartialEq)]
386#[non_exhaustive]
387pub enum JsonShape {
388 Null,
390 Bool,
392 Number,
394 String,
396 Array(Box<Self>),
398 Object(HashMap<String, Self>),
400}
401
402impl JsonShape {
403 #[must_use]
405 pub fn from_value(value: &serde_json::Value) -> Self {
406 match value {
407 serde_json::Value::Null => Self::Null,
408 serde_json::Value::Bool(_) => Self::Bool,
409 serde_json::Value::Number(_) => Self::Number,
410 serde_json::Value::String(_) => Self::String,
411 serde_json::Value::Array(arr) => {
412 let elem = arr.first().map_or(Self::Null, Self::from_value);
413 Self::Array(Box::new(elem))
414 }
415 serde_json::Value::Object(obj) => {
416 let fields: HashMap<String, Self> = obj
417 .iter()
418 .map(|(k, v)| (k.clone(), Self::from_value(v)))
419 .collect();
420 Self::Object(fields)
421 }
422 }
423 }
424
425 #[must_use]
427 pub fn type_name(&self) -> &'static str {
428 match self {
429 Self::Null => "null",
430 Self::Bool => "bool",
431 Self::Number => "number",
432 Self::String => "string",
433 Self::Array(_) => "array",
434 Self::Object(_) => "object",
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize)]
441#[non_exhaustive]
442pub struct ContractBaseline {
443 pub command: String,
445 pub args: serde_json::Value,
447 pub shape: JsonShape,
449 pub sample: String,
451 pub recorded_at: String,
453}
454
455impl ContractBaseline {
456 #[must_use]
458 pub fn new(
459 command: impl Into<String>,
460 args: serde_json::Value,
461 shape: JsonShape,
462 sample: impl Into<String>,
463 recorded_at: impl Into<String>,
464 ) -> Self {
465 Self {
466 command: command.into(),
467 args,
468 shape,
469 sample: sample.into(),
470 recorded_at: recorded_at.into(),
471 }
472 }
473}
474
475#[derive(Debug, Clone, Serialize)]
477#[non_exhaustive]
478pub struct ContractDrift {
479 pub command: String,
481 pub new_fields: Vec<String>,
483 pub removed_fields: Vec<String>,
485 pub type_changes: Vec<TypeChange>,
487 pub shape_matches: bool,
489}
490
491#[derive(Debug, Clone, Serialize)]
493#[non_exhaustive]
494pub struct TypeChange {
495 pub path: String,
497 pub baseline_type: String,
499 pub current_type: String,
501}
502
503#[must_use]
505pub fn diff_shapes(baseline: &JsonShape, current: &JsonShape, prefix: &str) -> ContractDrift {
506 let mut new_fields = Vec::new();
507 let mut removed_fields = Vec::new();
508 let mut type_changes = Vec::new();
509
510 diff_shapes_inner(
511 baseline,
512 current,
513 prefix,
514 &mut new_fields,
515 &mut removed_fields,
516 &mut type_changes,
517 );
518
519 let shape_matches =
520 new_fields.is_empty() && removed_fields.is_empty() && type_changes.is_empty();
521 ContractDrift {
522 command: prefix.to_string(),
523 new_fields,
524 removed_fields,
525 type_changes,
526 shape_matches,
527 }
528}
529
530fn diff_shapes_inner(
531 baseline: &JsonShape,
532 current: &JsonShape,
533 prefix: &str,
534 new_fields: &mut Vec<String>,
535 removed_fields: &mut Vec<String>,
536 type_changes: &mut Vec<TypeChange>,
537) {
538 match (baseline, current) {
539 (JsonShape::Object(b_fields), JsonShape::Object(c_fields)) => {
540 for (key, b_shape) in b_fields {
541 let path = if prefix.is_empty() {
542 key.clone()
543 } else {
544 format!("{prefix}.{key}")
545 };
546 if let Some(c_shape) = c_fields.get(key) {
547 diff_shapes_inner(
548 b_shape,
549 c_shape,
550 &path,
551 new_fields,
552 removed_fields,
553 type_changes,
554 );
555 } else {
556 removed_fields.push(path);
557 }
558 }
559 for key in c_fields.keys() {
560 if !b_fields.contains_key(key) {
561 let path = if prefix.is_empty() {
562 key.clone()
563 } else {
564 format!("{prefix}.{key}")
565 };
566 new_fields.push(path);
567 }
568 }
569 }
570 (JsonShape::Array(b_elem), JsonShape::Array(c_elem)) => {
571 let path = format!("{prefix}[]");
572 diff_shapes_inner(
573 b_elem,
574 c_elem,
575 &path,
576 new_fields,
577 removed_fields,
578 type_changes,
579 );
580 }
581 (b, c) if b.type_name() != c.type_name() => {
582 type_changes.push(TypeChange {
583 path: prefix.to_string(),
584 baseline_type: b.type_name().to_string(),
585 current_type: c.type_name().to_string(),
586 });
587 }
588 _ => {}
589 }
590}
591
592pub(crate) const MAX_CONTRACT_BASELINES: usize = 1024;
595
596pub struct ContractStore {
598 inner: RwLock<ContractBaselines>,
599}
600
601#[derive(Default)]
602struct ContractBaselines {
603 next_seq: u64,
605 map: HashMap<String, (u64, ContractBaseline)>,
606}
607
608impl ContractStore {
609 #[must_use]
611 pub fn new() -> Self {
612 Self {
613 inner: RwLock::new(ContractBaselines::default()),
614 }
615 }
616
617 pub fn record(&self, baseline: ContractBaseline) -> Option<String> {
621 let mut store = self
622 .inner
623 .write()
624 .unwrap_or_else(std::sync::PoisonError::into_inner);
625 let mut evicted = None;
626 if !store.map.contains_key(&baseline.command) && store.map.len() >= MAX_CONTRACT_BASELINES {
627 evicted = store
628 .map
629 .iter()
630 .min_by_key(|(_, (seq, _))| *seq)
631 .map(|(k, _)| k.clone());
632 if let Some(oldest) = &evicted {
633 store.map.remove(oldest);
634 }
635 }
636 let seq = store.next_seq;
637 store.next_seq += 1;
638 store.map.insert(baseline.command.clone(), (seq, baseline));
639 evicted
640 }
641
642 #[must_use]
644 pub fn get(&self, command: &str) -> Option<ContractBaseline> {
645 let store = self
646 .inner
647 .read()
648 .unwrap_or_else(std::sync::PoisonError::into_inner);
649 store.map.get(command).map(|(_, b)| b.clone())
650 }
651
652 #[must_use]
654 pub fn all(&self) -> Vec<ContractBaseline> {
655 let store = self
656 .inner
657 .read()
658 .unwrap_or_else(std::sync::PoisonError::into_inner);
659 store.map.values().map(|(_, b)| b.clone()).collect()
660 }
661
662 pub fn clear(&self) -> usize {
664 let mut store = self
665 .inner
666 .write()
667 .unwrap_or_else(std::sync::PoisonError::into_inner);
668 let count = store.map.len();
669 store.map.clear();
670 count
671 }
672}
673
674impl Default for ContractStore {
675 fn default() -> Self {
676 Self::new()
677 }
678}
679
680#[derive(Debug, Clone, Serialize)]
684#[non_exhaustive]
685pub struct StartupPhase {
686 pub name: String,
688 pub duration_ms: f64,
690 pub cumulative_ms: f64,
692}
693
694pub struct StartupTimeline {
696 start: Instant,
697 phases: RwLock<Vec<(String, Instant)>>,
698}
699
700impl StartupTimeline {
701 #[must_use]
703 pub fn new() -> Self {
704 Self {
705 start: Instant::now(),
706 phases: RwLock::new(Vec::new()),
707 }
708 }
709
710 pub fn mark(&self, name: &str) {
712 let mut phases = self
713 .phases
714 .write()
715 .unwrap_or_else(std::sync::PoisonError::into_inner);
716 phases.push((name.to_string(), Instant::now()));
717 }
718
719 #[must_use]
721 pub fn report(&self) -> Vec<StartupPhase> {
722 let phases = self
723 .phases
724 .read()
725 .unwrap_or_else(std::sync::PoisonError::into_inner);
726 let mut result = Vec::new();
727 let mut prev = self.start;
728
729 for (name, instant) in phases.iter() {
730 let duration = instant.duration_since(prev);
731 let cumulative = instant.duration_since(self.start);
732 result.push(StartupPhase {
733 name: name.clone(),
734 duration_ms: (duration.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
735 cumulative_ms: (cumulative.as_secs_f64() * 1000.0 * 100.0).round() / 100.0,
736 });
737 prev = *instant;
738 }
739 result
740 }
741
742 #[must_use]
744 pub fn total_ms(&self) -> f64 {
745 let phases = self
746 .phases
747 .read()
748 .unwrap_or_else(std::sync::PoisonError::into_inner);
749 if let Some((_, last)) = phases.last() {
750 (last.duration_since(self.start).as_secs_f64() * 1000.0 * 100.0).round() / 100.0
751 } else {
752 0.0
753 }
754 }
755}
756
757impl Default for StartupTimeline {
758 fn default() -> Self {
759 Self::new()
760 }
761}
762
763#[derive(Debug, Clone, Serialize)]
767#[non_exhaustive]
768pub struct CapturedTauriEvent {
769 pub name: String,
771 pub payload: String,
773 pub timestamp: String,
775}
776
777impl CapturedTauriEvent {
778 #[must_use]
780 pub fn new(
781 name: impl Into<String>,
782 payload: impl Into<String>,
783 timestamp: impl Into<String>,
784 ) -> Self {
785 Self {
786 name: name.into(),
787 payload: payload.into(),
788 timestamp: timestamp.into(),
789 }
790 }
791}
792
793const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000;
794
795#[derive(Clone)]
797pub struct EventBusMonitor {
798 inner: std::sync::Arc<RwLock<VecDeque<CapturedTauriEvent>>>,
799 capacity: usize,
800}
801
802impl EventBusMonitor {
803 #[must_use]
805 pub fn new(capacity: usize) -> Self {
806 Self {
807 inner: std::sync::Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
808 capacity,
809 }
810 }
811
812 pub fn push(&self, event: CapturedTauriEvent) {
814 let mut buf = self
815 .inner
816 .write()
817 .unwrap_or_else(std::sync::PoisonError::into_inner);
818 if buf.len() >= self.capacity {
819 buf.pop_front();
820 }
821 buf.push_back(event);
822 }
823
824 #[must_use]
826 pub fn events(&self) -> Vec<CapturedTauriEvent> {
827 self.inner
828 .read()
829 .unwrap_or_else(std::sync::PoisonError::into_inner)
830 .iter()
831 .cloned()
832 .collect()
833 }
834
835 #[must_use]
837 pub fn len(&self) -> usize {
838 self.inner
839 .read()
840 .unwrap_or_else(std::sync::PoisonError::into_inner)
841 .len()
842 }
843
844 #[must_use]
846 pub fn is_empty(&self) -> bool {
847 self.len() == 0
848 }
849
850 pub fn clear(&self) -> usize {
852 let mut buf = self
853 .inner
854 .write()
855 .unwrap_or_else(std::sync::PoisonError::into_inner);
856 let count = buf.len();
857 buf.clear();
858 count
859 }
860}
861
862impl Default for EventBusMonitor {
863 fn default() -> Self {
864 Self::new(DEFAULT_EVENT_BUS_CAPACITY)
865 }
866}
867
868pub type ProbeFn = dyn Fn() -> serde_json::Value + Send + Sync + 'static;
873
874#[derive(Clone, Default)]
884pub struct AppStateProbes {
885 inner: std::sync::Arc<RwLock<std::collections::BTreeMap<String, std::sync::Arc<ProbeFn>>>>,
886}
887
888impl AppStateProbes {
889 pub fn register(&self, name: impl Into<String>, probe: std::sync::Arc<ProbeFn>) {
891 self.inner
892 .write()
893 .unwrap_or_else(std::sync::PoisonError::into_inner)
894 .insert(name.into(), probe);
895 }
896
897 #[must_use]
899 pub fn names(&self) -> Vec<String> {
900 self.inner
901 .read()
902 .unwrap_or_else(std::sync::PoisonError::into_inner)
903 .keys()
904 .cloned()
905 .collect()
906 }
907
908 #[must_use]
911 pub fn run(&self, name: &str) -> Option<serde_json::Value> {
912 self.get(name).map(|p| p())
913 }
914
915 #[must_use]
918 pub fn get(&self, name: &str) -> Option<std::sync::Arc<ProbeFn>> {
919 self.inner
920 .read()
921 .unwrap_or_else(std::sync::PoisonError::into_inner)
922 .get(name)
923 .cloned()
924 }
925
926 #[must_use]
928 pub fn len(&self) -> usize {
929 self.inner
930 .read()
931 .unwrap_or_else(std::sync::PoisonError::into_inner)
932 .len()
933 }
934
935 #[must_use]
937 pub fn is_empty(&self) -> bool {
938 self.len() == 0
939 }
940}
941
942#[derive(Debug, Clone, Serialize)]
946#[non_exhaustive]
947pub struct TrackedTaskInfo {
948 pub name: String,
950 pub spawned_at: String,
952 pub is_finished: bool,
954 pub uptime_secs: u64,
956}
957
958struct TrackedTaskEntry {
959 name: String,
960 spawned_at: Instant,
961 spawned_at_wall: String,
962 finished: std::sync::Arc<AtomicBool>,
963}
964
965pub struct TaskTracker {
967 tasks: RwLock<Vec<TrackedTaskEntry>>,
968}
969
970impl TaskTracker {
971 #[must_use]
973 pub fn new() -> Self {
974 Self {
975 tasks: RwLock::new(Vec::new()),
976 }
977 }
978
979 pub fn track(&self, name: &str) -> std::sync::Arc<AtomicBool> {
981 let finished = std::sync::Arc::new(AtomicBool::new(false));
982 let entry = TrackedTaskEntry {
983 name: name.to_string(),
984 spawned_at: Instant::now(),
985 spawned_at_wall: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
986 finished: finished.clone(),
987 };
988 self.tasks
989 .write()
990 .unwrap_or_else(std::sync::PoisonError::into_inner)
991 .push(entry);
992 finished
993 }
994
995 pub(crate) fn track_guarded(&self, name: &str) -> TaskFinishedGuard {
1000 TaskFinishedGuard(self.track(name))
1001 }
1002
1003 #[must_use]
1005 pub fn list(&self) -> Vec<TrackedTaskInfo> {
1006 let tasks = self
1007 .tasks
1008 .read()
1009 .unwrap_or_else(std::sync::PoisonError::into_inner);
1010 tasks
1011 .iter()
1012 .map(|t| TrackedTaskInfo {
1013 name: t.name.clone(),
1014 spawned_at: t.spawned_at_wall.clone(),
1015 is_finished: t.finished.load(std::sync::atomic::Ordering::Relaxed),
1016 uptime_secs: t.spawned_at.elapsed().as_secs(),
1017 })
1018 .collect()
1019 }
1020
1021 #[must_use]
1023 pub fn active_count(&self) -> usize {
1024 let tasks = self
1025 .tasks
1026 .read()
1027 .unwrap_or_else(std::sync::PoisonError::into_inner);
1028 tasks
1029 .iter()
1030 .filter(|t| !t.finished.load(std::sync::atomic::Ordering::Relaxed))
1031 .count()
1032 }
1033}
1034
1035impl Default for TaskTracker {
1036 fn default() -> Self {
1037 Self::new()
1038 }
1039}
1040
1041#[must_use = "the task is marked finished as soon as the guard is dropped"]
1044pub(crate) struct TaskFinishedGuard(std::sync::Arc<AtomicBool>);
1045
1046impl Drop for TaskFinishedGuard {
1047 fn drop(&mut self) {
1048 self.0.store(true, std::sync::atomic::Ordering::Relaxed);
1049 }
1050}
1051
1052#[derive(Debug, Clone, Serialize)]
1056#[non_exhaustive]
1057pub struct ChildProcessInfo {
1058 pub pid: u32,
1060 pub ppid: u32,
1062 pub name: String,
1064 pub memory_bytes: Option<u64>,
1066}
1067
1068#[must_use]
1075pub fn enumerate_child_processes() -> Vec<ChildProcessInfo> {
1076 let my_pid = std::process::id();
1077
1078 #[cfg(windows)]
1079 {
1080 enumerate_children_windows(my_pid)
1081 }
1082
1083 #[cfg(target_os = "linux")]
1084 {
1085 enumerate_children_linux(my_pid)
1086 }
1087
1088 #[cfg(target_os = "macos")]
1089 {
1090 enumerate_children_macos(my_pid)
1091 }
1092
1093 #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
1094 {
1095 let _ = my_pid;
1096 Vec::new()
1097 }
1098}
1099
1100#[cfg(windows)]
1101#[allow(unsafe_code)]
1102fn enumerate_children_windows(parent_pid: u32) -> Vec<ChildProcessInfo> {
1103 use windows::Win32::Foundation::CloseHandle;
1104 use windows::Win32::System::Diagnostics::ToolHelp::{
1105 CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
1106 };
1107
1108 let mut children = Vec::new();
1109
1110 unsafe {
1115 let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
1116 return children;
1117 };
1118
1119 let mut entry: PROCESSENTRY32 = std::mem::zeroed();
1120 entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
1121
1122 if Process32First(snapshot, &mut entry).is_ok() {
1123 loop {
1124 if entry.th32ParentProcessID == parent_pid && entry.th32ProcessID != parent_pid {
1125 let name_bytes: Vec<u8> = entry
1126 .szExeFile
1127 .iter()
1128 .take_while(|&&b| b != 0)
1129 .map(|&b| b as u8)
1130 .collect();
1131 let name = String::from_utf8_lossy(&name_bytes).to_string();
1132
1133 let memory_bytes = get_process_memory_windows(entry.th32ProcessID);
1134
1135 children.push(ChildProcessInfo {
1136 pid: entry.th32ProcessID,
1137 ppid: entry.th32ParentProcessID,
1138 name,
1139 memory_bytes,
1140 });
1141 }
1142
1143 if Process32Next(snapshot, &mut entry).is_err() {
1144 break;
1145 }
1146 }
1147 }
1148
1149 let _ = CloseHandle(snapshot);
1150 }
1151
1152 children
1153}
1154
1155#[cfg(windows)]
1156#[allow(unsafe_code)]
1157fn get_process_memory_windows(pid: u32) -> Option<u64> {
1158 use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
1159 use windows::Win32::System::Threading::{
1160 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
1161 };
1162
1163 unsafe {
1167 let process = OpenProcess(
1168 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
1169 false,
1170 pid,
1171 )
1172 .ok()?;
1173
1174 let mut counters: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
1175 counters.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
1176
1177 if GetProcessMemoryInfo(process, &mut counters, counters.cb).is_ok() {
1178 Some(counters.WorkingSetSize as u64)
1179 } else {
1180 None
1181 }
1182 }
1183}
1184
1185#[cfg(target_os = "linux")]
1186fn enumerate_children_linux(parent_pid: u32) -> Vec<ChildProcessInfo> {
1187 let mut children = Vec::new();
1188 let Ok(entries) = std::fs::read_dir("/proc") else {
1189 return children;
1190 };
1191
1192 for entry in entries.flatten() {
1193 let file_name = entry.file_name();
1194 let Some(pid_str) = file_name.to_str() else {
1195 continue;
1196 };
1197 let Ok(pid) = pid_str.parse::<u32>() else {
1198 continue;
1199 };
1200
1201 let status_path = format!("/proc/{pid}/status");
1202 let Ok(status) = std::fs::read_to_string(&status_path) else {
1203 continue;
1204 };
1205
1206 let mut ppid: Option<u32> = None;
1207 let mut name = String::new();
1208 let mut vm_rss_kb: u64 = 0;
1209
1210 for line in status.lines() {
1211 if let Some(v) = line.strip_prefix("PPid:\t") {
1212 ppid = v.trim().parse().ok();
1213 } else if let Some(v) = line.strip_prefix("Name:\t") {
1214 name = v.trim().to_string();
1215 } else if let Some(v) = line.strip_prefix("VmRSS:") {
1216 vm_rss_kb = v
1217 .split_whitespace()
1218 .next()
1219 .and_then(|n| n.parse().ok())
1220 .unwrap_or(0);
1221 }
1222 }
1223
1224 if ppid == Some(parent_pid) {
1225 children.push(ChildProcessInfo {
1226 pid,
1227 ppid: parent_pid,
1228 name,
1229 memory_bytes: if vm_rss_kb > 0 {
1230 Some(vm_rss_kb * 1024)
1231 } else {
1232 None
1233 },
1234 });
1235 }
1236 }
1237
1238 children
1239}
1240
1241#[cfg(target_os = "macos")]
1242#[allow(unsafe_code)]
1243fn enumerate_children_macos(parent_pid: u32) -> Vec<ChildProcessInfo> {
1244 use std::mem;
1245
1246 unsafe extern "C" {
1247 fn proc_listchildpids(ppid: i32, buffer: *mut i32, buffersize: i32) -> i32;
1248 fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut u8, buffersize: i32) -> i32;
1249 fn proc_name(pid: i32, buffer: *mut u8, buffersize: u32) -> i32;
1250 }
1251
1252 const PROC_PIDTASKINFO: i32 = 4;
1253
1254 #[repr(C)]
1255 struct ProcTaskInfo {
1256 pti_virtual_size: u64,
1257 pti_resident_size: u64,
1258 pti_total_user: u64,
1259 pti_total_system: u64,
1260 pti_threads_user: u64,
1261 pti_threads_system: u64,
1262 pti_policy: i32,
1263 pti_faults: i32,
1264 pti_pageins: i32,
1265 pti_cow_faults: i32,
1266 pti_messages_sent: i32,
1267 pti_messages_received: i32,
1268 pti_syscalls_mach: i32,
1269 pti_syscalls_unix: i32,
1270 pti_csw: i32,
1271 pti_threadnum: i32,
1272 pti_numrunning: i32,
1273 pti_priority: i32,
1274 }
1275
1276 let mut children = Vec::new();
1277
1278 unsafe {
1282 let ppid = parent_pid as i32;
1283 let mut cap = 256usize;
1290 let (pids, n) = loop {
1291 let mut pids = vec![0i32; cap];
1292 let buf_size = (cap * mem::size_of::<i32>()) as i32;
1293 let actual = proc_listchildpids(ppid, pids.as_mut_ptr(), buf_size);
1294 if actual <= 0 {
1295 return children;
1296 }
1297 let count = actual as usize;
1298 if count < cap || cap >= 65536 {
1301 break (pids, count.min(cap));
1302 }
1303 cap = (count + 16).max(cap * 2);
1304 };
1305 for &pid in &pids[..n] {
1306 if pid <= 0 {
1307 continue;
1308 }
1309
1310 let mut name_buf = [0u8; 256];
1311 let name_len = proc_name(pid, name_buf.as_mut_ptr(), 256);
1312 let name = if name_len > 0 {
1313 String::from_utf8_lossy(&name_buf[..name_len as usize]).to_string()
1314 } else {
1315 String::from("<unknown>")
1316 };
1317
1318 let mut task_info: ProcTaskInfo = mem::zeroed();
1319 let info_size = mem::size_of::<ProcTaskInfo>() as i32;
1320 let ret = proc_pidinfo(
1321 pid,
1322 PROC_PIDTASKINFO,
1323 0,
1324 &mut task_info as *mut _ as *mut u8,
1325 info_size,
1326 );
1327
1328 let memory_bytes = if ret == info_size {
1329 Some(task_info.pti_resident_size)
1330 } else {
1331 None
1332 };
1333
1334 children.push(ChildProcessInfo {
1335 pid: pid as u32,
1336 ppid: parent_pid,
1337 name,
1338 memory_bytes,
1339 });
1340 }
1341 }
1342
1343 children
1344}
1345
1346#[derive(Default)]
1354pub(crate) struct PageLoads {
1355 last_load: std::sync::Mutex<HashMap<String, PageLoad>>,
1356 seq: std::sync::atomic::AtomicU64,
1357 changed: tokio::sync::Notify,
1358}
1359
1360#[derive(Debug, Clone, PartialEq, Eq)]
1362pub(crate) struct PageLoad {
1363 pub seq: u64,
1366 pub nonce: Option<String>,
1368}
1369
1370const MAX_PAGE_NONCE_LEN: usize = 128;
1372
1373const MAX_PAGE_LOAD_WINDOWS: usize = 256;
1376
1377impl PageLoads {
1378 pub fn record_load(&self, label: &str, nonce: Option<&str>) {
1380 let nonce = nonce.map(|n| n.chars().take(MAX_PAGE_NONCE_LEN).collect());
1381 {
1382 let mut loads = self
1383 .last_load
1384 .lock()
1385 .unwrap_or_else(std::sync::PoisonError::into_inner);
1386 let seq = self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1388 if !loads.contains_key(label) && loads.len() >= MAX_PAGE_LOAD_WINDOWS {
1389 let oldest = loads
1390 .iter()
1391 .min_by_key(|(_, load)| load.seq)
1392 .map(|(k, _)| k.clone());
1393 if let Some(oldest) = oldest {
1394 loads.remove(&oldest);
1395 }
1396 }
1397 loads.insert(label.to_string(), PageLoad { seq, nonce });
1398 }
1399 self.changed.notify_waiters();
1400 }
1401
1402 #[must_use]
1404 pub fn latest(&self, label: &str) -> Option<PageLoad> {
1405 self.last_load
1406 .lock()
1407 .unwrap_or_else(std::sync::PoisonError::into_inner)
1408 .get(label)
1409 .cloned()
1410 }
1411
1412 #[must_use]
1414 pub fn current_seq(&self) -> u64 {
1415 self.seq.load(std::sync::atomic::Ordering::Relaxed)
1416 }
1417
1418 pub fn changed(&self) -> tokio::sync::futures::Notified<'_> {
1420 self.changed.notified()
1421 }
1422}
1423#[derive(Default)]
1437pub(crate) struct DrainWatermarks {
1438 inner: std::sync::Mutex<WatermarkState>,
1439}
1440
1441#[derive(Default)]
1442struct WatermarkState {
1443 floor_ms: f64,
1444 epoch: u64,
1445 per_label: HashMap<String, DrainMark>,
1446 locks: HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>,
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Eq)]
1452pub(crate) struct DrainMark {
1453 pub instance: String,
1455 pub seq: u64,
1457}
1458
1459#[derive(Debug, Clone)]
1461pub(crate) struct DrainCursor {
1462 pub epoch: u64,
1464 pub floor_ms: f64,
1466 pub mark: Option<DrainMark>,
1468}
1469
1470impl DrainWatermarks {
1471 fn state(&self) -> std::sync::MutexGuard<'_, WatermarkState> {
1472 self.inner
1473 .lock()
1474 .unwrap_or_else(std::sync::PoisonError::into_inner)
1475 }
1476
1477 #[must_use]
1479 pub fn cursor(&self, label: &str) -> DrainCursor {
1480 let s = self.state();
1481 DrainCursor {
1482 epoch: s.epoch,
1483 floor_ms: s.floor_ms,
1484 mark: s.per_label.get(label).cloned(),
1485 }
1486 }
1487
1488 pub fn advance(&self, label: &str, epoch: u64, mark: DrainMark) -> bool {
1492 let mut s = self.state();
1493 if s.epoch != epoch {
1494 return false;
1495 }
1496 s.per_label.insert(label.to_string(), mark);
1497 true
1498 }
1499
1500 pub fn reset(&self, floor_ms: f64, epoch: u64) {
1505 let mut s = self.state();
1506 if epoch <= s.epoch {
1507 return;
1508 }
1509 s.floor_ms = floor_ms;
1510 s.epoch = epoch;
1511 s.per_label.clear();
1512 }
1513
1514 pub fn retain(&self, labels: &[String]) {
1516 let mut s = self.state();
1517 s.per_label.retain(|l, _| labels.contains(l));
1518 s.locks.retain(|l, _| labels.contains(l));
1519 }
1520
1521 #[must_use]
1523 pub fn lock_for(&self, label: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
1524 std::sync::Arc::clone(self.state().locks.entry(label.to_string()).or_default())
1525 }
1526}
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530
1531 #[test]
1533 fn fault_registry_evicts_expired_rules_and_is_bounded() {
1534 let reg = FaultRegistry::new();
1535 let mut expired = FaultConfig::new("old", FaultType::Drop, 0);
1536 expired.created_at = Instant::now()
1537 .checked_sub(FAULT_TTL + Duration::from_secs(1))
1538 .expect("clock far enough from boot");
1539 reg.inject(expired);
1540 reg.inject(FaultConfig::new("fresh", FaultType::Drop, 0));
1541 let listed: Vec<String> = reg.list().into_iter().map(|f| f.command).collect();
1542 assert_eq!(
1543 listed,
1544 vec!["fresh".to_string()],
1545 "expired rule still listed"
1546 );
1547 assert_eq!(
1548 reg.inner.read().unwrap().map.len(),
1549 1,
1550 "expired rule still stored"
1551 );
1552
1553 for i in 0..300 {
1554 reg.inject(FaultConfig::new(format!("cmd-{i}"), FaultType::Drop, 0));
1555 }
1556 let stored = reg.inner.read().unwrap().map.len();
1557 assert_eq!(stored, 256, "fault rules must be capped");
1558 assert!(reg.check_and_trigger("cmd-299").is_some());
1560 assert!(reg.check_and_trigger("fresh").is_none());
1561 reg.inject(FaultConfig::new("cmd-299", FaultType::Drop, 5));
1563 assert_eq!(reg.inner.read().unwrap().map.len(), 256);
1564 }
1565
1566 #[test]
1569 fn contract_store_is_bounded_evicting_the_oldest() {
1570 let store = ContractStore::new();
1571 let baseline =
1572 |c: &str| ContractBaseline::new(c, serde_json::Value::Null, JsonShape::Null, "", "t");
1573 for i in 0..1030 {
1574 store.record(baseline(&format!("cmd-{i}")));
1575 }
1576 assert_eq!(store.all().len(), 1024);
1577 assert!(
1578 store.get("cmd-0").is_none(),
1579 "the oldest baseline goes first"
1580 );
1581 assert!(store.get("cmd-5").is_none());
1582 assert!(store.get("cmd-6").is_some());
1583 assert!(store.get("cmd-1029").is_some());
1584 store.record(baseline("cmd-6"));
1586 assert_eq!(store.all().len(), 1024);
1587 assert!(store.get("cmd-7").is_some());
1588 store.record(baseline("cmd-new"));
1590 assert!(store.get("cmd-6").is_some());
1591 assert!(store.get("cmd-7").is_none());
1592 }
1593
1594 #[test]
1596 fn page_loads_are_bounded_keeping_the_most_recent_windows() {
1597 let loads = PageLoads::default();
1598 for i in 0..300 {
1599 loads.record_load(&format!("win-{i}"), Some("n"));
1600 }
1601 assert_eq!(loads.last_load.lock().unwrap().len(), 256);
1602 assert!(loads.latest("win-299").is_some());
1603 assert!(
1604 loads.latest("win-0").is_none(),
1605 "the least recently loaded window goes first"
1606 );
1607 loads.record_load("win-44", Some("n2"));
1609 loads.record_load("win-new", None);
1610 assert!(loads.latest("win-44").is_some());
1611 assert!(loads.latest("win-45").is_none());
1612 }
1613
1614 #[test]
1618 fn drain_watermark_reset_is_monotonic() {
1619 let w = DrainWatermarks::default();
1620 w.reset(200.0, 2);
1621 let mark = DrainMark {
1622 instance: "page-a".to_string(),
1623 seq: 7,
1624 };
1625 assert!(w.advance("main", 2, mark.clone()));
1626 w.reset(100.0, 1);
1628 let c = w.cursor("main");
1629 assert_eq!(
1630 c.epoch, 2,
1631 "a stale reset must not move the epoch backwards"
1632 );
1633 assert!((c.floor_ms - 200.0).abs() < f64::EPSILON);
1634 assert_eq!(c.mark, Some(mark));
1635 assert!(w.advance(
1637 "main",
1638 2,
1639 DrainMark {
1640 instance: "page-a".to_string(),
1641 seq: 9,
1642 }
1643 ));
1644 w.reset(300.0, 3);
1646 let c = w.cursor("main");
1647 assert_eq!((c.epoch, c.mark), (3, None));
1648 }
1649
1650 #[test]
1652 fn command_timings_bound_distinct_commands() {
1653 let t = CommandTimings::new();
1654 for i in 0..(MAX_TIMED_COMMANDS + 100) {
1655 t.record(&format!("cmd-{i}"), Duration::from_millis(1));
1656 }
1657 assert_eq!(t.all_stats().len(), MAX_TIMED_COMMANDS);
1658 t.record("cmd-0", Duration::from_millis(1));
1660 assert_eq!(t.stats_for("cmd-0").unwrap().count, 2);
1661 }
1662
1663 #[test]
1664 fn p95_is_nearest_rank_not_max_for_small_samples() {
1665 let t = CommandTimings::new();
1666 for ms in 1..=20 {
1667 t.record("cmd", Duration::from_millis(ms));
1668 }
1669 let s = t.stats_for("cmd").unwrap();
1670 assert!(
1671 (s.p95_ms - 19.0).abs() < 1e-9,
1672 "p95 of 1..=20 is 19, got {}",
1673 s.p95_ms
1674 );
1675 assert!((s.max_ms - 20.0).abs() < 1e-9);
1676 }
1677
1678 #[test]
1679 fn app_state_probes_register_run_list() {
1680 let probes = AppStateProbes::default();
1681 assert!(probes.is_empty());
1682
1683 probes.register(
1684 "scoring",
1685 std::sync::Arc::new(|| serde_json::json!({ "pipeline_version": 5 })),
1686 );
1687 probes.register("queue", std::sync::Arc::new(|| serde_json::json!(0)));
1688
1689 assert_eq!(
1691 probes.names(),
1692 vec!["queue".to_string(), "scoring".to_string()]
1693 );
1694 assert_eq!(probes.len(), 2);
1695
1696 let snapshot = probes.run("scoring").expect("probe runs");
1697 assert_eq!(snapshot["pipeline_version"], 5);
1698 assert!(probes.run("missing").is_none());
1699 }
1700
1701 #[test]
1702 fn app_state_probe_reflects_live_state() {
1703 let counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
1706 let probe_counter = std::sync::Arc::clone(&counter);
1707 let probes = AppStateProbes::default();
1708 probes.register(
1709 "counter",
1710 std::sync::Arc::new(move || {
1711 serde_json::json!(probe_counter.load(std::sync::atomic::Ordering::SeqCst))
1712 }),
1713 );
1714
1715 assert_eq!(probes.run("counter").unwrap(), serde_json::json!(0));
1716 counter.store(42, std::sync::atomic::Ordering::SeqCst);
1717 assert_eq!(probes.run("counter").unwrap(), serde_json::json!(42));
1718 }
1719
1720 #[test]
1721 fn event_bus_push_and_read() {
1722 let bus = EventBusMonitor::new(3);
1723 assert!(bus.is_empty());
1724 bus.push(CapturedTauriEvent {
1725 name: "test".to_string(),
1726 payload: "{}".to_string(),
1727 timestamp: "2026-01-01T00:00:00Z".to_string(),
1728 });
1729 assert_eq!(bus.len(), 1);
1730 assert_eq!(bus.events()[0].name, "test");
1731 }
1732
1733 #[test]
1734 fn event_bus_ring_buffer_eviction() {
1735 let bus = EventBusMonitor::new(2);
1736 for i in 0..5 {
1737 bus.push(CapturedTauriEvent {
1738 name: format!("event_{i}"),
1739 payload: String::new(),
1740 timestamp: String::new(),
1741 });
1742 }
1743 assert_eq!(bus.len(), 2);
1744 assert_eq!(bus.events()[0].name, "event_3");
1745 assert_eq!(bus.events()[1].name, "event_4");
1746 }
1747
1748 #[test]
1749 fn event_bus_clear() {
1750 let bus = EventBusMonitor::new(10);
1751 bus.push(CapturedTauriEvent {
1752 name: "a".to_string(),
1753 payload: String::new(),
1754 timestamp: String::new(),
1755 });
1756 assert_eq!(bus.clear(), 1);
1757 assert!(bus.is_empty());
1758 }
1759
1760 #[test]
1763 fn a_panicking_tracked_task_is_reported_finished() {
1764 let tracker = std::sync::Arc::new(TaskTracker::new());
1765 let guard = tracker.track_guarded("event_drain_loop");
1766 let r = std::thread::spawn(move || {
1767 let _finished = guard;
1768 panic!("task blew up");
1769 })
1770 .join();
1771 assert!(r.is_err());
1772 assert_eq!(
1773 tracker.active_count(),
1774 0,
1775 "a panicked task is still reported active"
1776 );
1777 assert!(tracker.list()[0].is_finished);
1778
1779 let running = tracker.track_guarded("mcp_server");
1781 assert_eq!(tracker.active_count(), 1);
1782 drop(running);
1783 assert_eq!(tracker.active_count(), 0);
1784 }
1785
1786 #[test]
1787 fn task_tracker_lifecycle() {
1788 let tracker = TaskTracker::new();
1789 let flag = tracker.track("mcp_server");
1790 let tasks = tracker.list();
1791 assert_eq!(tasks.len(), 1);
1792 assert_eq!(tasks[0].name, "mcp_server");
1793 assert!(!tasks[0].is_finished);
1794 assert_eq!(tracker.active_count(), 1);
1795
1796 flag.store(true, std::sync::atomic::Ordering::Relaxed);
1797 let tasks = tracker.list();
1798 assert!(tasks[0].is_finished);
1799 assert_eq!(tracker.active_count(), 0);
1800 }
1801
1802 #[test]
1803 fn timing_samples_basic() {
1804 let mut samples = TimingSamples::default();
1805 samples.record(Duration::from_millis(10));
1806 samples.record(Duration::from_millis(20));
1807 samples.record(Duration::from_millis(30));
1808 let stats = samples.stats("test_cmd");
1809 assert_eq!(stats.count, 3);
1810 assert!((stats.min_ms - 10.0).abs() < 1.0);
1811 assert!((stats.max_ms - 30.0).abs() < 1.0);
1812 assert!((stats.avg_ms - 20.0).abs() < 1.0);
1813 }
1814
1815 #[test]
1816 fn timing_samples_empty() {
1817 let samples = TimingSamples::default();
1818 let stats = samples.stats("empty");
1819 assert_eq!(stats.count, 0);
1820 assert_eq!(stats.min_ms, 0.0);
1821 }
1822
1823 #[test]
1824 fn timing_samples_bounded_but_count_accurate() {
1825 let mut samples = TimingSamples::default();
1831 let n = MAX_TIMING_SAMPLES * 3;
1832 for i in 0..n {
1833 samples.record(Duration::from_millis((i + 1) as u64));
1836 }
1837 assert!(
1838 samples.recent.len() <= MAX_TIMING_SAMPLES,
1839 "recent window must stay bounded, got {}",
1840 samples.recent.len()
1841 );
1842 let stats = samples.stats("soak");
1843 assert_eq!(stats.count, n as u64, "count must reflect full history");
1844 assert!(
1845 (stats.min_ms - 1.0).abs() < 0.5,
1846 "all-time min lost: {}",
1847 stats.min_ms
1848 );
1849 assert!(
1850 (stats.max_ms - n as f64).abs() < 0.5,
1851 "all-time max lost: {}",
1852 stats.max_ms
1853 );
1854 }
1855
1856 #[test]
1857 fn command_timings_thread_safe() {
1858 let timings = CommandTimings::new();
1859 timings.record("cmd_a", Duration::from_millis(5));
1860 timings.record("cmd_a", Duration::from_millis(15));
1861 timings.record("cmd_b", Duration::from_millis(100));
1862
1863 let all = timings.all_stats();
1864 assert_eq!(all.len(), 2);
1865 assert_eq!(all[0].command, "cmd_b");
1866
1867 let a = timings.stats_for("cmd_a").unwrap();
1868 assert_eq!(a.count, 2);
1869 }
1870
1871 #[test]
1872 fn fault_registry_lifecycle() {
1873 let registry = FaultRegistry::new();
1874 registry.inject(FaultConfig {
1875 command: "slow_cmd".to_string(),
1876 fault_type: FaultType::Delay { delay_ms: 500 },
1877 trigger_count: 0,
1878 max_triggers: 2,
1879 created_at: Instant::now(),
1880 });
1881
1882 assert!(registry.check_and_trigger("slow_cmd").is_some());
1883 assert!(registry.check_and_trigger("slow_cmd").is_some());
1884 assert!(registry.check_and_trigger("slow_cmd").is_none());
1885
1886 assert_eq!(registry.list().len(), 1);
1887 assert!(registry.clear("slow_cmd"));
1888 assert_eq!(registry.list().len(), 0);
1889 }
1890
1891 #[test]
1892 fn fault_registry_unlimited() {
1893 let registry = FaultRegistry::new();
1894 registry.inject(FaultConfig {
1895 command: "always_fail".to_string(),
1896 fault_type: FaultType::Error {
1897 message: "injected".to_string(),
1898 },
1899 trigger_count: 0,
1900 max_triggers: 0,
1901 created_at: Instant::now(),
1902 });
1903
1904 for _ in 0..100 {
1905 assert!(registry.check_and_trigger("always_fail").is_some());
1906 }
1907 }
1908
1909 #[test]
1910 fn fault_expires_after_ttl() {
1911 let cfg = FaultConfig {
1912 command: "x".to_string(),
1913 fault_type: FaultType::Error {
1914 message: "e".to_string(),
1915 },
1916 trigger_count: 0,
1917 max_triggers: 0, created_at: Instant::now(),
1919 };
1920 assert!(cfg.should_trigger_at(cfg.created_at));
1922 assert!(!cfg.should_trigger_at(cfg.created_at + FAULT_TTL + Duration::from_secs(1)));
1923 }
1924
1925 #[test]
1926 fn json_shape_extraction() {
1927 let value = serde_json::json!({
1928 "name": "test",
1929 "count": 42,
1930 "active": true,
1931 "items": [{"id": 1}],
1932 "meta": null
1933 });
1934 let shape = JsonShape::from_value(&value);
1935 match &shape {
1936 JsonShape::Object(fields) => {
1937 assert_eq!(fields.len(), 5);
1938 assert_eq!(*fields.get("name").unwrap(), JsonShape::String);
1939 assert_eq!(*fields.get("count").unwrap(), JsonShape::Number);
1940 assert_eq!(*fields.get("active").unwrap(), JsonShape::Bool);
1941 assert_eq!(*fields.get("meta").unwrap(), JsonShape::Null);
1942 }
1943 _ => panic!("expected object"),
1944 }
1945 }
1946
1947 #[test]
1948 fn contract_diff_detects_changes() {
1949 let baseline = serde_json::json!({"name": "old", "count": 1});
1950 let current = serde_json::json!({"name": "new", "count": "not_a_number", "extra": true});
1951
1952 let b_shape = JsonShape::from_value(&baseline);
1953 let c_shape = JsonShape::from_value(¤t);
1954 let drift = diff_shapes(&b_shape, &c_shape, "test_cmd");
1955
1956 assert!(!drift.shape_matches);
1957 assert_eq!(drift.new_fields, vec!["test_cmd.extra"]);
1958 assert_eq!(drift.type_changes.len(), 1);
1959 assert_eq!(drift.type_changes[0].path, "test_cmd.count");
1960 }
1961
1962 #[test]
1963 fn contract_store_crud() {
1964 let store = ContractStore::new();
1965 let baseline = ContractBaseline {
1966 command: "get_user".to_string(),
1967 args: serde_json::json!({}),
1968 shape: JsonShape::Object(HashMap::new()),
1969 sample: "{}".to_string(),
1970 recorded_at: "2026-05-26".to_string(),
1971 };
1972 store.record(baseline);
1973 assert!(store.get("get_user").is_some());
1974 assert_eq!(store.all().len(), 1);
1975 assert_eq!(store.clear(), 1);
1976 assert!(store.get("get_user").is_none());
1977 }
1978
1979 #[test]
1980 fn startup_timeline_records_phases() {
1981 let timeline = StartupTimeline::new();
1982 std::thread::sleep(Duration::from_millis(5));
1983 timeline.mark("phase_1");
1984 std::thread::sleep(Duration::from_millis(5));
1985 timeline.mark("phase_2");
1986
1987 let report = timeline.report();
1988 assert_eq!(report.len(), 2);
1989 assert_eq!(report[0].name, "phase_1");
1990 assert!(report[1].cumulative_ms >= report[0].cumulative_ms);
1991 assert!(timeline.total_ms() > 0.0);
1992 }
1993
1994 #[test]
1995 fn enumerate_child_processes_returns_vec() {
1996 let children = enumerate_child_processes();
1997 for child in &children {
2000 assert_ne!(child.pid, 0, "child PID should be non-zero");
2001 assert_eq!(
2002 child.ppid,
2003 std::process::id(),
2004 "parent PID should match current process"
2005 );
2006 assert!(!child.name.is_empty(), "child name should not be empty");
2007 }
2008 }
2009
2010 #[test]
2011 fn enumerate_child_processes_with_spawned_child() {
2012 let child = std::process::Command::new(if cfg!(windows) { "cmd.exe" } else { "sleep" })
2014 .args(if cfg!(windows) {
2015 &["/c", "timeout /t 10 /nobreak >nul"][..]
2016 } else {
2017 &["10"][..]
2018 })
2019 .spawn();
2020
2021 if let Ok(mut child_proc) = child {
2022 let children = enumerate_child_processes();
2023 assert!(
2024 !children.is_empty(),
2025 "should find at least one child process"
2026 );
2027
2028 let found = children.iter().any(|c| c.pid == child_proc.id());
2029 assert!(
2030 found,
2031 "spawned child (PID {}) should appear in enumeration",
2032 child_proc.id()
2033 );
2034
2035 let _ = child_proc.kill();
2036 let _ = child_proc.wait();
2037 }
2038 }
2039
2040 #[test]
2041 fn child_process_info_serializes() {
2042 let info = ChildProcessInfo {
2043 pid: 1234,
2044 ppid: 5678,
2045 name: "test-sidecar".to_string(),
2046 memory_bytes: Some(1_048_576),
2047 };
2048 let json = serde_json::to_value(&info).unwrap();
2049 assert_eq!(json["pid"], 1234);
2050 assert_eq!(json["ppid"], 5678);
2051 assert_eq!(json["name"], "test-sidecar");
2052 assert_eq!(json["memory_bytes"], 1_048_576);
2053 }
2054
2055 #[test]
2056 fn child_process_info_serializes_no_memory() {
2057 let info = ChildProcessInfo {
2058 pid: 42,
2059 ppid: 1,
2060 name: "zombie".to_string(),
2061 memory_bytes: None,
2062 };
2063 let json = serde_json::to_value(&info).unwrap();
2064 assert!(json["memory_bytes"].is_null());
2065 }
2066}