1use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16pub const REPLAY_FORMAT: u32 = 1;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum FailureKind {
25 RuntimeError,
27 Assertion,
29 Deadlock,
31 TestFailure,
33}
34
35impl FailureKind {
36 pub const ALL: [FailureKind; 4] = [
38 FailureKind::RuntimeError,
39 FailureKind::Assertion,
40 FailureKind::Deadlock,
41 FailureKind::TestFailure,
42 ];
43
44 pub fn classify(message: &str) -> FailureKind {
47 if message.contains("deadlock") {
48 FailureKind::Deadlock
49 } else if message.contains("Assertion failed") {
50 FailureKind::Assertion
51 } else {
52 FailureKind::RuntimeError
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Cursor {
62 pub epoch: u64,
63 pub task: u64,
64 pub seq: u64,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SourceSection {
71 pub hash: String,
72 pub entry: String,
73 pub files: BTreeMap<String, String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct TraceChoice {
79 pub kind: String,
80 pub options: u32,
81 pub chosen: u32,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Schedule {
89 pub seed: u64,
90 pub trace: Vec<TraceChoice>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct StdinRead {
96 pub cursor: Cursor,
97 pub data_b64: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Transcript {
103 pub args: Vec<String>,
104 pub stdin: Vec<StdinRead>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct FailureSpan {
110 pub file: String,
111 pub start: usize,
112 pub end: usize,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Failure {
118 pub kind: FailureKind,
119 pub message: String,
120 pub cursor: Cursor,
121 pub span: Option<FailureSpan>,
122 pub test: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct ReplayBundle {
128 pub format: u32,
129 pub logos_version: String,
130 pub source: SourceSection,
131 pub schedule: Schedule,
132 pub generation: Vec<u64>,
133 pub transcript: Transcript,
134 pub failure: Failure,
135}
136
137fn fnv1a64(bytes: &[u8], mut state: u64) -> u64 {
141 const PRIME: u64 = 0x0000_0100_0000_01B3;
142 for b in bytes {
143 state ^= u64::from(*b);
144 state = state.wrapping_mul(PRIME);
145 }
146 state
147}
148
149impl ReplayBundle {
150 pub fn to_json(&self) -> String {
152 serde_json::to_string_pretty(self).expect("a replay bundle always serializes")
153 }
154
155 pub fn from_json(json: &str) -> Result<ReplayBundle, String> {
158 let value: serde_json::Value =
159 serde_json::from_str(json).map_err(|e| format!("unreadable .replay bundle: {e}"))?;
160 let format = value
161 .get("format")
162 .and_then(serde_json::Value::as_u64)
163 .ok_or_else(|| "unreadable .replay bundle: no format field".to_string())?;
164 if format != u64::from(REPLAY_FORMAT) {
165 return Err(format!(
166 "unsupported .replay format {format} — this build reads format {REPLAY_FORMAT}"
167 ));
168 }
169 serde_json::from_value(value).map_err(|e| format!("malformed .replay bundle: {e}"))
170 }
171
172 pub fn source_hash(files: &BTreeMap<String, String>) -> String {
176 const OFFSET: u64 = 0xCBF2_9CE4_8422_2325;
177 let mut state = OFFSET;
178 for (path, content) in files {
179 state = fnv1a64(&(path.len() as u64).to_le_bytes(), state);
180 state = fnv1a64(path.as_bytes(), state);
181 state = fnv1a64(&(content.len() as u64).to_le_bytes(), state);
182 state = fnv1a64(content.as_bytes(), state);
183 }
184 format!("fnv1a64:{state:016x}")
185 }
186}
187
188fn run_traced(source: &str, seed: u64) -> (Option<String>, Vec<TraceChoice>) {
193 let (result, trace) = crate::ui_bridge::run_vm_concurrent_traced(source, seed);
194 let choices = trace
195 .choices
196 .iter()
197 .map(|cp| TraceChoice {
198 kind: format!("{:?}", cp.kind),
199 options: cp.options as u32,
200 chosen: cp.chosen as u32,
201 })
202 .collect();
203 (result.error, choices)
204}
205
206pub fn capture_failure(
210 entry: &str,
211 source: &str,
212 seed: u64,
213 args: &[String],
214) -> Option<ReplayBundle> {
215 let (error, trace) = run_traced(source, seed);
216 let message = error?;
217 let kind = FailureKind::classify(&message);
218 let epoch = trace.len() as u64;
219 let mut files = BTreeMap::new();
220 files.insert(entry.to_string(), source.to_string());
221 let hash = ReplayBundle::source_hash(&files);
222 Some(ReplayBundle {
223 format: REPLAY_FORMAT,
224 logos_version: env!("CARGO_PKG_VERSION").to_string(),
225 source: SourceSection {
226 hash,
227 entry: entry.to_string(),
228 files,
229 },
230 schedule: Schedule { seed, trace },
231 generation: Vec::new(),
232 transcript: Transcript {
233 args: args.to_vec(),
234 stdin: Vec::new(),
235 },
236 failure: Failure {
237 kind,
238 message,
239 cursor: Cursor {
240 epoch,
241 task: 0,
242 seq: 0,
243 },
244 span: None,
245 test: None,
246 },
247 })
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum Verify {
253 Reproduced,
255 Diverged(String),
258 NoFailure,
260}
261
262pub fn verify(bundle: &ReplayBundle) -> Verify {
266 let recomputed = ReplayBundle::source_hash(&bundle.source.files);
267 if recomputed != bundle.source.hash {
268 return Verify::Diverged(format!(
269 "source hash mismatch: recorded {} but the embedded files hash to {recomputed} \
270 — the bundle was edited since recording",
271 bundle.source.hash
272 ));
273 }
274 let Some(source) = bundle.source.files.get(&bundle.source.entry) else {
275 return Verify::Diverged(format!(
276 "the entry file {} is missing from the bundle",
277 bundle.source.entry
278 ));
279 };
280 let (error, live_trace) = run_traced(source, bundle.schedule.seed);
281 let Some(message) = error else {
282 return Verify::NoFailure;
283 };
284 if live_trace != bundle.schedule.trace {
285 let at = live_trace
286 .iter()
287 .zip(bundle.schedule.trace.iter())
288 .position(|(live, recorded)| live != recorded)
289 .unwrap_or_else(|| live_trace.len().min(bundle.schedule.trace.len()));
290 return Verify::Diverged(format!(
291 "schedule diverged at decision {at}: recorded {} decisions, live run made {}",
292 bundle.schedule.trace.len(),
293 live_trace.len()
294 ));
295 }
296 if message != bundle.failure.message {
297 return Verify::Diverged(format!(
298 "failure message changed: recorded {:?}, live {message:?}",
299 bundle.failure.message
300 ));
301 }
302 let live_kind = FailureKind::classify(&message);
303 if live_kind != bundle.failure.kind {
304 return Verify::Diverged(format!(
305 "failure kind changed: recorded {:?}, live {live_kind:?}",
306 bundle.failure.kind
307 ));
308 }
309 let live_epoch = live_trace.len() as u64;
310 if live_epoch != bundle.failure.cursor.epoch {
311 return Verify::Diverged(format!(
312 "failure cursor moved: recorded epoch {}, live epoch {live_epoch}",
313 bundle.failure.cursor.epoch
314 ));
315 }
316 Verify::Reproduced
317}