1use serde::{Serialize, Serializer};
2use std::time::Duration;
3
4#[derive(Debug, Clone)]
5pub struct TraceConfig {
6 pub enabled: bool,
7 pub max_events: Option<usize>,
8 pub max_bytes: Option<usize>,
9 pub sampling_rate: f64,
10}
11
12impl Default for TraceConfig {
13 fn default() -> Self {
14 Self {
15 enabled: false,
16 max_events: None,
17 max_bytes: None,
18 sampling_rate: 1.0,
19 }
20 }
21}
22
23#[derive(Debug, Clone)]
24pub enum TraceEvent {
25 TaskPolled {
26 task_id: usize,
27 virtual_time_ms: u64,
28 },
29 TimerFired {
30 deadline_ms: u64,
31 count: usize,
32 },
33}
34
35#[derive(Debug, Clone, Default, Serialize)]
36pub struct TraceMetadata {
37 pub events_captured: usize,
38 pub events_dropped: usize,
39 pub truncated: bool,
40}
41
42#[derive(Clone)]
43pub struct TraceBuffer {
44 pub(crate) config: TraceConfig,
45 events: Vec<TraceEvent>,
46 total_attempted: usize,
47 sample_counter: usize,
48}
49
50impl TraceBuffer {
51 pub fn new(config: TraceConfig) -> Self {
52 Self {
53 config,
54 events: Vec::new(),
55 total_attempted: 0,
56 sample_counter: 0,
57 }
58 }
59
60 pub fn record(&mut self, event: TraceEvent) {
61 if !self.config.enabled {
62 return;
63 }
64 self.total_attempted += 1;
65
66 if self.config.sampling_rate < 1.0 {
67 self.sample_counter += 1;
68 let period = (1.0 / self.config.sampling_rate).ceil() as usize;
69 if self.sample_counter % period != 0 {
70 return;
71 }
72 }
73
74 if let Some(max) = self.config.max_events {
75 if self.events.len() >= max {
76 return;
77 }
78 }
79
80 self.events.push(event);
81 }
82
83 pub fn events(&self) -> &[TraceEvent] {
84 &self.events
85 }
86
87 pub fn metadata(&self) -> TraceMetadata {
88 let cap = self.config.max_events;
89 let truncated = cap.map(|m| self.total_attempted > m).unwrap_or(false);
90 TraceMetadata {
91 events_captured: self.events.len(),
92 events_dropped: self.total_attempted.saturating_sub(self.events.len()),
93 truncated,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct ReproBundle {
100 pub seed: u64,
101 #[serde(rename = "virtual_time_ms", serialize_with = "duration_as_millis")]
102 pub virtual_time: Duration,
103 pub steps: u64,
104 pub error: String,
105 pub decision_log: Vec<usize>,
106 #[serde(flatten)]
107 pub trace_metadata: TraceMetadata,
108}
109
110fn duration_as_millis<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
111 s.serialize_u64(d.as_millis() as u64)
112}
113
114impl ReproBundle {
115 pub fn catalog_name(&self) -> String {
116 let err_slug = self
117 .error
118 .chars()
119 .take(20)
120 .map(|c| if c.is_alphanumeric() { c } else { '-' })
121 .collect::<String>()
122 .to_lowercase();
123 format!("failure-{}-{}.json", err_slug, self.seed)
124 }
125
126 pub fn to_json(&self) -> std::io::Result<String> {
127 serde_json::to_string(self).map_err(std::io::Error::other)
128 }
129
130 pub fn write_to_file(&self) -> std::io::Result<std::path::PathBuf> {
131 let base_dir = std::env::var("CARGO_TARGET_DIR")
132 .ok()
133 .map(std::path::PathBuf::from)
134 .unwrap_or_else(|| std::path::PathBuf::from("."));
135
136 let repro_dir = base_dir.join("sim-repro");
137 let catalog_dir = base_dir.join("sim-catalog");
138
139 std::fs::create_dir_all(&repro_dir)?;
140 std::fs::create_dir_all(&catalog_dir)?;
141
142 let json = self.to_json()?;
143
144 let repro_path = repro_dir.join("repro_bundle.json");
145 std::fs::write(&repro_path, &json)?;
146
147 let catalog_path = catalog_dir.join(self.catalog_name());
148 if !catalog_path.exists() {
149 std::fs::write(&catalog_path, &json)?;
150 }
151
152 Ok(repro_path)
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::{ReproBundle, TraceBuffer, TraceConfig, TraceEvent, TraceMetadata};
159 use std::time::Duration;
160
161 #[test]
162 fn max_events_truncates_trace_metadata() {
163 let mut trace = TraceBuffer::new(TraceConfig {
164 enabled: true,
165 max_events: Some(1),
166 max_bytes: None,
167 sampling_rate: 1.0,
168 });
169
170 trace.record(TraceEvent::TaskPolled {
171 task_id: 1,
172 virtual_time_ms: 0,
173 });
174 trace.record(TraceEvent::TaskPolled {
175 task_id: 2,
176 virtual_time_ms: 1,
177 });
178
179 let metadata = trace.metadata();
180 assert_eq!(metadata.events_captured, 1);
181 assert_eq!(metadata.events_dropped, 1);
182 assert!(metadata.truncated);
183 }
184
185 #[test]
186 fn repro_bundle_json_is_valid_and_complete() {
187 let bundle = ReproBundle {
188 seed: 42,
189 virtual_time: Duration::from_millis(100),
190 steps: 5,
191 error: "invariant violated: x > 0".to_string(),
192 decision_log: vec![1, 2, 3],
193 trace_metadata: TraceMetadata {
194 events_captured: 10,
195 events_dropped: 2,
196 truncated: true,
197 },
198 };
199
200 let json = bundle.to_json().expect("serialize");
201 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
202
203 assert_eq!(v["seed"], 42);
204 assert_eq!(v["virtual_time_ms"], 100);
205 assert_eq!(v["steps"], 5);
206 assert_eq!(v["error"], "invariant violated: x > 0");
207 assert_eq!(v["decision_log"], serde_json::json!([1, 2, 3]));
208 assert_eq!(v["events_captured"], 10);
209 assert_eq!(v["events_dropped"], 2);
210 assert_eq!(v["truncated"], true);
211 }
212
213 #[test]
214 fn repro_bundle_json_escapes_error_string() {
215 let bundle = ReproBundle {
216 seed: 1,
217 virtual_time: Duration::ZERO,
218 steps: 0,
219 error: r#"msg with "quotes" and \backslash"#.to_string(),
220 decision_log: vec![],
221 trace_metadata: TraceMetadata::default(),
222 };
223
224 let json = bundle.to_json().expect("serialize");
225 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
227 assert_eq!(v["error"], bundle.error);
228 }
229}