1use core::fmt::Display;
12use core::fmt::Formatter;
13use core::fmt::Result;
14use std::cmp::Ordering;
15use std::collections::HashMap;
16use std::io::Write;
17use std::path::Path;
18use std::process::Command;
19use std::str::FromStr;
20use std::sync::LazyLock;
21
22use clap;
23use clap::Parser;
24use regex::Regex;
25use tempfile::NamedTempFile;
26
27use crate::detlog::DetLogEvent;
28use crate::detlog::DetLogRecord;
29
30pub const TRUNCATION_MARKER: &str = "=== HERMIT LOG TRUNCATED: reached the configured size bound \
50 (HERMIT_LOG_MAX_BYTES). Output beyond this point was DISCARDED. The run itself continued and \
51 was NOT affected. ===";
52
53pub const STRIP_WALL_CLOCK_PREFIX_V1: &str = "real-wall-clock-prefix/v1";
55
56pub const CANON_ADDRESS_ORDINAL_V1: &str = "host-address-to-first-appearance-ordinal/v1";
58
59pub fn log_was_truncated(log_text: &str) -> bool {
83 let trimmed = log_text.trim_end_matches(['\n', '\r']);
84 if !trimmed.ends_with(TRUNCATION_MARKER) {
85 return false;
86 }
87 let marker_start = trimmed.len() - TRUNCATION_MARKER.len();
89 marker_start == 0 || trimmed.as_bytes()[marker_start - 1] == b'\n'
90}
91
92#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
94pub enum LogComparisonMode {
95 #[default]
97 Deterministic,
98 Info,
102 FullTrace,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ComparisonSideLabels {
113 pub left: String,
115 pub right: String,
117}
118
119impl ComparisonSideLabels {
120 pub fn new(left: impl Into<String>, right: impl Into<String>) -> Self {
122 Self {
123 left: left.into(),
124 right: right.into(),
125 }
126 }
127}
128
129impl Default for ComparisonSideLabels {
130 fn default() -> Self {
131 Self::new("run 1", "run 2")
132 }
133}
134
135#[derive(Debug, Parser, Clone)]
137pub struct LogDiffOpts {
138 #[clap(long = "unsafe-strip-lines")]
144 pub strip_lines: bool,
145
146 #[clap(long = "canonicalize-host-addresses")]
176 pub canonicalize_addresses: bool,
177
178 #[clap(skip)]
180 pub comparison: LogComparisonMode,
181
182 #[clap(skip)]
185 pub side_labels: ComparisonSideLabels,
186
187 #[clap(skip)]
190 pub require_structured_events: bool,
191
192 #[clap(long)]
197 pub print_logs: bool,
198
199 #[clap(long, default_value = "20")]
201 pub limit: u64,
202
203 #[clap(long)]
205 pub ignore_lines: Vec<String>,
206
207 #[clap(long, default_value = "0")]
210 pub syscall_history: u64,
211 #[clap(long)]
213 pub no_color: bool,
214
215 #[clap(long)]
217 pub skip_commit: bool,
218
219 #[clap(long)]
221 pub skip_detlog: bool,
222
223 #[clap(long)]
225 pub git_diff: bool,
226
227 #[clap(long, default_values = &["syscall", "syscallresult", "other"])]
230 pub include_detlogs: Vec<DetLogFilter>,
231}
232
233impl LogDiffOpts {
234 fn is_skip(&self, filter: DetLogFilter) -> bool {
235 !self.include_detlogs.contains(&filter)
236 }
237
238 fn skip_detlog(&self, entry: &LogMessage<'_>) -> bool {
239 if self.skip_detlog {
240 return true;
241 }
242
243 if is_detlog_syscall(entry) && self.is_skip(DetLogFilter::Syscall) {
244 return true;
245 }
246 if is_detlog_syscall_result(entry) && self.is_skip(DetLogFilter::SyscallResult) {
247 return true;
248 }
249
250 if !is_detlog_syscall(entry)
251 && !is_detlog_syscall_result(entry)
252 && self.is_skip(DetLogFilter::Other)
253 {
254 return true;
255 }
256
257 false
258 }
259
260 fn filter_deterministic<'a>(&self, v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
261 v.iter()
262 .filter_map(|message| {
263 if (is_detlog(message)
264 && !self.skip_detlog(message)
265 && !is_scheduler_committed_time(message))
266 || (is_commit(message)
267 && !self.skip_commit
268 && !is_internal_io_poll_commit(message))
269 {
270 Some(*message)
271 } else {
272 None
273 }
274 })
275 .collect()
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum LogNormalization {
281 Exact,
282 Stripped,
283 Canonical,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292struct LogComparisonPolicy {
293 comparison: LogComparisonMode,
294 normalization: LogNormalization,
295}
296
297impl LogComparisonPolicy {
298 fn from_options(options: &LogDiffOpts) -> Self {
299 let normalization = if options.strip_lines {
300 LogNormalization::Stripped
301 } else if options.canonicalize_addresses {
302 LogNormalization::Canonical
303 } else {
304 LogNormalization::Exact
305 };
306 Self {
307 comparison: options.comparison,
308 normalization,
309 }
310 }
311
312 fn name(self) -> &'static str {
313 match (self.comparison, self.normalization) {
314 (LogComparisonMode::Deterministic, LogNormalization::Exact) => "Deterministic",
315 (LogComparisonMode::Deterministic, LogNormalization::Stripped) => "Stripped",
316 (LogComparisonMode::Deterministic, LogNormalization::Canonical) => {
317 "Deterministic with Canonical host-address normalization"
318 }
319 (LogComparisonMode::Info, LogNormalization::Exact) => "Info",
320 (LogComparisonMode::Info, LogNormalization::Stripped) => {
321 "Info with Stripped normalization"
322 }
323 (LogComparisonMode::Info, LogNormalization::Canonical) => "Canonical",
324 (LogComparisonMode::FullTrace, LogNormalization::Exact) => "FullTrace",
325 (LogComparisonMode::FullTrace, LogNormalization::Stripped) => {
326 "FullTrace with Stripped normalization"
327 }
328 (LogComparisonMode::FullTrace, LogNormalization::Canonical) => {
329 "FullTrace with Canonical host-address normalization"
330 }
331 }
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum DetLogFilter {
338 Syscall,
340 SyscallResult,
342 Other,
344}
345
346impl FromStr for DetLogFilter {
347 type Err = anyhow::Error;
348
349 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
350 match s.to_lowercase().as_str() {
351 "syscall" => Ok(DetLogFilter::Syscall),
352 "syscallresult" => Ok(DetLogFilter::SyscallResult),
353 "other" => Ok(DetLogFilter::Other),
354 _ => Err(anyhow::Error::msg(format!(
355 "unknown value {} for DetLogFilter",
356 s
357 ))),
358 }
359 }
360}
361
362impl Default for LogDiffOpts {
365 fn default() -> Self {
366 let v: Vec<String> = vec![];
367 LogDiffOpts::parse_from(v.iter())
368 }
369}
370
371pub fn strip_log_entry(log: &str) -> String {
388 static RE0: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b0[xX][A-Fa-f0-9]+\b").unwrap());
393
394 static RE1: LazyLock<Regex> =
404 LazyLock::new(|| Regex::new(r"\b[\d][\d_]*(?:\.[\d][\d_]*)?(?:ns|us|µs|ms)?\b").unwrap());
405
406 static RE2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"/tmp/[^"]*""#).unwrap());
413
414 static RE3: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/proc/[\d]+/").unwrap());
416
417 static RE4: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[\d][\d_.]*s\b").unwrap());
420
421 let log = RE4.replace_all(log, "<NANOSECONDS>");
422 let log = RE3.replace_all(&log, "/proc/<PID>/");
423 let log = RE0.replace_all(&log, "<ADDR>");
424 let log = RE1.replace_all(&log, "<NUM>");
425 let log = RE2.replace_all(&log, "/tmp/<somewhere>\"");
426 String::from(log)
427}
428
429pub fn host_addr(addr: usize) -> String {
441 format!("<hostaddr {addr:#x}>")
442}
443
444fn canonicalize_addresses_in_line(
468 line: &str,
469 map: &mut HashMap<String, usize>,
470 next: &mut usize,
471) -> String {
472 static RE_HOSTADDR: LazyLock<Regex> =
475 LazyLock::new(|| Regex::new(r"<hostaddr (0[xX][A-Fa-f0-9]+)>").unwrap());
476
477 RE_HOSTADDR
478 .replace_all(line, |caps: ®ex::Captures| {
479 let addr = &caps[1];
480 let ord = match map.get(addr) {
481 Some(existing) => *existing,
482 None => {
483 let assigned = *next;
484 *next += 1;
485 map.insert(addr.to_string(), assigned);
486 assigned
487 }
488 };
489 format!("<addr{ord}>")
490 })
491 .into_owned()
492}
493
494fn messages_for_comparison(
495 messages: &[LogMessage<'_>],
496 policy: LogComparisonPolicy,
497) -> Vec<String> {
498 match policy.normalization {
499 LogNormalization::Stripped => messages
500 .iter()
501 .map(|message| strip_log_entry(message.text))
502 .collect(),
503 LogNormalization::Canonical => {
504 let mut addresses = HashMap::new();
505 let mut next_address = 1usize;
506 messages
507 .iter()
508 .map(|message| {
509 canonicalize_addresses_in_line(message.text, &mut addresses, &mut next_address)
510 })
511 .collect()
512 }
513 LogNormalization::Exact => messages
514 .iter()
515 .map(|message| message.text.to_owned())
516 .collect(),
517 }
518}
519
520#[cfg(test)]
521fn canonical_info_from_str(contents: &str) -> std::io::Result<Vec<String>> {
522 canonical_info_from_str_with_filter(contents, |_| true)
523}
524
525fn canonical_info_from_str_with_filter(
526 contents: &str,
527 keep_record: impl Fn(&str) -> bool,
528) -> std::io::Result<Vec<String>> {
529 let info = filter_infos(
530 &extract_log_messages(contents)?
531 .into_iter()
532 .filter(|record| keep_record(record.text))
533 .collect::<Vec<_>>(),
534 );
535 let opts = LogDiffOpts {
536 canonicalize_addresses: true,
537 comparison: LogComparisonMode::Info,
538 ..Default::default()
539 };
540 Ok(messages_for_comparison(
541 &info,
542 LogComparisonPolicy::from_options(&opts),
543 ))
544}
545
546pub fn write_canonical_info(file: &Path, writer: &mut impl Write) -> std::io::Result<usize> {
554 write_canonical_info_with_filter(file, writer, |_| true)
555}
556
557pub fn write_bitwise_info_v1_bytes(
565 bytes: &[u8],
566 side_label: &str,
567 writer: &mut impl Write,
568) -> std::io::Result<usize> {
569 let contents = std::str::from_utf8(bytes).map_err(|error| {
570 std::io::Error::new(
571 std::io::ErrorKind::InvalidData,
572 format!("{side_label} is not UTF-8: {error}"),
573 )
574 })?;
575 if log_was_truncated(contents) {
576 return Err(std::io::Error::new(
577 std::io::ErrorKind::InvalidData,
578 format!("{side_label} was truncated at the configured size bound"),
579 ));
580 }
581 let records = extract_log_messages(contents)
582 .map_err(|error| std::io::Error::new(error.kind(), format!("{side_label} {error}")))?;
583 validate_structured_events(side_label, &records, true)?;
584 let info = filter_infos(&records);
585 let options = bitwise_info_v1_options(ComparisonSideLabels::new(side_label, side_label));
586 let messages = messages_for_comparison(&info, LogComparisonPolicy::from_options(&options));
587 for message in &messages {
588 writeln!(writer, "{message}")?;
589 }
590 Ok(messages.len())
591}
592
593pub fn write_canonical_info_with_filter(
601 file: &Path,
602 writer: &mut impl Write,
603 keep_record: impl Fn(&str) -> bool,
604) -> std::io::Result<usize> {
605 let bytes = std::fs::read(file)?;
606 let contents = std::str::from_utf8(&bytes).map_err(|error| {
607 std::io::Error::new(
608 std::io::ErrorKind::InvalidData,
609 format!("{} is not UTF-8: {error}", file.display()),
610 )
611 })?;
612 let messages = canonical_info_from_str_with_filter(contents, keep_record)?;
613 for message in &messages {
614 writeln!(writer, "{message}")?;
615 }
616 Ok(messages.len())
617}
618
619static RECORD_START: LazyLock<Regex> = LazyLock::new(|| {
626 Regex::new(r"((Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d \d\d:\d\d:\d\d\.\d+|\d+-\d\d-\d\dT\d\d:\d\d:\d\d.\d+Z) +")
627 .unwrap()
628});
629
630fn record_starts(contents: &str) -> Vec<usize> {
632 RECORD_START
633 .find_iter(contents)
634 .map(|m| m.start())
635 .collect()
636}
637
638pub fn complete_record_count(contents: &str) -> usize {
646 record_starts(contents).len().saturating_sub(1)
647}
648
649pub fn take_complete_records(contents: &str, n: usize) -> Option<&str> {
655 let starts = record_starts(contents);
656 if n == 0 {
657 return Some(&contents[..0]);
658 }
659 starts.get(n).map(|end| &contents[..*end])
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684struct LogMessage<'a> {
685 index: usize,
686 text: &'a str,
687 event: Option<DetLogEvent>,
688}
689
690fn extract_log_messages(contents: &str) -> std::io::Result<Vec<LogMessage<'_>>> {
691 let ts = &*RECORD_START;
692 let tag = Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE) ").unwrap();
693 ts.split(contents) .enumerate()
695 .map(|(i, s)| (i, s.trim()))
696 .filter(|(_, s)| !s.is_empty())
697 .map(|(i, s)| {
698 if !tag.is_match(s) {
700 return Err(std::io::Error::new(
701 std::io::ErrorKind::InvalidData,
702 format!(
703 "log line {i} has no ERROR/WARN/INFO/DEBUG/TRACE tag, so it cannot be \
704 placed in the compared record stream: {s}"
705 ),
706 ));
707 }
708 let (text, record) = DetLogRecord::split(s).map_err(|error| {
709 std::io::Error::new(
710 std::io::ErrorKind::InvalidData,
711 format!("log record {i} has an invalid structured DETLOG result: {error}"),
712 )
713 })?;
714 Ok(LogMessage {
715 index: i,
716 text,
717 event: record.map(|record| record.event),
718 })
719 })
720 .collect()
721}
722
723fn is_info(message: &LogMessage<'_>) -> bool {
724 message.text.starts_with("INFO ")
725}
726
727fn historical_is_commit(line: &str) -> bool {
728 line.contains(" COMMIT ")
729}
730
731fn historical_is_detlog(line: &str) -> bool {
732 line.contains(" DETLOG ")
733}
734
735fn is_commit(message: &LogMessage<'_>) -> bool {
736 match message.event {
737 Some(DetLogEvent::SchedulerCommit { .. }) => true,
738 Some(_) => false,
739 None => historical_is_commit(message.text),
740 }
741}
742
743fn is_detlog(message: &LogMessage<'_>) -> bool {
744 match message.event {
745 Some(
746 DetLogEvent::Other
747 | DetLogEvent::Syscall
748 | DetLogEvent::SyscallResult { .. }
749 | DetLogEvent::SchedulerCommittedTime,
750 ) => true,
751 Some(_) => false,
752 None => historical_is_detlog(message.text),
753 }
754}
755
756fn is_internal_io_poll_commit(message: &LogMessage<'_>) -> bool {
777 match message.event {
778 Some(DetLogEvent::SchedulerCommit {
779 internal_io_poll, ..
780 }) => internal_io_poll,
781 Some(_) => false,
782 None => {
783 historical_is_commit(message.text)
784 && (message.text.contains("{InternalIOPolling: ")
785 || message.text.contains(" [sabre-internal-pipe-io]")
786 || message.text.contains(" [sabre-loopback-poll-zero-timeout]"))
787 }
788 }
789}
790
791fn is_scheduler_committed_time(message: &LogMessage<'_>) -> bool {
800 match message.event {
801 Some(DetLogEvent::SchedulerCommittedTime) => true,
802 Some(_) => false,
803 None => message.text.contains("advancing committed_time from "),
804 }
805}
806
807fn is_detcore(message: &LogMessage<'_>) -> bool {
808 static PREFIX: LazyLock<Regex> =
809 LazyLock::new(|| Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE).* detcore:").unwrap());
810
811 PREFIX.is_match(message.text)
812}
813
814fn is_detlog_syscall(message: &LogMessage<'_>) -> bool {
815 match message.event {
816 Some(DetLogEvent::Syscall | DetLogEvent::SyscallResult { .. }) => true,
817 Some(_) => false,
818 None => historical_is_detlog(message.text) && message.text.contains("[syscall]"),
819 }
820}
821
822fn is_detlog_syscall_result(message: &LogMessage<'_>) -> bool {
823 match message.event {
824 Some(DetLogEvent::SyscallResult { .. }) => true,
825 Some(_) => false,
826 None => is_detlog_syscall(message) && message.text.contains("finish syscall"),
827 }
828}
829
830fn event_matches_human_record(event: DetLogEvent, text: &str) -> bool {
831 match event {
832 DetLogEvent::Other
833 | DetLogEvent::Syscall
834 | DetLogEvent::SyscallResult { .. }
835 | DetLogEvent::SchedulerCommittedTime => historical_is_detlog(text),
836 DetLogEvent::SchedulerCommit { .. } => historical_is_commit(text),
837 DetLogEvent::SchedulerEmptyQueueKick => text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
838 }
839}
840
841fn validate_structured_events(
842 label: &str,
843 messages: &[LogMessage<'_>],
844 require: bool,
845) -> std::io::Result<()> {
846 for message in messages {
847 let is_semantic_record = historical_is_detlog(message.text)
848 || historical_is_commit(message.text)
849 || message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK);
850 if require && is_semantic_record && message.event.is_none() {
851 return Err(std::io::Error::new(
852 std::io::ErrorKind::InvalidData,
853 format!(
854 "{label} log record {} is missing its structured DETLOG result",
855 message.index
856 ),
857 ));
858 }
859 if let Some(event) = message.event
860 && !event_matches_human_record(event, message.text)
861 {
862 return Err(std::io::Error::new(
863 std::io::ErrorKind::InvalidData,
864 format!(
865 "{label} log record {} has structured DETLOG kind {:?} that disagrees with its human record",
866 message.index, event
867 ),
868 ));
869 }
870 }
871 Ok(())
872}
873
874fn _truncate_messages(_v: &[&str]) -> String {
877 unimplemented!()
878}
879
880fn filter_infos<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
881 v.iter()
882 .filter(|message| is_info(message))
883 .copied()
884 .collect()
885}
886
887fn filter_detcore<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
888 v.iter()
889 .filter(|message| is_detcore(message))
890 .copied()
891 .collect()
892}
893
894const SCHEDULER_EMPTY_QUEUE_KICK: &str = "zero threads left anywhere, fizzling.";
918
919fn count_empty_queue_kicks(v: &[LogMessage<'_>]) -> usize {
921 v.iter()
922 .filter(|message| match message.event {
923 Some(DetLogEvent::SchedulerEmptyQueueKick) => true,
924 Some(_) => false,
925 None => message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
926 })
927 .count()
928}
929
930const RUNTIME_MAPS_READ_RESOURCE: &str = r#"Path("/proc/self/maps")"#;
947
948fn describe_maps_commit(first: Option<(u64, Option<u64>)>) -> String {
952 match first {
953 Some((turn, Some(nanoseconds))) => {
954 format!("first at turn {turn}, committed virtual time {nanoseconds}ns")
955 }
956 Some((turn, None)) => format!("first at turn {turn}, committed virtual time unrecorded"),
957 None => "no such record".to_string(),
958 }
959}
960
961fn maps_read_commits(v: &[LogMessage<'_>]) -> (usize, Option<(u64, Option<u64>)>) {
964 let mut count = 0;
965 let mut first = None;
966 for message in v {
967 let reads_runtime_maps = match message.event {
968 Some(DetLogEvent::SchedulerCommit {
969 runtime_maps_read, ..
970 }) => runtime_maps_read,
971 Some(_) => false,
972 None => message.text.contains(RUNTIME_MAPS_READ_RESOURCE),
973 };
974 if !reads_runtime_maps {
975 continue;
976 }
977 let Some(position) = commit_position(message) else {
978 continue;
979 };
980 count += 1;
981 if first.is_none() {
982 first = Some(position);
983 }
984 }
985 (count, first)
986}
987
988fn filter_ignored<'a>(lines: Vec<LogMessage<'a>>, omits: &Vec<String>) -> Vec<LogMessage<'a>> {
989 lines
990 .into_iter()
991 .filter(|message| {
992 let mut keep = true;
993 for omit in omits {
994 if message.text.contains(omit) {
995 keep = false
996 }
997 }
998 keep
999 })
1000 .collect()
1001}
1002
1003fn collect_syscalls<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
1004 v.iter()
1005 .filter(|entry| is_detlog_syscall(entry))
1006 .copied()
1007 .collect()
1008}
1009
1010fn matched_prefix_length(compared_left: &[String], compared_right: &[String]) -> usize {
1018 compared_left
1019 .iter()
1020 .zip(compared_right)
1021 .take_while(|(left, right)| left == right)
1022 .count()
1023}
1024
1025fn matched_prefix_for_verdict(
1038 diff_found: bool,
1039 prefix: usize,
1040 compared_left: usize,
1041 compared_right: usize,
1042) -> Option<usize> {
1043 let consistent = if diff_found {
1044 prefix < compared_left.max(compared_right)
1045 } else {
1046 prefix == compared_left && prefix == compared_right
1047 };
1048 consistent.then_some(prefix)
1049}
1050
1051fn first_different_message_indices(
1052 left: &[LogMessage<'_>],
1053 compared_left: &[String],
1054 right: &[LogMessage<'_>],
1055 compared_right: &[String],
1056) -> Option<(Option<usize>, Option<usize>)> {
1057 let common = compared_left.len().min(compared_right.len());
1058 let position = matched_prefix_length(compared_left, compared_right);
1059
1060 if position < common {
1061 return Some((Some(left[position].index), Some(right[position].index)));
1062 }
1063
1064 match compared_left.len().cmp(&compared_right.len()) {
1065 Ordering::Less => Some((None, Some(right[common].index))),
1066 Ordering::Greater => Some((Some(left[common].index), None)),
1067 Ordering::Equal => None,
1068 }
1069}
1070
1071fn first_divergent_message(message: &LogMessage<'_>) -> String {
1079 static FINISHED_SYSCALL: LazyLock<Regex> =
1080 LazyLock::new(|| Regex::new(r"(finish syscall #)[0-9][0-9_]*").unwrap());
1081 static COMMIT_TURN: LazyLock<Regex> =
1082 LazyLock::new(|| Regex::new(r"(\bCOMMIT turn )[0-9][0-9_]*\b").unwrap());
1083 static COMMITTED_TIME: LazyLock<Regex> = LazyLock::new(|| {
1084 Regex::new(r"(\b(?:at time|on previously committed) )[0-9][0-9_]*(?:\.[0-9_]+)?(?:ns|s)?\b")
1085 .unwrap()
1086 });
1087
1088 let (first_line, continuation) = match message.text.split_once('\n') {
1089 Some((first_line, continuation)) => (first_line, Some(continuation)),
1090 None => (message.text, None),
1091 };
1092 let first_line =
1093 if is_detlog_syscall_result(message) && finished_syscall_number(message).is_some() {
1094 FINISHED_SYSCALL
1095 .replace_all(first_line, "${1}<NUM>")
1096 .into_owned()
1097 } else {
1098 first_line.to_string()
1099 };
1100 let first_line = if is_commit(message) && commit_position(message).is_some() {
1101 let first_line = COMMIT_TURN.replace_all(&first_line, "${1}<NUM>");
1102 COMMITTED_TIME
1103 .replace_all(&first_line, "${1}<NANOSECONDS>")
1104 .into_owned()
1105 } else {
1106 first_line
1107 };
1108 match continuation {
1109 Some(continuation) => format!("{first_line}\n{continuation}"),
1110 None => first_line,
1111 }
1112}
1113
1114fn compared_message_at_record(
1115 records: &[LogMessage<'_>],
1116 compared: &[String],
1117 record: Option<usize>,
1118) -> Option<String> {
1119 let record = record?;
1120 let position = records.iter().position(|message| message.index == record)?;
1121 let original = records.get(position)?;
1122 let prepared = LogMessage {
1123 index: original.index,
1124 text: compared.get(position)?,
1125 event: original.event,
1126 };
1127 Some(first_divergent_message(&prepared))
1128}
1129
1130fn parse_underscored_u64(value: &str) -> Option<u64> {
1131 value.replace('_', "").parse().ok()
1132}
1133
1134fn parse_virtual_nanoseconds(value: &str, unit: Option<&str>) -> Option<u64> {
1135 match unit {
1136 None | Some("ns") if !value.contains('.') => parse_underscored_u64(value),
1137 Some("s") => {
1138 let value = value.replace('_', "");
1139 let (seconds, fraction) = value.split_once('.').unwrap_or((&value, ""));
1140 if fraction.len() > 9 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
1141 return None;
1142 }
1143 let seconds = seconds.parse::<u64>().ok()?;
1144 let fraction = if fraction.is_empty() {
1145 0
1146 } else {
1147 let digits = fraction.parse::<u64>().ok()?;
1148 digits.checked_mul(10_u64.pow((9 - fraction.len()) as u32))?
1149 };
1150 seconds.checked_mul(1_000_000_000)?.checked_add(fraction)
1151 }
1152 _ => None,
1153 }
1154}
1155
1156fn historical_commit_position(message: &str) -> Option<(u64, Option<u64>)> {
1157 static TURN: LazyLock<Regex> =
1158 LazyLock::new(|| Regex::new(r"\bCOMMIT turn ([0-9][0-9_]*)\b").unwrap());
1159 static TIME: LazyLock<Regex> = LazyLock::new(|| {
1160 Regex::new(r"\b(?:at time|on previously committed) ([0-9][0-9_]*(?:\.[0-9_]+)?)(ns|s)?\b")
1161 .unwrap()
1162 });
1163
1164 let turn = parse_underscored_u64(TURN.captures(message)?.get(1)?.as_str())?;
1165 let virtual_nanoseconds = TIME.captures(message).and_then(|captures| {
1166 parse_virtual_nanoseconds(
1167 captures.get(1)?.as_str(),
1168 captures.get(2).map(|unit| unit.as_str()),
1169 )
1170 });
1171 Some((turn, virtual_nanoseconds))
1172}
1173
1174fn commit_position(message: &LogMessage<'_>) -> Option<(u64, Option<u64>)> {
1175 match message.event {
1176 Some(DetLogEvent::SchedulerCommit {
1177 scheduler_turn,
1178 virtual_nanoseconds,
1179 ..
1180 }) => Some((scheduler_turn, Some(virtual_nanoseconds))),
1181 Some(_) => None,
1182 None => historical_commit_position(message.text),
1183 }
1184}
1185
1186fn commit_position_at_or_before(
1187 messages: &[LogMessage<'_>],
1188 message_index: usize,
1189) -> Option<(u64, Option<u64>)> {
1190 messages
1191 .iter()
1192 .rev()
1193 .filter(|message| message.index <= message_index)
1194 .find_map(commit_position)
1195}
1196
1197fn historical_finished_syscall_number(line: &str) -> Option<u64> {
1203 let rest = line.split("finish syscall #").nth(1)?;
1204 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1205 digits.parse().ok()
1206}
1207
1208fn finished_syscall_number(message: &LogMessage<'_>) -> Option<u64> {
1209 match message.event {
1210 Some(DetLogEvent::SyscallResult {
1211 finished_syscall_number,
1212 }) => Some(finished_syscall_number),
1213 Some(_) => None,
1214 None => historical_finished_syscall_number(message.text),
1215 }
1216}
1217
1218fn finished_syscall_at_or_before(v: &[LogMessage<'_>], message_index: usize) -> Option<u64> {
1232 v.iter()
1233 .rev()
1234 .filter(|message| message.index <= message_index)
1235 .find_map(finished_syscall_number)
1236}
1237
1238fn syscall_at_or_before<'a>(
1239 syscalls: &'a [LogMessage<'a>],
1240 index: usize,
1241) -> Option<LogMessage<'a>> {
1242 syscalls
1243 .iter()
1244 .rev()
1245 .find(|message| message.index <= index)
1246 .copied()
1247}
1248
1249fn sentence_case_label(label: &str) -> String {
1250 let mut characters = label.chars();
1251 match characters.next() {
1252 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1253 None => String::new(),
1254 }
1255}
1256
1257fn write_syscall_context(
1258 w: &mut impl std::io::Write,
1259 left_index: usize,
1260 right_index: usize,
1261 left_syscalls: &[LogMessage<'_>],
1262 right_syscalls: &[LogMessage<'_>],
1263 labels: &ComparisonSideLabels,
1264 history_count: u64,
1265) -> std::io::Result<()> {
1266 if history_count == 0 {
1267 return Ok(());
1268 }
1269
1270 let left_current = syscall_at_or_before(left_syscalls, left_index);
1271 let right_current = syscall_at_or_before(right_syscalls, right_index);
1272 if left_current.is_none() && right_current.is_none() {
1273 return Ok(());
1274 }
1275
1276 writeln!(w, "Divergent syscall context:")?;
1277 for (label, current) in [
1278 (labels.left.as_str(), left_current),
1279 (labels.right.as_str(), right_current),
1280 ] {
1281 if let Some(syscall) = current {
1282 writeln!(
1283 w,
1284 " {label}, log message {}: {}",
1285 syscall.index, syscall.text
1286 )?;
1287 } else {
1288 writeln!(w, " {label}: <no syscall observed>")?;
1289 }
1290 }
1291
1292 let history_limit = usize::try_from(history_count).unwrap_or(usize::MAX);
1293 for (label, index, syscalls) in [
1294 (labels.left.as_str(), left_index, left_syscalls),
1295 (labels.right.as_str(), right_index, right_syscalls),
1296 ] {
1297 let history_boundary =
1298 syscall_at_or_before(syscalls, index).map_or(index, |current| current.index);
1299 let mut history = syscalls
1300 .iter()
1301 .rev()
1302 .filter(|entry| entry.index < history_boundary && is_detlog_syscall_result(entry))
1303 .take(history_limit)
1304 .copied()
1305 .collect::<Vec<_>>();
1306 history.reverse();
1307 if !history.is_empty() {
1308 writeln!(w, " Prior completed syscalls for {label}:")?;
1309 for syscall in history {
1310 writeln!(w, " {}", syscall.text)?;
1311 }
1312 }
1313 }
1314 writeln!(w)?;
1315
1316 Ok(())
1317}
1318
1319pub struct Comparison<'a> {
1323 left: &'a str,
1324 right: &'a str,
1325 no_color: bool,
1326}
1327impl<'a> Comparison<'a> {
1328 pub fn new(no_color: bool, left: &'a str, right: &'a str) -> Comparison<'a> {
1332 Comparison {
1333 left,
1334 right,
1335 no_color,
1336 }
1337 }
1338}
1339impl<'a> Display for Comparison<'a> {
1340 fn fmt(&self, f: &mut Formatter) -> Result {
1341 if self.no_color {
1342 writeln!(f, "Diff < left / right > :")?;
1343 writeln!(f, "<\"{}\"", self.left)?;
1344 writeln!(f, ">\"{}\"", self.right)
1345 } else {
1346 pretty_assertions::Comparison::new(&self.left, &self.right).fmt(f)
1347 }
1348 }
1349}
1350
1351fn diff_vecs(
1362 which: &str,
1363 left: (&[LogMessage<'_>], &[String]),
1364 right: (&[LogMessage<'_>], &[String]),
1365 opts: &LogDiffOpts,
1366 w: &mut impl std::io::Write,
1367 left_syscalls: &[LogMessage<'_>],
1368 right_syscalls: &[LogMessage<'_>],
1369) -> std::io::Result<bool> {
1370 let (v1, compared_left) = left;
1371 let (v2, compared_right) = right;
1372 writeln!(w, " Comparing {which} messages...\n")?;
1373 if v1.is_empty() && v2.is_empty() {
1374 return Ok(false);
1375 }
1376
1377 let mut diff_count = 0;
1378 for (position, (left, right)) in v1.iter().zip(v2.iter()).enumerate() {
1379 let left_compared = &compared_left[position];
1380 let right_compared = &compared_right[position];
1381 if left_compared == right_compared {
1382 continue;
1383 }
1384
1385 if diff_count >= opts.limit && opts.limit != 0 {
1386 writeln!(
1387 w,
1388 "More than {} differences, eliding the rest...",
1389 opts.limit
1390 )?;
1391 break;
1392 }
1393
1394 write!(
1395 w,
1396 "({which}) Mismatch at log messages {} ({}) and {} ({}): {}",
1397 left.index,
1398 opts.side_labels.left,
1399 right.index,
1400 opts.side_labels.right,
1401 Comparison::new(opts.no_color, left_compared, right_compared)
1402 )?;
1403 if opts.strip_lines || opts.canonicalize_addresses {
1404 write!(
1405 w,
1406 "({which}) Original entries before normalization: {}",
1407 Comparison::new(opts.no_color, left.text, right.text)
1408 )?;
1409 }
1410 write_syscall_context(
1411 w,
1412 left.index,
1413 right.index,
1414 left_syscalls,
1415 right_syscalls,
1416 &opts.side_labels,
1417 opts.syscall_history,
1418 )?;
1419
1420 diff_count += 1;
1421 }
1422
1423 match v1.len().cmp(&v2.len()) {
1424 Ordering::Less => {
1425 writeln!(
1426 w,
1427 "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1428 sentence_case_label(&opts.side_labels.right),
1429 v2.len() - v1.len(),
1430 opts.side_labels.left,
1431 )?;
1432 diff_count += 1;
1433 let start = v2.len() - std::cmp::min(10, v2.len() - v1.len());
1434 for message in &compared_right[start..] {
1435 writeln!(w, "{message}")?;
1436 }
1437 }
1438 Ordering::Greater => {
1439 writeln!(
1440 w,
1441 "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1442 sentence_case_label(&opts.side_labels.left),
1443 v1.len() - v2.len(),
1444 opts.side_labels.right,
1445 )?;
1446 diff_count += 1;
1447 let start = v1.len() - std::cmp::min(10, v1.len() - v2.len());
1448 for message in &compared_left[start..] {
1449 writeln!(w, "{message}")?;
1450 }
1451 }
1452 Ordering::Equal => {}
1453 }
1454
1455 Ok(diff_count > 0)
1456}
1457
1458fn write_compared_messages(
1459 writer: &mut impl std::io::Write,
1460 messages: &[String],
1461) -> std::io::Result<()> {
1462 for message in messages {
1463 writeln!(writer, "{message}")?;
1464 }
1465 Ok(())
1466}
1467
1468fn write_compared_logs(
1469 writer: &mut impl std::io::Write,
1470 policy: LogComparisonPolicy,
1471 compared_left: &[String],
1472 compared_right: &[String],
1473 labels: &ComparisonSideLabels,
1474) -> std::io::Result<()> {
1475 writeln!(writer, "Comparison policy: {}", policy.name())?;
1476 writeln!(writer, "--- begin {} compared log ---", labels.left)?;
1477 write_compared_messages(writer, compared_left)?;
1478 writeln!(writer, "--- end {} compared log ---", labels.left)?;
1479 writeln!(writer, "--- begin {} compared log ---", labels.right)?;
1480 write_compared_messages(writer, compared_right)?;
1481 writeln!(writer, "--- end {} compared log ---", labels.right)?;
1482 Ok(())
1483}
1484
1485fn git_diff(
1486 which: &str,
1487 left: (&[LogMessage<'_>], &[String]),
1488 right: (&[LogMessage<'_>], &[String]),
1489 opts: &LogDiffOpts,
1490 w: &mut impl std::io::Write,
1491 left_syscalls: &[LogMessage<'_>],
1492 right_syscalls: &[LogMessage<'_>],
1493) -> std::io::Result<bool> {
1494 let (v1, compared_left) = left;
1495 let (v2, compared_right) = right;
1496 writeln!(w, " Comparing {which} messages...\n")?;
1497
1498 let mut file1 = NamedTempFile::new()?;
1499 let mut file2 = NamedTempFile::new()?;
1500
1501 write_compared_messages(&mut file1, compared_left)?;
1502 write_compared_messages(&mut file2, compared_right)?;
1503
1504 match Command::new("git")
1505 .args(["diff", "--color", "--color-words", "-w"])
1506 .arg(file1.path())
1507 .arg(file2.path())
1508 .status()
1509 {
1510 Ok(code) => Ok(!code.success()),
1511 Err(error) => {
1512 eprintln!("Error launching git, falling back to basic diff: {error}");
1513 diff_vecs(
1514 which,
1515 (v1, compared_left),
1516 (v2, compared_right),
1517 opts,
1518 w,
1519 left_syscalls,
1520 right_syscalls,
1521 )
1522 }
1523 }
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Eq)]
1534pub struct LogDiffSummary {
1535 pub diff_found: bool,
1537 pub compared_left: usize,
1539 pub compared_right: usize,
1541 pub first_divergent_scheduler_turn: Option<u64>,
1544 pub first_divergent_virtual_nanoseconds: Option<u64>,
1547 pub first_divergent_record: Option<usize>,
1551 pub matched_prefix_messages: Option<usize>,
1566 pub first_divergent_syscall: Option<u64>,
1571 pub first_divergent_left_message: Option<String>,
1575 pub first_divergent_right_message: Option<String>,
1578 pub refusal_reason: Option<String>,
1594}
1595
1596impl LogDiffSummary {
1597 pub fn matched_with_evidence(&self) -> bool {
1600 self.refusal_reason.is_none()
1601 && !self.diff_found
1602 && self.compared_left > 0
1603 && self.compared_right > 0
1604 }
1605}
1606
1607pub fn log_diff(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> bool {
1624 log_diff_detailed(file_a, file_b, opts).diff_found
1625}
1626
1627pub fn log_diff_detailed(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> LogDiffSummary {
1630 try_log_diff_detailed(file_a, file_b, opts).expect("could not read or compare log inputs")
1631}
1632
1633pub fn try_log_diff_detailed(
1636 file_a: &Path,
1637 file_b: &Path,
1638 opts: &LogDiffOpts,
1639) -> std::io::Result<LogDiffSummary> {
1640 try_log_diff_detailed_with_filter(file_a, file_b, opts, |_| true)
1641}
1642
1643pub fn try_compare_bitwise_info_v1(
1652 file_a: &Path,
1653 file_b: &Path,
1654 side_labels: ComparisonSideLabels,
1655) -> std::io::Result<LogDiffSummary> {
1656 try_compare_bitwise_info_v1_with_records(file_a, file_b, side_labels)
1657 .map(|(summary, _, _)| summary)
1658}
1659
1660pub fn try_compare_bitwise_info_v1_with_records(
1662 file_a: &Path,
1663 file_b: &Path,
1664 side_labels: ComparisonSideLabels,
1665) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1666 let bytes_a = std::fs::read(file_a)?;
1667 let bytes_b = std::fs::read(file_b)?;
1668 try_compare_bitwise_info_v1_bytes_with_records(&bytes_a, &bytes_b, side_labels)
1669}
1670
1671pub fn try_compare_bitwise_info_v1_bytes_with_records(
1673 bytes_a: &[u8],
1674 bytes_b: &[u8],
1675 side_labels: ComparisonSideLabels,
1676) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1677 try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1678 bytes_a,
1679 bytes_b,
1680 side_labels,
1681 BitwiseInfoV1Diagnostics::default(),
1682 &mut std::io::stderr(),
1683 )
1684}
1685
1686#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1692pub struct BitwiseInfoV1Diagnostics {
1693 pub difference_limit: u64,
1695 pub syscall_history: u64,
1697 pub no_color: bool,
1699 pub print_logs: bool,
1701}
1702
1703impl Default for BitwiseInfoV1Diagnostics {
1704 fn default() -> Self {
1705 Self {
1706 difference_limit: 20,
1707 syscall_history: 5,
1708 no_color: false,
1709 print_logs: false,
1710 }
1711 }
1712}
1713
1714pub fn try_compare_bitwise_info_v1_with_records_and_diagnostics(
1716 file_a: &Path,
1717 file_b: &Path,
1718 side_labels: ComparisonSideLabels,
1719 diagnostics: BitwiseInfoV1Diagnostics,
1720 writer: &mut impl Write,
1721) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1722 let bytes_a = std::fs::read(file_a)?;
1723 let bytes_b = std::fs::read(file_b)?;
1724 try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1725 &bytes_a,
1726 &bytes_b,
1727 side_labels,
1728 diagnostics,
1729 writer,
1730 )
1731}
1732
1733pub fn try_compare_bitwise_info_v1_with_diagnostics(
1735 file_a: &Path,
1736 file_b: &Path,
1737 side_labels: ComparisonSideLabels,
1738 diagnostics: BitwiseInfoV1Diagnostics,
1739 writer: &mut impl Write,
1740) -> std::io::Result<LogDiffSummary> {
1741 try_compare_bitwise_info_v1_with_records_and_diagnostics(
1742 file_a,
1743 file_b,
1744 side_labels,
1745 diagnostics,
1746 writer,
1747 )
1748 .map(|(summary, _, _)| summary)
1749}
1750
1751pub fn try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1753 bytes_a: &[u8],
1754 bytes_b: &[u8],
1755 side_labels: ComparisonSideLabels,
1756 diagnostics: BitwiseInfoV1Diagnostics,
1757 writer: &mut impl Write,
1758) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1759 let str_a = std::str::from_utf8(bytes_a).map_err(|error| {
1760 std::io::Error::new(
1761 std::io::ErrorKind::InvalidData,
1762 format!("{} is not UTF-8: {error}", side_labels.left),
1763 )
1764 })?;
1765 let str_b = std::str::from_utf8(bytes_b).map_err(|error| {
1766 std::io::Error::new(
1767 std::io::ErrorKind::InvalidData,
1768 format!("{} is not UTF-8: {error}", side_labels.right),
1769 )
1770 })?;
1771 let mut options = bitwise_info_v1_options(side_labels);
1772 options.limit = diagnostics.difference_limit;
1773 options.syscall_history = diagnostics.syscall_history;
1774 options.no_color = diagnostics.no_color;
1775 options.print_logs = diagnostics.print_logs;
1776 let records_a = record_count(str_a);
1777 let records_b = record_count(str_b);
1778 let summary = log_diff_summary_from_strs_with_filter(str_a, str_b, &options, writer, |_| true)?;
1779 Ok((summary, records_a, records_b))
1780}
1781
1782fn bitwise_info_v1_options(side_labels: ComparisonSideLabels) -> LogDiffOpts {
1784 LogDiffOpts {
1785 strip_lines: false,
1786 canonicalize_addresses: true,
1787 comparison: LogComparisonMode::Info,
1788 side_labels,
1789 require_structured_events: true,
1790 print_logs: false,
1791 limit: 20,
1792 ignore_lines: Vec::new(),
1793 syscall_history: 5,
1794 no_color: false,
1795 skip_commit: false,
1796 skip_detlog: false,
1797 git_diff: false,
1798 include_detlogs: vec![
1799 DetLogFilter::Syscall,
1800 DetLogFilter::SyscallResult,
1801 DetLogFilter::Other,
1802 ],
1803 }
1804}
1805
1806pub fn try_log_diff_detailed_with_filter(
1814 file_a: &Path,
1815 file_b: &Path,
1816 opts: &LogDiffOpts,
1817 keep_record: impl Fn(&str) -> bool,
1818) -> std::io::Result<LogDiffSummary> {
1819 let vec_a = std::fs::read(file_a)?;
1823 let vec_b = std::fs::read(file_b)?;
1824 let str_a = String::from_utf8_lossy(&vec_a);
1825 let str_b = String::from_utf8_lossy(&vec_b);
1826 log_diff_summary_from_strs_with_filter(str_a, str_b, opts, &mut std::io::stderr(), keep_record)
1827}
1828
1829pub fn record_count(contents: &str) -> usize {
1832 record_starts(contents).len()
1833}
1834
1835pub fn try_log_diff_with_records(
1839 file_a: &Path,
1840 file_b: &Path,
1841 opts: &LogDiffOpts,
1842) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1843 try_log_diff_with_records_and_filter(file_a, file_b, opts, |_| true)
1844}
1845
1846pub fn try_log_diff_with_records_and_filter(
1850 file_a: &Path,
1851 file_b: &Path,
1852 opts: &LogDiffOpts,
1853 keep_record: impl Fn(&str) -> bool,
1854) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1855 let vec_a = std::fs::read(file_a)?;
1856 let vec_b = std::fs::read(file_b)?;
1857 let str_a = String::from_utf8_lossy(&vec_a);
1858 let str_b = String::from_utf8_lossy(&vec_b);
1859 let records_a = record_count(&str_a);
1860 let records_b = record_count(&str_b);
1861 let summary = log_diff_summary_from_strs_with_filter(
1862 &str_a,
1863 &str_b,
1864 opts,
1865 &mut std::io::stderr(),
1866 keep_record,
1867 )?;
1868 Ok((summary, records_a, records_b))
1869}
1870
1871#[derive(Debug, Clone, PartialEq, Eq)]
1878pub struct PrefixComparison {
1879 pub summary: LogDiffSummary,
1881 pub records_available_left: usize,
1883 pub records_available_right: usize,
1885 pub records_compared: usize,
1887}
1888
1889impl PrefixComparison {
1890 pub fn one_side_is_ahead(&self) -> bool {
1893 self.records_available_left != self.records_available_right
1894 }
1895}
1896
1897pub fn compare_complete_prefix(
1905 contents_a: &str,
1906 contents_b: &str,
1907 opts: &LogDiffOpts,
1908 w: &mut impl std::io::Write,
1909) -> std::io::Result<PrefixComparison> {
1910 compare_complete_prefix_with_filter(contents_a, contents_b, opts, w, |_| true)
1911}
1912
1913pub fn compare_complete_bitwise_info_v1_prefix(
1923 bytes_a: &[u8],
1924 bytes_b: &[u8],
1925 side_labels: ComparisonSideLabels,
1926 diagnostics: BitwiseInfoV1Diagnostics,
1927 w: &mut impl std::io::Write,
1928) -> std::io::Result<PrefixComparison> {
1929 let contents_a = std::str::from_utf8(bytes_a).map_err(|error| {
1930 std::io::Error::new(
1931 std::io::ErrorKind::InvalidData,
1932 format!("{} is not UTF-8: {error}", side_labels.left),
1933 )
1934 })?;
1935 let contents_b = std::str::from_utf8(bytes_b).map_err(|error| {
1936 std::io::Error::new(
1937 std::io::ErrorKind::InvalidData,
1938 format!("{} is not UTF-8: {error}", side_labels.right),
1939 )
1940 })?;
1941
1942 let truncated_a = log_was_truncated(contents_a);
1943 let truncated_b = log_was_truncated(contents_b);
1944 if truncated_a || truncated_b {
1945 let which_side = match (truncated_a, truncated_b) {
1946 (true, true) => "both logs were",
1947 (true, false) => "the first log was",
1948 (false, true) => "the second log was",
1949 (false, false) => unreachable!("guarded by the condition above"),
1950 };
1951 return Err(std::io::Error::new(
1952 std::io::ErrorKind::InvalidData,
1953 format!(
1954 "{which_side} truncated at the configured size bound; the discarded tail was never written"
1955 ),
1956 ));
1957 }
1958
1959 let records_available_left = complete_record_count(contents_a);
1960 let records_available_right = complete_record_count(contents_b);
1961 for (label, contents, available) in [
1962 (
1963 side_labels.left.as_str(),
1964 contents_a,
1965 records_available_left,
1966 ),
1967 (
1968 side_labels.right.as_str(),
1969 contents_b,
1970 records_available_right,
1971 ),
1972 ] {
1973 let complete = take_complete_records(contents, available)
1974 .expect("the complete-record count always identifies its own prefix");
1975 let records = extract_log_messages(complete)
1976 .map_err(|error| std::io::Error::new(error.kind(), format!("{label} {error}")))?;
1977 validate_structured_events(label, &records, true)?;
1978 }
1979
1980 let records_compared = records_available_left.min(records_available_right);
1981 let prefix_a = take_complete_records(contents_a, records_compared)
1982 .expect("common prefix never exceeds either side's complete record count");
1983 let prefix_b = take_complete_records(contents_b, records_compared)
1984 .expect("common prefix never exceeds either side's complete record count");
1985 let mut options = bitwise_info_v1_options(side_labels);
1986 options.limit = diagnostics.difference_limit;
1987 options.syscall_history = diagnostics.syscall_history;
1988 options.no_color = diagnostics.no_color;
1989 options.print_logs = diagnostics.print_logs;
1990 let summary =
1991 log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, &options, w, |_| true)?;
1992 Ok(PrefixComparison {
1993 summary,
1994 records_available_left,
1995 records_available_right,
1996 records_compared,
1997 })
1998}
1999
2000pub fn compare_complete_prefix_with_filter(
2004 contents_a: &str,
2005 contents_b: &str,
2006 opts: &LogDiffOpts,
2007 w: &mut impl std::io::Write,
2008 keep_record: impl Fn(&str) -> bool,
2009) -> std::io::Result<PrefixComparison> {
2010 let records_available_left = complete_record_count(contents_a);
2011 let records_available_right = complete_record_count(contents_b);
2012 let records_compared = records_available_left.min(records_available_right);
2013 let prefix_a = take_complete_records(contents_a, records_compared)
2015 .expect("common prefix never exceeds either side's complete record count");
2016 let prefix_b = take_complete_records(contents_b, records_compared)
2017 .expect("common prefix never exceeds either side's complete record count");
2018 let summary = log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, opts, w, keep_record)?;
2019 Ok(PrefixComparison {
2020 summary,
2021 records_available_left,
2022 records_available_right,
2023 records_compared,
2024 })
2025}
2026
2027#[cfg(test)]
2030fn log_diff_from_strs(
2031 file_a_str: impl AsRef<str>,
2032 file_b_str: impl AsRef<str>,
2033 opts: &LogDiffOpts,
2034 w: &mut impl std::io::Write,
2035) -> std::io::Result<bool> {
2036 Ok(log_diff_summary_from_strs(file_a_str, file_b_str, opts, w)?.diff_found)
2037}
2038
2039#[cfg(test)]
2040fn log_diff_summary_from_strs(
2041 file_a_str: impl AsRef<str>,
2042 file_b_str: impl AsRef<str>,
2043 opts: &LogDiffOpts,
2044 w: &mut impl std::io::Write,
2045) -> std::io::Result<LogDiffSummary> {
2046 log_diff_summary_from_strs_with_filter(file_a_str, file_b_str, opts, w, |_| true)
2047}
2048
2049pub fn log_diff_summary_from_strs_with_filter(
2057 file_a_str: impl AsRef<str>,
2058 file_b_str: impl AsRef<str>,
2059 opts: &LogDiffOpts,
2060 w: &mut impl std::io::Write,
2061 keep_record: impl Fn(&str) -> bool,
2062) -> std::io::Result<LogDiffSummary> {
2063 let truncated_a = log_was_truncated(file_a_str.as_ref());
2073 let truncated_b = log_was_truncated(file_b_str.as_ref());
2074 if truncated_a || truncated_b {
2075 let which_side = match (truncated_a, truncated_b) {
2076 (true, true) => "both logs were",
2077 (true, false) => "the first log was",
2078 (false, true) => "the second log was",
2079 (false, false) => unreachable!("guarded by the condition above"),
2080 };
2081 let refusal_reason = format!(
2082 "{which_side} truncated at the configured size bound (the log ends with the bounded writer's truncation marker). The discarded tail was never written, so no comparison of these files can establish that the runs agree past that point. Re-run with a larger HERMIT_LOG_MAX_BYTES, or 0 to disable the bound."
2083 );
2084 writeln!(
2085 w,
2086 "REFUSING to compare: {refusal_reason} This is a NO-RESULT, not a difference and not a match."
2087 )?;
2088 return Ok(LogDiffSummary {
2089 diff_found: true,
2090 compared_left: 0,
2091 compared_right: 0,
2092 first_divergent_scheduler_turn: None,
2093 first_divergent_virtual_nanoseconds: None,
2094 first_divergent_record: None,
2095 matched_prefix_messages: None,
2096 first_divergent_syscall: None,
2097 first_divergent_left_message: None,
2098 first_divergent_right_message: None,
2099 refusal_reason: Some(refusal_reason),
2100 });
2101 }
2102
2103 let extracted_a = extract_log_messages(file_a_str.as_ref())?;
2104 let extracted_b = extract_log_messages(file_b_str.as_ref())?;
2105 validate_structured_events(
2106 opts.side_labels.left.as_str(),
2107 &extracted_a,
2108 opts.require_structured_events,
2109 )?;
2110 validate_structured_events(
2111 opts.side_labels.right.as_str(),
2112 &extracted_b,
2113 opts.require_structured_events,
2114 )?;
2115 let all_a = filter_ignored(
2116 extracted_a
2117 .into_iter()
2118 .filter(|record| keep_record(record.text))
2119 .collect(),
2120 &opts.ignore_lines,
2121 );
2122 let all_b = filter_ignored(
2123 extracted_b
2124 .into_iter()
2125 .filter(|record| keep_record(record.text))
2126 .collect(),
2127 &opts.ignore_lines,
2128 );
2129
2130 writeln!(
2131 w,
2132 "Logs contain {} | {} messages total",
2133 all_a.len(),
2134 all_b.len(),
2135 )?;
2136
2137 let detcore_a = filter_detcore(&all_a);
2138 let detcore_b = filter_detcore(&all_b);
2139 let infos_a = filter_infos(&all_a);
2140 let infos_b = filter_infos(&all_b);
2141 let detlogs_a = opts.filter_deterministic(&detcore_a);
2142 let detlogs_b = opts.filter_deterministic(&detcore_b);
2143 let left_syscalls = collect_syscalls(&all_a);
2144 let right_syscalls = collect_syscalls(&all_b);
2145 writeln!(
2146 w,
2147 "Logs contain {} | {} detcore-specific messages",
2148 detcore_a.len(),
2149 detcore_b.len(),
2150 )?;
2151 writeln!(
2152 w,
2153 "Logs contain {} | {} INFO messages",
2154 infos_a.len(),
2155 infos_b.len(),
2156 )?;
2157 writeln!(
2158 w,
2159 "Logs contain {} | {} DETLOG & scheduler COMMIT messages",
2160 detlogs_a.len(),
2161 detlogs_b.len(),
2162 )?;
2163
2164 let policy = LogComparisonPolicy::from_options(opts);
2165
2166 if policy.normalization == LogNormalization::Stripped {
2167 writeln!(
2168 w,
2169 "Normalizing known nondeterministic numerical data before comparison..."
2170 )?;
2171 } else if policy.normalization == LogNormalization::Canonical {
2172 writeln!(
2173 w,
2174 "Canonicalizing host addresses (ordinal by first appearance); comparing everything else exactly..."
2175 )?;
2176 }
2177
2178 let (which, compared_a, compared_b) = match policy.comparison {
2179 LogComparisonMode::Deterministic => ("DETLOG", &detlogs_a, &detlogs_b),
2180 LogComparisonMode::Info => ("INFO", &infos_a, &infos_b),
2181 LogComparisonMode::FullTrace => ("full trace", &all_a, &all_b),
2182 };
2183
2184 let prepared_a = messages_for_comparison(compared_a, policy);
2189 let prepared_b = messages_for_comparison(compared_b, policy);
2190
2191 if opts.print_logs {
2192 write_compared_logs(w, policy, &prepared_a, &prepared_b, &opts.side_labels)?;
2193 }
2194
2195 let first_different =
2196 first_different_message_indices(compared_a, &prepared_a, compared_b, &prepared_b);
2197 let first_position_candidate = first_different.and_then(|(left_index, right_index)| {
2198 left_index
2199 .and_then(|index| commit_position_at_or_before(&all_a, index))
2200 .or_else(|| right_index.and_then(|index| commit_position_at_or_before(&all_b, index)))
2201 });
2202
2203 let first_divergent_syscall_candidate = first_different.and_then(|(left, right)| {
2204 left.and_then(|index| finished_syscall_at_or_before(&all_a, index))
2205 .or_else(|| right.and_then(|index| finished_syscall_at_or_before(&all_b, index)))
2206 });
2207 let first_divergent_left_message = first_different
2208 .and_then(|(left, _)| compared_message_at_record(compared_a, &prepared_a, left));
2209 let first_divergent_right_message = first_different
2210 .and_then(|(_, right)| compared_message_at_record(compared_b, &prepared_b, right));
2211
2212 let diff_found = if opts.git_diff {
2213 git_diff(
2214 which,
2215 (compared_a, &prepared_a),
2216 (compared_b, &prepared_b),
2217 opts,
2218 w,
2219 &left_syscalls,
2220 &right_syscalls,
2221 )?
2222 } else {
2223 diff_vecs(
2224 which,
2225 (compared_a, &prepared_a),
2226 (compared_b, &prepared_b),
2227 opts,
2228 w,
2229 &left_syscalls,
2230 &right_syscalls,
2231 )?
2232 };
2233
2234 let summary = LogDiffSummary {
2235 diff_found,
2236 compared_left: compared_a.len(),
2237 compared_right: compared_b.len(),
2238 first_divergent_scheduler_turn: diff_found
2239 .then_some(first_position_candidate)
2240 .flatten()
2241 .map(|(turn, _)| turn),
2242 first_divergent_virtual_nanoseconds: diff_found
2243 .then_some(first_position_candidate)
2244 .flatten()
2245 .and_then(|(_, time)| time),
2246 first_divergent_record: diff_found
2247 .then_some(first_different)
2248 .flatten()
2249 .and_then(|(left_index, right_index)| left_index.or(right_index)),
2250 matched_prefix_messages: matched_prefix_for_verdict(
2254 diff_found,
2255 matched_prefix_length(&prepared_a, &prepared_b),
2256 compared_a.len(),
2257 compared_b.len(),
2258 ),
2259 first_divergent_syscall: diff_found
2260 .then_some(first_divergent_syscall_candidate)
2261 .flatten(),
2262 first_divergent_left_message: diff_found.then_some(first_divergent_left_message).flatten(),
2263 first_divergent_right_message: diff_found
2264 .then_some(first_divergent_right_message)
2265 .flatten(),
2266 refusal_reason: None,
2268 };
2269
2270 if diff_found {
2271 writeln!(w, "Done processing logs, differences found.")?;
2272 } else if summary.compared_left == 0 && summary.compared_right == 0 {
2273 writeln!(
2277 w,
2278 "Done processing logs, but ZERO {which} messages were selected on either side: \
2279 nothing was compared (no-result, not a match)."
2280 )?;
2281 } else {
2282 writeln!(
2283 w,
2284 "Done processing logs, no substantive differences found ({} | {} {which} messages compared).",
2285 summary.compared_left, summary.compared_right,
2286 )?;
2287 writeln!(
2298 w,
2299 "Logs contain {} | {} scheduler empty-run-queue kick messages",
2300 count_empty_queue_kicks(&infos_a),
2301 count_empty_queue_kicks(&infos_b),
2302 )?;
2303 let (maps_left, first_left) = maps_read_commits(&infos_a);
2324 let (maps_right, first_right) = maps_read_commits(&infos_b);
2325 let positions = if first_left.is_none() && first_right.is_none() {
2326 String::new()
2327 } else {
2328 format!(
2329 " ({} {}, {} {})",
2330 opts.side_labels.left,
2331 describe_maps_commit(first_left),
2332 opts.side_labels.right,
2333 describe_maps_commit(first_right),
2334 )
2335 };
2336 writeln!(
2337 w,
2338 "Logs contain {maps_left} | {maps_right} scheduler COMMIT records reading /proc/self/maps{positions}",
2339 )?;
2340 }
2341 Ok(summary)
2342}
2343
2344#[cfg(test)]
2345mod test {
2346 use clap::CommandFactory;
2347 use clap::Parser;
2348 use pretty_assertions::assert_eq;
2349
2350 use super::finished_syscall_at_or_before;
2351 use super::finished_syscall_number;
2352 use crate::detlog::DetLogEvent;
2353 use crate::logdiff::DetLogFilter;
2354
2355 fn record(second: usize, body: &str) -> String {
2358 format!("Apr 09 06:08:{second:02}.100 INFO detcore: {body}\n")
2359 }
2360
2361 fn structured_record(second: usize, body: &str, event: DetLogEvent) -> String {
2362 record(
2363 second,
2364 &format!("{body}{}", crate::detlog::record_suffix(event)),
2365 )
2366 }
2367
2368 fn historical(index: usize, text: &str) -> super::LogMessage<'_> {
2369 super::LogMessage {
2370 index,
2371 text,
2372 event: None,
2373 }
2374 }
2375
2376 fn indexed_text<'a>(messages: &'a [super::LogMessage<'a>]) -> Vec<(usize, &'a str)> {
2377 messages
2378 .iter()
2379 .map(|message| (message.index, message.text))
2380 .collect()
2381 }
2382
2383 fn info_opts() -> super::LogDiffOpts {
2384 super::LogDiffOpts {
2385 comparison: super::LogComparisonMode::Info,
2386 ..Default::default()
2387 }
2388 }
2389
2390 fn compare(left: &str, right: &str) -> super::PrefixComparison {
2391 super::compare_complete_prefix(left, right, &info_opts(), &mut Vec::new())
2392 .expect("comparing in-memory strings cannot fail on I/O")
2393 }
2394
2395 fn temp_log(contents: &str) -> tempfile::NamedTempFile {
2396 let file = tempfile::NamedTempFile::new().expect("create temporary log");
2397 std::fs::write(file.path(), contents).expect("write temporary log");
2398 file
2399 }
2400
2401 #[test]
2402 fn bitwise_info_v1_binds_the_complete_policy() {
2403 let labels = super::ComparisonSideLabels::new("left", "right");
2404 let options = super::bitwise_info_v1_options(labels.clone());
2405 assert!(!options.strip_lines);
2406 assert!(options.canonicalize_addresses);
2407 assert_eq!(options.comparison, super::LogComparisonMode::Info);
2408 assert_eq!(options.side_labels, labels);
2409 assert!(options.require_structured_events);
2410 assert!(!options.print_logs);
2411 assert_eq!(options.limit, 20);
2412 assert!(options.ignore_lines.is_empty());
2413 assert_eq!(options.syscall_history, 5);
2414 assert!(!options.no_color);
2415 assert!(!options.skip_commit);
2416 assert!(!options.skip_detlog);
2417 assert!(!options.git_diff);
2418 assert_eq!(
2419 options.include_detlogs,
2420 [
2421 DetLogFilter::Syscall,
2422 DetLogFilter::SyscallResult,
2423 DetLogFilter::Other,
2424 ]
2425 );
2426 }
2427
2428 #[test]
2429 fn bitwise_info_v1_matches_and_renders_current_records() -> std::io::Result<()> {
2430 let left_text = structured_record(
2431 1,
2432 &format!("DETLOG allocation={}", super::host_addr(0x1000)),
2433 DetLogEvent::Other,
2434 );
2435 let right_text = structured_record(
2436 1,
2437 &format!("DETLOG allocation={}", super::host_addr(0x9000)),
2438 DetLogEvent::Other,
2439 );
2440 let left = temp_log(&left_text);
2441 let right = temp_log(&right_text);
2442 let summary = super::try_compare_bitwise_info_v1(
2443 left.path(),
2444 right.path(),
2445 super::ComparisonSideLabels::new("left", "right"),
2446 )?;
2447 assert!(summary.matched_with_evidence());
2448 assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2449
2450 let mut rendered = Vec::new();
2451 assert_eq!(
2452 super::write_bitwise_info_v1_bytes(left_text.as_bytes(), "left", &mut rendered)?,
2453 1
2454 );
2455 let rendered = String::from_utf8(rendered).unwrap();
2456 assert!(rendered.contains("<addr1>"));
2457 assert!(!rendered.contains("0x1000"));
2458 Ok(())
2459 }
2460
2461 #[test]
2462 fn bitwise_info_v1_reports_first_divergence() -> std::io::Result<()> {
2463 let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2464 let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2465 let mut diagnostic = Vec::new();
2466 let (summary, records_left, records_right) =
2467 super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2468 left.as_bytes(),
2469 right.as_bytes(),
2470 super::ComparisonSideLabels::new("left", "right"),
2471 super::BitwiseInfoV1Diagnostics {
2472 difference_limit: 1,
2473 syscall_history: 0,
2474 no_color: true,
2475 print_logs: false,
2476 },
2477 &mut diagnostic,
2478 )?;
2479 assert!(summary.diff_found);
2480 assert!(summary.refusal_reason.is_none());
2481 assert_eq!(summary.first_divergent_record, Some(1));
2482 assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2483 assert_eq!((records_left, records_right), (1, 1));
2484 assert!(
2485 String::from_utf8(diagnostic)
2486 .unwrap()
2487 .contains("payload=left")
2488 );
2489 Ok(())
2490 }
2491
2492 #[test]
2493 fn bitwise_info_v1_diagnostics_do_not_change_the_verdict() -> std::io::Result<()> {
2494 let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2495 let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2496 let baseline = super::try_compare_bitwise_info_v1_bytes_with_records(
2497 left.as_bytes(),
2498 right.as_bytes(),
2499 super::ComparisonSideLabels::default(),
2500 )?;
2501 let mut diagnostic = Vec::new();
2502 let varied = super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2503 left.as_bytes(),
2504 right.as_bytes(),
2505 super::ComparisonSideLabels::default(),
2506 super::BitwiseInfoV1Diagnostics {
2507 difference_limit: 0,
2508 syscall_history: 10,
2509 no_color: true,
2510 print_logs: true,
2511 },
2512 &mut diagnostic,
2513 )?;
2514 assert_eq!(baseline, varied);
2515 assert!(!diagnostic.is_empty());
2516 Ok(())
2517 }
2518
2519 #[test]
2520 fn bitwise_info_v1_refuses_empty_missing_and_unreadable_inputs() -> std::io::Result<()> {
2521 let empty_left = temp_log("");
2522 let empty_right = temp_log("");
2523 let summary = super::try_compare_bitwise_info_v1(
2524 empty_left.path(),
2525 empty_right.path(),
2526 super::ComparisonSideLabels::default(),
2527 )?;
2528 assert!(!summary.matched_with_evidence());
2529 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2530
2531 let missing_parent = tempfile::tempdir()?;
2532 let missing = missing_parent.path().join("missing.log");
2533 assert!(
2534 super::try_compare_bitwise_info_v1(
2535 &missing,
2536 empty_right.path(),
2537 super::ComparisonSideLabels::default(),
2538 )
2539 .is_err()
2540 );
2541 let directory = tempfile::tempdir()?;
2542 assert!(
2543 super::try_compare_bitwise_info_v1(
2544 directory.path(),
2545 empty_right.path(),
2546 super::ComparisonSideLabels::default(),
2547 )
2548 .is_err()
2549 );
2550 Ok(())
2551 }
2552
2553 #[test]
2554 fn bitwise_info_v1_refuses_invalid_utf8_and_truncation() -> std::io::Result<()> {
2555 let valid = structured_record(1, "DETLOG payload", DetLogEvent::Other);
2556 let mut invalid = valid.clone().into_bytes();
2557 invalid.insert(invalid.len() - 1, 0x80);
2558 let error = super::try_compare_bitwise_info_v1_bytes_with_records(
2559 &invalid,
2560 valid.as_bytes(),
2561 super::ComparisonSideLabels::new("left", "right"),
2562 )
2563 .expect_err("invalid UTF-8 must refuse");
2564 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2565 assert!(error.to_string().contains("left"));
2566
2567 let truncated = temp_log(&format!("{valid}{}\n", super::TRUNCATION_MARKER));
2568 let complete = temp_log(&valid);
2569 let summary = super::try_compare_bitwise_info_v1(
2570 truncated.path(),
2571 complete.path(),
2572 super::ComparisonSideLabels::default(),
2573 )?;
2574 assert!(summary.diff_found);
2575 assert!(
2576 summary
2577 .refusal_reason
2578 .as_deref()
2579 .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
2580 "the typed result must retain the truncation refusal cause"
2581 );
2582 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2583 Ok(())
2584 }
2585
2586 #[test]
2587 fn bitwise_info_v1_requires_current_structured_events() {
2588 let historical = temp_log(&record(1, "DETLOG stable"));
2589 let error = super::try_compare_bitwise_info_v1(
2590 historical.path(),
2591 historical.path(),
2592 super::ComparisonSideLabels::new("left", "right"),
2593 )
2594 .expect_err("prose-only DETLOG must refuse");
2595 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2596 assert!(
2597 error
2598 .to_string()
2599 .contains("missing its structured DETLOG result")
2600 );
2601 }
2602
2603 #[test]
2604 fn bitwise_info_v1_complete_prefix_withholds_the_unfinished_tail() -> std::io::Result<()> {
2605 let common = structured_record(1, "DETLOG common", DetLogEvent::Other);
2606 let left = format!(
2607 "{common}{}",
2608 structured_record(2, "DETLOG unfinished-left", DetLogEvent::Other)
2609 );
2610 let right = format!(
2611 "{common}{}",
2612 structured_record(2, "DETLOG unfinished-right", DetLogEvent::Other)
2613 );
2614 let comparison = super::compare_complete_bitwise_info_v1_prefix(
2615 left.as_bytes(),
2616 right.as_bytes(),
2617 super::ComparisonSideLabels::new("left", "right"),
2618 super::BitwiseInfoV1Diagnostics::default(),
2619 &mut Vec::new(),
2620 )?;
2621 assert_eq!(comparison.records_available_left, 1);
2622 assert_eq!(comparison.records_available_right, 1);
2623 assert_eq!(comparison.records_compared, 1);
2624 assert!(comparison.summary.matched_with_evidence());
2625 assert_eq!(comparison.summary.compared_left, 1);
2626 assert_eq!(comparison.summary.compared_right, 1);
2627 Ok(())
2628 }
2629
2630 #[test]
2631 fn a_record_is_complete_only_once_the_next_one_starts() {
2632 assert_eq!(super::complete_record_count(""), 0);
2633
2634 let one = record(1, "first");
2636 assert_eq!(super::complete_record_count(&one), 0);
2637
2638 let two = format!("{}{}", record(1, "first"), record(2, "second"));
2640 assert_eq!(super::complete_record_count(&two), 1);
2641
2642 let three = format!("{two}{}", record(3, "third"));
2643 assert_eq!(super::complete_record_count(&three), 2);
2644 }
2645
2646 #[test]
2647 fn a_multiline_record_counts_once_and_is_not_split_at_its_newlines() {
2648 let multiline = format!(
2649 "{}{}",
2650 record(1, "first\n continued detail\n more detail"),
2651 record(2, "second")
2652 );
2653 assert_eq!(super::complete_record_count(&multiline), 1);
2656
2657 let prefix = super::take_complete_records(&multiline, 1).unwrap();
2658 assert!(prefix.contains("continued detail"));
2659 assert!(prefix.contains("more detail"));
2660 assert!(!prefix.contains("second"));
2661 }
2662
2663 #[test]
2664 fn asking_past_the_written_end_is_none_not_a_short_answer() {
2665 let two = format!("{}{}", record(1, "first"), record(2, "second"));
2666 assert_eq!(super::take_complete_records(&two, 0), Some(""));
2667 assert!(super::take_complete_records(&two, 1).is_some());
2668 assert_eq!(super::take_complete_records(&two, 2), None);
2671 assert_eq!(super::take_complete_records(&two, 99), None);
2672 }
2673
2674 #[test]
2675 fn a_half_written_final_record_is_never_a_difference() {
2676 let left = format!(
2679 "{}{}{}",
2680 record(1, "same"),
2681 record(2, "same"),
2682 record(3, "TAIL-LEFT")
2683 );
2684 let right = format!(
2685 "{}{}{}",
2686 record(1, "same"),
2687 record(2, "same"),
2688 record(3, "TAIL-RIGHT-AND-LONGER")
2689 );
2690
2691 let comparison = compare(&left, &right);
2692 assert!(
2693 !comparison.summary.diff_found,
2694 "an unfinished record must not read as a divergence"
2695 );
2696 assert_eq!(comparison.records_compared, 2);
2697 assert!(!comparison.one_side_is_ahead());
2698 }
2699
2700 #[test]
2701 fn a_difference_inside_the_completed_prefix_is_found() {
2702 let left = format!(
2703 "{}{}{}",
2704 record(1, "same"),
2705 record(2, "LEFT"),
2706 record(3, "tail")
2707 );
2708 let right = format!(
2709 "{}{}{}",
2710 record(1, "same"),
2711 record(2, "RIGHT"),
2712 record(3, "tail")
2713 );
2714
2715 let comparison = compare(&left, &right);
2716 assert!(comparison.summary.diff_found);
2717 assert_eq!(comparison.records_compared, 2);
2718 }
2719
2720 #[test]
2721 fn comparison_is_bounded_by_the_shorter_log_and_says_so() {
2722 let ahead = format!(
2723 "{}{}{}{}{}",
2724 record(1, "same"),
2725 record(2, "same"),
2726 record(3, "same"),
2727 record(4, "same"),
2728 record(5, "same")
2729 );
2730 let behind = format!(
2731 "{}{}{}",
2732 record(1, "same"),
2733 record(2, "same"),
2734 record(3, "same")
2735 );
2736
2737 let comparison = compare(&ahead, &behind);
2738 assert!(!comparison.summary.diff_found);
2739 assert_eq!(comparison.records_available_left, 4);
2740 assert_eq!(comparison.records_available_right, 2);
2741 assert_eq!(comparison.records_compared, 2);
2743 assert!(
2744 comparison.one_side_is_ahead(),
2745 "the caller must be able to see the comparison was reading-bound"
2746 );
2747 }
2748
2749 #[test]
2750 fn the_first_differing_record_is_located_not_just_bounded() {
2751 let build = |marker: &str| {
2753 (1..=81)
2754 .map(|index| {
2755 let body = if index == 13 { marker } else { "same" };
2756 record(index % 60, &format!("record {index} {body}"))
2757 })
2758 .collect::<String>()
2759 };
2760 let left = build("LEFT");
2761 let right = build("RIGHT");
2762
2763 let found = compare(&left, &right).summary.first_divergent_record;
2764 assert_eq!(
2765 found,
2766 Some(13),
2767 "bisection must name the record, not merely the prefix that contains it"
2768 );
2769 }
2770
2771 #[test]
2772 fn identical_logs_have_no_first_divergent_record() {
2773 let same = format!("{}{}{}", record(1, "a"), record(2, "b"), record(3, "c"));
2774 assert_eq!(compare(&same, &same).summary.first_divergent_record, None);
2775 assert_eq!(compare("", "").summary.first_divergent_record, None);
2777 }
2778
2779 #[test]
2785 fn matched_prefix_counts_leading_equal_compared_messages() {
2786 let log = |bodies: &[&str]| {
2787 bodies
2788 .iter()
2789 .enumerate()
2790 .map(|(index, body)| record(index + 1, body))
2791 .collect::<String>()
2792 };
2793 let summary = |left: &str, right: &str| {
2794 super::log_diff_summary_from_strs(left, right, &info_opts(), &mut Vec::new())
2795 .expect("comparing in-memory strings cannot fail on I/O")
2796 };
2797
2798 let same = log(&["a", "b", "c"]);
2799 let identical = summary(&same, &same);
2800 assert!(!identical.diff_found);
2801 assert_eq!(identical.matched_prefix_messages, Some(3));
2802
2803 let at_first = summary(&log(&["X", "b", "c"]), &log(&["Y", "b", "c"]));
2804 assert!(at_first.diff_found);
2805 assert_eq!(at_first.matched_prefix_messages, Some(0));
2806 assert_eq!(at_first.first_divergent_record, Some(1));
2807
2808 let at_third = summary(&log(&["a", "b", "X", "d"]), &log(&["a", "b", "Y", "d"]));
2809 assert_eq!(at_third.matched_prefix_messages, Some(2));
2810 assert_eq!(at_third.first_divergent_record, Some(3));
2811
2812 let shorter = summary(&log(&["a", "b"]), &log(&["a", "b", "c", "d"]));
2813 assert!(shorter.diff_found);
2814 assert_eq!((shorter.compared_left, shorter.compared_right), (2, 4));
2815 assert_eq!(shorter.matched_prefix_messages, Some(2));
2816
2817 let with_debug = format!(
2818 "{}Apr 09 06:08:02.100 DEBUG detcore: not compared\n{}",
2819 record(1, "a"),
2820 record(3, "X")
2821 );
2822 let unit_mismatch = summary(&with_debug, &log(&["a", "Y"]));
2823 assert_eq!(unit_mismatch.matched_prefix_messages, Some(1));
2824 assert_eq!(unit_mismatch.first_divergent_record, Some(3));
2825
2826 let empty = summary("", "");
2827 assert_eq!(empty.matched_prefix_messages, Some(0));
2828 }
2829
2830 #[test]
2835 fn a_matched_prefix_is_reported_only_where_the_exact_scan_agrees_with_the_verdict() {
2836 for (case, diff_found, prefix, left, right, expected) in [
2838 ("full match", false, 3, 3, 3, Some(3)),
2839 ("empty match", false, 0, 0, 0, Some(0)),
2840 ("divergence inside both", true, 2, 4, 4, Some(2)),
2841 ("divergence at the first message", true, 0, 3, 3, Some(0)),
2842 ("strict prefix", true, 2, 2, 5, Some(2)),
2843 ("match over unequal counts", false, 2, 1, 2, None),
2844 ("match of a strict prefix", false, 1, 1, 2, None),
2845 ("match the exact scan stops inside", false, 1, 2, 2, None),
2846 ("divergence with nothing unmatched", true, 3, 3, 3, None),
2847 ("divergence of two empty streams", true, 0, 0, 0, None),
2848 ] {
2849 assert_eq!(
2850 super::matched_prefix_for_verdict(diff_found, prefix, left, right),
2851 expected,
2852 "{case}"
2853 );
2854 }
2855 }
2856
2857 #[test]
2863 fn a_git_diff_match_over_unequal_counts_reports_no_matched_prefix() {
2864 let left = "Apr 09 06:08:01.100 INFO detcore: a\nINFO detcore: b\n";
2865 let right = format!("{}{}", record(1, "a"), record(2, "b"));
2866
2867 let git_opts = super::LogDiffOpts {
2868 git_diff: true,
2869 ..info_opts()
2870 };
2871 let git = super::log_diff_summary_from_strs(left, &right, &git_opts, &mut Vec::new())
2872 .expect("comparing in-memory strings cannot fail on I/O");
2873 assert_eq!((git.compared_left, git.compared_right), (1, 2));
2874 assert!(
2875 !git.diff_found,
2876 "git diff -w must accept the split record (this test needs git on PATH)"
2877 );
2878 assert_eq!(
2879 git.matched_prefix_messages, None,
2880 "a match over 1 | 2 compared messages has no prefix covering both streams"
2881 );
2882
2883 let exact = super::log_diff_summary_from_strs(left, &right, &info_opts(), &mut Vec::new())
2884 .expect("comparing in-memory strings cannot fail on I/O");
2885 assert_eq!((exact.compared_left, exact.compared_right), (1, 2));
2886 assert!(exact.diff_found);
2887 assert_eq!(exact.matched_prefix_messages, Some(0));
2888 }
2889
2890 #[test]
2899 fn an_untagged_line_is_refused_by_name_rather_than_panicking() {
2900 let log = "detcore-dbt: background client thread entered\n";
2908 let error = super::extract_log_messages(log)
2909 .expect_err("an untagged line must refuse, not be admitted");
2910 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2911 let message = error.to_string();
2912 assert!(
2913 message.contains("detcore-dbt: background client thread entered"),
2914 "the refusal must name the offending line, got: {message}"
2915 );
2916 assert!(
2917 message.contains("no ERROR/WARN/INFO/DEBUG/TRACE tag"),
2918 "the refusal must say why, got: {message}"
2919 );
2920 }
2921
2922 #[test]
2925 fn a_fully_tagged_log_still_parses() {
2926 let log = "2026-08-24T20:16:17.897469Z INFO detcore: DETLOG a\n\
2927 2026-08-24T20:16:17.897470Z WARN detcore: b\n";
2928 let records = super::extract_log_messages(log).expect("tagged log parses");
2929 assert_eq!(records.len(), 2);
2930 }
2931
2932 #[test]
2935 fn historical_syscall_numbers_remain_readable() {
2936 assert_eq!(
2937 finished_syscall_number(&historical(
2938 0,
2939 "DETLOG [syscall][detcore, dtid 3] finish syscall #37: write(1, 0x5, 6) = Ok(6)"
2940 )),
2941 Some(37)
2942 );
2943 assert_eq!(
2945 finished_syscall_number(&historical(
2946 0,
2947 "DETLOG [syscall][detcore, dtid 3] inbound syscall: brk(NULL) = ?"
2948 )),
2949 None
2950 );
2951 assert_eq!(
2952 finished_syscall_number(&historical(0, "no syscall here")),
2953 None
2954 );
2955 }
2956
2957 #[test]
2964 fn the_syscall_count_is_the_last_one_completed_before_the_divergence() {
2965 let finished = |n: u64| format!("finish syscall #{n}: write(1, 0x5, 6) = Ok(6)");
2966 let a = finished(2);
2967 let b = finished(37);
2968 let syscalls = vec![historical(10, a.as_str()), historical(90, b.as_str())];
2969 assert_eq!(finished_syscall_at_or_before(&syscalls, 98), Some(37));
2970 assert_eq!(finished_syscall_at_or_before(&syscalls, 90), Some(37));
2971 assert_eq!(finished_syscall_at_or_before(&syscalls, 50), Some(2));
2972 assert_eq!(
2973 finished_syscall_at_or_before(&syscalls, 9),
2974 None,
2975 "a divergence before any syscall completed has no syscall count, \
2976 and that is a state rather than a missing value"
2977 );
2978 }
2979
2980 #[test]
2981 fn structured_positions_and_syscall_counts_are_authoritative() -> std::io::Result<()> {
2982 let run = |turn: u64, time: u64, syscall: u64, value: u64| {
2983 format!(
2984 "{}{}{}",
2985 structured_record(
2986 1,
2987 "COMMIT turn 999 at time 999",
2988 DetLogEvent::SchedulerCommit {
2989 scheduler_turn: turn,
2990 virtual_nanoseconds: time,
2991 internal_io_poll: false,
2992 runtime_maps_read: false,
2993 },
2994 ),
2995 structured_record(
2996 2,
2997 "DETLOG [syscall] finish syscall #999: write = Ok(1)",
2998 DetLogEvent::SyscallResult {
2999 finished_syscall_number: syscall,
3000 },
3001 ),
3002 structured_record(3, &format!("DETLOG value={value}"), DetLogEvent::Other),
3003 )
3004 };
3005 let options = super::LogDiffOpts {
3006 require_structured_events: true,
3007 ..Default::default()
3008 };
3009
3010 let original = super::log_diff_summary_from_strs(
3011 run(17, 123, 37, 1),
3012 run(17, 123, 37, 2),
3013 &options,
3014 &mut Vec::new(),
3015 )?;
3016 assert_eq!(original.first_divergent_scheduler_turn, Some(17));
3017 assert_eq!(original.first_divergent_virtual_nanoseconds, Some(123));
3018 assert_eq!(original.first_divergent_syscall, Some(37));
3019
3020 let mutated = super::log_diff_summary_from_strs(
3021 run(18, 124, 38, 1),
3022 run(18, 124, 38, 2),
3023 &options,
3024 &mut Vec::new(),
3025 )?;
3026 assert_eq!(mutated.first_divergent_scheduler_turn, Some(18));
3027 assert_eq!(mutated.first_divergent_virtual_nanoseconds, Some(124));
3028 assert_eq!(mutated.first_divergent_syscall, Some(38));
3029 Ok(())
3030 }
3031
3032 #[test]
3033 fn current_verification_refuses_a_missing_structured_record_by_name() {
3034 let options = super::LogDiffOpts {
3035 require_structured_events: true,
3036 ..Default::default()
3037 };
3038 let error = super::log_diff_summary_from_strs(
3039 record(1, "DETLOG value=1"),
3040 record(1, "DETLOG value=1"),
3041 &options,
3042 &mut Vec::new(),
3043 )
3044 .expect_err("current verification must not fall back to prose");
3045 assert!(
3046 error
3047 .to_string()
3048 .contains("missing its structured DETLOG result"),
3049 "refusal must name the missing result: {error}"
3050 );
3051 }
3052
3053 #[test]
3054 fn structured_kind_not_the_human_tag_selects_the_syscall_class() {
3055 let text = "INFO detcore: DETLOG [syscall] inbound syscall: read = ?";
3056 let other = super::LogMessage {
3057 index: 0,
3058 text,
3059 event: Some(DetLogEvent::Other),
3060 };
3061 let syscall = super::LogMessage {
3062 event: Some(DetLogEvent::Syscall),
3063 ..other
3064 };
3065 assert!(!super::is_detlog_syscall(&other));
3066 assert!(super::is_detlog_syscall(&syscall));
3067 }
3068
3069 #[test]
3070 fn structured_scheduler_flags_control_filtering_and_retained_counts() {
3071 let text = "INFO detcore::scheduler: COMMIT turn 999 at time 999";
3072 let internal = super::LogMessage {
3073 index: 0,
3074 text,
3075 event: Some(DetLogEvent::SchedulerCommit {
3076 scheduler_turn: 17,
3077 virtual_nanoseconds: 123,
3078 internal_io_poll: true,
3079 runtime_maps_read: false,
3080 }),
3081 };
3082 let maps_read = super::LogMessage {
3083 event: Some(DetLogEvent::SchedulerCommit {
3084 scheduler_turn: 17,
3085 virtual_nanoseconds: 123,
3086 internal_io_poll: false,
3087 runtime_maps_read: true,
3088 }),
3089 ..internal
3090 };
3091
3092 assert!(
3093 super::LogDiffOpts::default()
3094 .filter_deterministic(&[internal])
3095 .is_empty()
3096 );
3097 assert_eq!(
3098 super::LogDiffOpts::default()
3099 .filter_deterministic(&[maps_read])
3100 .len(),
3101 1
3102 );
3103 assert_eq!(super::maps_read_commits(&[internal]), (0, None));
3104 assert_eq!(
3105 super::maps_read_commits(&[maps_read]),
3106 (1, Some((17, Some(123))))
3107 );
3108 }
3109
3110 #[test]
3111 fn nothing_written_yet_is_a_no_result_not_a_match() {
3112 let comparison = compare(&record(1, "first"), &record(1, "first"));
3114 assert_eq!(comparison.records_compared, 0);
3115 assert!(!comparison.summary.diff_found);
3116 assert!(
3117 !comparison.summary.matched_with_evidence(),
3118 "comparing zero records must never report a match"
3119 );
3120 }
3121
3122 #[test]
3123 fn unsafe_strip_lines_cli_name_and_warning_are_explicit() {
3124 let options = super::LogDiffOpts::try_parse_from(["log-diff", "--unsafe-strip-lines"])
3125 .expect("the explicitly unsafe spelling should parse");
3126 assert!(options.strip_lines);
3127
3128 assert!(super::LogDiffOpts::try_parse_from(["log-diff", "--strip-lines"]).is_err());
3129
3130 let mut help = Vec::new();
3131 super::LogDiffOpts::command()
3132 .write_long_help(&mut help)
3133 .expect("write clap help");
3134 let help = String::from_utf8(help).expect("help is UTF-8");
3135 assert!(help.contains("--unsafe-strip-lines"));
3136 assert!(help.contains("erases timestamps and syscall values"));
3137 assert!(help.contains("make a failing parity diff pass"));
3138 assert!(help.contains("doing so is cheating"));
3139 assert!(!help.contains("--strip-lines"));
3140 }
3141
3142 #[test]
3143 fn test_compare_with_no_color() {
3144 let str1 = "test1";
3145 let str2 = "test2";
3146
3147 assert_eq!(
3148 format!("{}", super::Comparison::new(true, str1, str2))
3149 .split('\n')
3150 .collect::<Vec<&str>>(),
3151 ["Diff < left / right > :", "<\"test1\"", ">\"test2\"", "",]
3152 );
3153 }
3154
3155 #[test]
3156 fn test_compare_with_color() {
3157 let str1 = "test1";
3158 let str2 = "test2";
3159
3160 assert_eq!(
3161 format!("{}", super::Comparison::new(false, str1, str2))
3162 .split('\n')
3163 .collect::<Vec<&str>>(),
3164 [
3165 "\u{1b}[1mDiff\u{1b}[0m \u{1b}[31m< left\u{1b}[0m / \u{1b}[32mright >\u{1b}[0m :",
3166 "\u{1b}[31m<\"test\u{1b}[0m\u{1b}[1;48;5;52;31m1\u{1b}[0m\u{1b}[31m\"\u{1b}[0m",
3167 "\u{1b}[32m>\"test\u{1b}[0m\u{1b}[1;48;5;22;32m2\u{1b}[0m\u{1b}[32m\"\u{1b}[0m",
3168 "",
3169 ]
3170 );
3171 }
3172
3173 #[test]
3180 fn truncated_logs_are_refused_and_untruncated_logs_still_match() -> std::io::Result<()> {
3181 let body = "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)\n2022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)";
3182 let marked = format!("{body}\n{}\n", super::TRUNCATION_MARKER);
3183 let options = super::LogDiffOpts {
3184 no_color: true,
3185 ..Default::default()
3186 };
3187
3188 let clean = super::log_diff_summary_from_strs(body, body, &options, &mut Vec::new())?;
3191 assert!(!clean.diff_found, "identical untruncated logs must match");
3192 assert!(
3193 clean.matched_with_evidence(),
3194 "the untruncated match must carry nonzero compared counts, got {clean:?}"
3195 );
3196 assert!(clean.refusal_reason.is_none());
3197
3198 for (label, left, right) in [
3201 ("left", marked.as_str(), body),
3202 ("right", body, marked.as_str()),
3203 ("both", marked.as_str(), marked.as_str()),
3204 ] {
3205 let mut out = Vec::new();
3206 let summary = super::log_diff_summary_from_strs(left, right, &options, &mut out)?;
3207 assert!(
3208 summary.diff_found,
3209 "{label}: a truncated log must not be reported as a match"
3210 );
3211 assert_eq!(
3212 (summary.compared_left, summary.compared_right),
3213 (0, 0),
3214 "{label}: nothing was compared, so the counts must not claim otherwise"
3215 );
3216 assert!(
3217 summary
3218 .refusal_reason
3219 .as_deref()
3220 .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
3221 "{label}: the typed result must retain the printed refusal cause"
3222 );
3223 assert!(
3224 !summary.matched_with_evidence(),
3225 "{label}: the evidence predicate must also refuse"
3226 );
3227 assert_eq!(
3228 summary.matched_prefix_messages, None,
3229 "{label}: a refused comparison measured no matched prefix"
3230 );
3231 let text = String::from_utf8(out).unwrap();
3232 assert!(
3233 text.contains("REFUSING to compare"),
3234 "{label}: the refusal must be stated, got: {text}"
3235 );
3236 assert!(
3237 !text.contains("no substantive differences found"),
3238 "{label}: a refusal must never print the match line, got: {text}"
3239 );
3240 }
3241
3242 Ok(())
3243 }
3244
3245 #[test]
3256 fn marker_text_in_guest_content_is_not_truncation() -> std::io::Result<()> {
3257 let options = super::LogDiffOpts {
3258 no_color: true,
3259 ..Default::default()
3260 };
3261 let marker = super::TRUNCATION_MARKER;
3262 let guest_path_line = format!(
3265 "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] inbound syscall: \
3266 statx(-100, 0x7fff -> \"/tmp/{marker} probe\", AtFlags(AT_NO_AUTOMOUNT), 2, 0x7fff) \
3267 = ?"
3268 );
3269 let tail_line = "2022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: \
3270 write(1, 0x2000, 1) = Ok(1)";
3271
3272 for (label, text) in [
3273 (
3275 "marker inside the final DETLOG line",
3276 guest_path_line.clone(),
3277 ),
3278 (
3282 "marker on its own line, followed by more log",
3283 format!("{guest_path_line}\n{marker}\n{tail_line}"),
3284 ),
3285 (
3287 "marker at end of file but mid-line",
3288 format!("{tail_line}\nsomething {marker}"),
3289 ),
3290 ] {
3291 assert!(
3292 !super::log_was_truncated(&text),
3293 "{label}: an untruncated log must not be classified as truncated"
3294 );
3295 let mut out = Vec::new();
3296 let summary = super::log_diff_summary_from_strs(&text, &text, &options, &mut out)?;
3297 let printed = String::from_utf8(out).unwrap();
3298 assert!(
3299 !printed.contains("REFUSING to compare"),
3300 "{label}: must be compared, not refused, got: {printed}"
3301 );
3302 assert!(
3303 !summary.diff_found,
3304 "{label}: identical logs must compare equal, got {summary:?}"
3305 );
3306 assert!(
3307 summary.matched_with_evidence(),
3308 "{label}: the match must carry nonzero compared counts, got {summary:?}"
3309 );
3310 }
3311
3312 let really_truncated = format!("{guest_path_line}\n{marker}\n");
3315 assert!(
3316 super::log_was_truncated(&really_truncated),
3317 "a log ending in the marker line IS truncated and must still be caught"
3318 );
3319 let mut out = Vec::new();
3320 let summary = super::log_diff_summary_from_strs(
3321 &really_truncated,
3322 &really_truncated,
3323 &options,
3324 &mut out,
3325 )?;
3326 assert!(summary.diff_found, "real truncation must still be refused");
3327 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
3328 assert!(
3329 String::from_utf8(out)
3330 .unwrap()
3331 .contains("REFUSING to compare"),
3332 "real truncation must still print the refusal"
3333 );
3334
3335 Ok(())
3336 }
3337
3338 #[test]
3339 fn test_log_diff_with_color() -> std::io::Result<()> {
3340 let str1 = "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #11: mmap(NULL, 3954880, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_DENYWRITE, 3, 0) = Ok(140737347883008)";
3341 let str2 = "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #15: mmap(NULL, 3954880, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_DENYWRITE, 3, 0) = Ok(140737347883008)";
3342 let mut result = Vec::<u8>::new();
3343
3344 super::log_diff_from_strs(
3345 str1,
3346 str2,
3347 &super::LogDiffOpts {
3348 limit: 1,
3349 strip_lines: false,
3350 canonicalize_addresses: false,
3351 comparison: super::LogComparisonMode::Deterministic,
3352 side_labels: super::ComparisonSideLabels::default(),
3353 require_structured_events: false,
3354 print_logs: false,
3355 syscall_history: 5,
3356 no_color: false,
3357 skip_commit: false,
3358 skip_detlog: false,
3359 git_diff: false,
3360 ignore_lines: Vec::new(),
3361 include_detlogs: vec![
3362 DetLogFilter::Syscall,
3363 DetLogFilter::SyscallResult,
3364 DetLogFilter::Other,
3365 ],
3366 },
3367 &mut result,
3368 )?;
3369
3370 let output = String::from_utf8(result).unwrap();
3371 assert!(output.contains(" Comparing DETLOG messages..."));
3372 assert!(output.contains("Mismatch at log messages 0 (run 1) and 0 (run 2)"));
3373 assert!(output.contains("run 1, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #11"));
3374 assert!(output.contains("run 2, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #15"));
3375 assert!(!output.contains("eliding the rest"));
3376
3377 Ok(())
3378 }
3379
3380 #[test]
3381 fn test_log_diff_reports_each_runs_syscall_context() -> std::io::Result<()> {
3382 let log_a = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
33832022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"#;
3384 let log_b = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
33852022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"#;
3386 let mut result = Vec::new();
3387 let options = super::LogDiffOpts {
3388 no_color: true,
3389 syscall_history: 1,
3390 ..Default::default()
3391 };
3392
3393 assert!(super::log_diff_from_strs(
3394 log_a,
3395 log_b,
3396 &options,
3397 &mut result
3398 )?);
3399
3400 let output = String::from_utf8(result).unwrap();
3401 assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3402 assert!(output.contains("run 1, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"));
3403 assert!(output.contains("run 2, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"));
3404 assert!(output.contains("Prior completed syscalls for run 1:"));
3405 assert!(output.contains("Prior completed syscalls for run 2:"));
3406 assert_eq!(output.matches("finish syscall #1: read").count(), 2);
3407 Ok(())
3408 }
3409
3410 #[test]
3411 fn custom_side_labels_cover_mismatch_history_and_tail_diagnostics() -> std::io::Result<()> {
3412 let left = format!(
3413 "{}{}",
3414 record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3415 record(2, "DETLOG [syscall] finish syscall #2: write = Ok(1)"),
3416 );
3417 let right = format!(
3418 "{}{}",
3419 record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3420 record(2, "DETLOG [syscall] finish syscall #2: write = Err(5)"),
3421 );
3422 let options = super::LogDiffOpts {
3423 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3424 syscall_history: 1,
3425 no_color: true,
3426 ..Default::default()
3427 };
3428 let mut output = Vec::new();
3429 assert!(super::log_diff_from_strs(
3430 &left,
3431 &right,
3432 &options,
3433 &mut output
3434 )?);
3435 let output = String::from_utf8(output).unwrap();
3436 assert!(output.contains("Mismatch at log messages 2 (the recording) and 2 (the replay)"));
3437 assert!(output.contains("the recording, log message 2:"));
3438 assert!(output.contains("the replay, log message 2:"));
3439 assert!(output.contains("Prior completed syscalls for the recording:"));
3440 assert!(output.contains("Prior completed syscalls for the replay:"));
3441 assert!(!output.contains("run 1") && !output.contains("run 2"));
3442
3443 let left = record(1, "DETLOG stable");
3444 let right = format!("{left}{}", record(2, "DETLOG extra"));
3445 let mut output = Vec::new();
3446 assert!(super::log_diff_from_strs(
3447 &left,
3448 &right,
3449 &options,
3450 &mut output
3451 )?);
3452 let output = String::from_utf8(output).unwrap();
3453 assert!(
3454 output.contains("The replay contains 1 extra messages not matched in the recording.")
3455 );
3456 assert!(!output.contains("run 1") && !output.contains("run 2"));
3457
3458 let mut output = Vec::new();
3459 assert!(super::log_diff_from_strs(
3460 &right,
3461 &left,
3462 &options,
3463 &mut output
3464 )?);
3465 let output = String::from_utf8(output).unwrap();
3466 assert!(
3467 output.contains("The recording contains 1 extra messages not matched in the replay.")
3468 );
3469 Ok(())
3470 }
3471
3472 fn printed_labeled_log<'a>(output: &'a str, label: &str) -> &'a str {
3473 let start = format!("--- begin {label} compared log ---\n");
3474 let end = format!("--- end {label} compared log ---\n");
3475 output
3476 .split_once(&start)
3477 .expect("printed log start marker")
3478 .1
3479 .split_once(&end)
3480 .expect("printed log end marker")
3481 .0
3482 }
3483
3484 #[test]
3485 fn custom_side_labels_name_printed_logs() -> std::io::Result<()> {
3486 let options = super::LogDiffOpts {
3487 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3488 print_logs: true,
3489 no_color: true,
3490 ..Default::default()
3491 };
3492 let mut output = Vec::new();
3493 let summary = super::log_diff_summary_from_strs(
3494 record(1, "DETLOG recorded"),
3495 record(1, "DETLOG replayed"),
3496 &options,
3497 &mut output,
3498 )?;
3499 assert!(summary.diff_found);
3500 let output = String::from_utf8(output).unwrap();
3501 assert_eq!(
3502 printed_labeled_log(&output, "the recording"),
3503 "INFO detcore: DETLOG recorded\n"
3504 );
3505 assert_eq!(
3506 printed_labeled_log(&output, "the replay"),
3507 "INFO detcore: DETLOG replayed\n"
3508 );
3509 assert!(!output.contains("begin run 1") && !output.contains("begin run 2"));
3510 Ok(())
3511 }
3512
3513 fn printed_log(output: &str, run: u8) -> &str {
3514 let start = format!("--- begin run {run} compared log ---\n");
3515 let end = format!("--- end run {run} compared log ---\n");
3516 output
3517 .split_once(&start)
3518 .expect("printed log start marker")
3519 .1
3520 .split_once(&end)
3521 .expect("printed log end marker")
3522 .0
3523 }
3524
3525 #[test]
3526 fn printed_logs_are_the_exact_selected_comparator_inputs() -> std::io::Result<()> {
3527 let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG value=101\n\
35282026-08-15T01:02:03.000001Z INFO unrelated: omitted value=303";
3529 let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG value=202\n\
35302026-08-15T04:05:06.000001Z INFO unrelated: omitted value=404";
3531
3532 let exact = super::LogDiffOpts {
3533 print_logs: true,
3534 no_color: true,
3535 ..Default::default()
3536 };
3537 let mut exact_output = Vec::new();
3538 let exact_summary =
3539 super::log_diff_summary_from_strs(left, right, &exact, &mut exact_output)?;
3540 let exact_output = String::from_utf8(exact_output).unwrap();
3541
3542 assert!(exact_summary.diff_found);
3543 assert!(exact_output.contains("Comparison policy: Deterministic\n"));
3544 assert_eq!(
3545 printed_log(&exact_output, 1).as_bytes(),
3546 b"INFO detcore: DETLOG value=101\n"
3547 );
3548 assert_eq!(
3549 printed_log(&exact_output, 2).as_bytes(),
3550 b"INFO detcore: DETLOG value=202\n"
3551 );
3552
3553 let stripped = super::LogDiffOpts {
3554 strip_lines: true,
3555 print_logs: true,
3556 no_color: true,
3557 ..Default::default()
3558 };
3559 let mut stripped_output = Vec::new();
3560 let stripped_summary =
3561 super::log_diff_summary_from_strs(left, right, &stripped, &mut stripped_output)?;
3562 let stripped_output = String::from_utf8(stripped_output).unwrap();
3563
3564 assert!(stripped_summary.matched_with_evidence());
3565 assert!(stripped_output.contains("Comparison policy: Stripped\n"));
3566 assert_eq!(
3567 printed_log(&stripped_output, 1).as_bytes(),
3568 b"INFO detcore: DETLOG value=<NUM>\n"
3569 );
3570 assert_eq!(
3571 printed_log(&stripped_output, 1),
3572 printed_log(&stripped_output, 2)
3573 );
3574 assert_ne!(
3575 printed_log(&exact_output, 1),
3576 printed_log(&stripped_output, 1)
3577 );
3578 Ok(())
3579 }
3580
3581 #[test]
3582 fn printed_policy_name_tracks_the_selected_scope_and_normalization() -> std::io::Result<()> {
3583 let log = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n\
35842026-08-15T01:02:03.000001Z DEBUG unrelated: diagnostic=2";
3585
3586 let cases = [
3587 (
3588 super::LogDiffOpts {
3589 comparison: super::LogComparisonMode::Info,
3590 print_logs: true,
3591 no_color: true,
3592 ..Default::default()
3593 },
3594 "Comparison policy: Info\n",
3595 "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n",
3596 ),
3597 (
3598 super::LogDiffOpts {
3599 comparison: super::LogComparisonMode::FullTrace,
3600 print_logs: true,
3601 no_color: true,
3602 ..Default::default()
3603 },
3604 "Comparison policy: FullTrace\n",
3605 "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\nDEBUG unrelated: diagnostic=2\n",
3606 ),
3607 (
3608 super::LogDiffOpts {
3609 comparison: super::LogComparisonMode::Deterministic,
3610 canonicalize_addresses: true,
3611 print_logs: true,
3612 no_color: true,
3613 ..Default::default()
3614 },
3615 "Comparison policy: Deterministic with Canonical host-address normalization\n",
3616 "INFO detcore: DETLOG stable=1 address=<addr1>\n",
3617 ),
3618 (
3619 super::LogDiffOpts {
3620 comparison: super::LogComparisonMode::Deterministic,
3621 strip_lines: true,
3622 print_logs: true,
3623 no_color: true,
3624 ..Default::default()
3625 },
3626 "Comparison policy: Stripped\n",
3627 "INFO detcore: DETLOG stable=<NUM> address=<hostaddr <ADDR>>\n",
3628 ),
3629 ];
3630
3631 for (options, expected_name, expected_log) in cases {
3632 let mut output = Vec::new();
3633 let summary = super::log_diff_summary_from_strs(log, log, &options, &mut output)?;
3634 let output = String::from_utf8(output).unwrap();
3635 assert!(summary.matched_with_evidence());
3636 assert!(output.contains(expected_name), "{output}");
3637 assert_eq!(printed_log(&output, 1), expected_log);
3638 assert_eq!(printed_log(&output, 2), expected_log);
3639 }
3640 Ok(())
3641 }
3642
3643 #[test]
3644 fn printed_canonical_policy_uses_the_info_scope_and_address_ordinals() -> std::io::Result<()> {
3645 let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1\n\
36462026-08-15T01:02:03.000001Z INFO unrelated: value=101 address=<hostaddr 0xaaaa>";
3647 let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG stable=1\n\
36482026-08-15T04:05:06.000001Z INFO unrelated: value=202 address=<hostaddr 0xbbbb>";
3649
3650 let deterministic = super::LogDiffOpts {
3651 print_logs: true,
3652 no_color: true,
3653 ..Default::default()
3654 };
3655 let mut deterministic_output = Vec::new();
3656 let deterministic_summary = super::log_diff_summary_from_strs(
3657 left,
3658 right,
3659 &deterministic,
3660 &mut deterministic_output,
3661 )?;
3662 let deterministic_output = String::from_utf8(deterministic_output).unwrap();
3663 assert!(deterministic_summary.matched_with_evidence());
3664 assert!(deterministic_output.contains("Comparison policy: Deterministic\n"));
3665 assert_eq!(
3666 printed_log(&deterministic_output, 1).as_bytes(),
3667 b"INFO detcore: DETLOG stable=1\n"
3668 );
3669 assert_eq!(
3670 printed_log(&deterministic_output, 1),
3671 printed_log(&deterministic_output, 2)
3672 );
3673
3674 let options = super::LogDiffOpts {
3675 comparison: super::LogComparisonMode::Info,
3676 canonicalize_addresses: true,
3677 print_logs: true,
3678 no_color: true,
3679 ..Default::default()
3680 };
3681 let mut output = Vec::new();
3682 let summary = super::log_diff_summary_from_strs(left, right, &options, &mut output)?;
3683 let output = String::from_utf8(output).unwrap();
3684
3685 assert!(summary.diff_found);
3686 assert!(output.contains("Comparison policy: Canonical\n"));
3687 assert_eq!(
3688 printed_log(&output, 1).as_bytes(),
3689 b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=101 address=<addr1>\n"
3690 );
3691 assert_eq!(
3692 printed_log(&output, 2).as_bytes(),
3693 b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=202 address=<addr1>\n"
3694 );
3695 Ok(())
3696 }
3697
3698 #[test]
3699 fn test_full_trace_detects_unnormalized_timing_difference() -> std::io::Result<()> {
3700 let log_a = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 100) = Ok(0)";
3701 let log_b = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 101) = Ok(0)";
3702 let normalized = super::LogDiffOpts {
3703 strip_lines: true,
3704 no_color: true,
3705 ..Default::default()
3706 };
3707
3708 assert!(!super::log_diff_from_strs(
3709 log_a,
3710 log_b,
3711 &normalized,
3712 &mut Vec::new()
3713 )?);
3714
3715 let verbose = super::LogDiffOpts {
3716 comparison: super::LogComparisonMode::FullTrace,
3717 strip_lines: false,
3718 syscall_history: 1,
3719 no_color: true,
3720 ..Default::default()
3721 };
3722 let mut result = Vec::new();
3723 assert!(super::log_diff_from_strs(
3724 log_a,
3725 log_b,
3726 &verbose,
3727 &mut result
3728 )?);
3729
3730 let output = String::from_utf8(result).unwrap();
3731 assert!(output.contains("Comparing full trace messages"));
3732 assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 100)"));
3733 assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 101)"));
3734 assert!(output.contains("run 1"));
3735 assert!(output.contains("run 2"));
3736 Ok(())
3737 }
3738
3739 #[test]
3740 fn info_scope_compares_info_exactly_without_promoting_debug_diagnostics() -> std::io::Result<()>
3741 {
3742 let stable_info = "2026-08-06T01:00:00.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: write(1, 0x2, 1) = Ok(1)";
3743 let left = format!(
3744 "{stable_info}\n2026-08-06T01:00:00.000001Z DEBUG detcore: diagnostic host timing=100"
3745 );
3746 let right = format!(
3747 "{stable_info}\n2026-08-06T01:00:00.000002Z DEBUG detcore: diagnostic host timing=200"
3748 );
3749 let info = super::LogDiffOpts {
3750 comparison: super::LogComparisonMode::Info,
3751 no_color: true,
3752 ..Default::default()
3753 };
3754
3755 let matched = super::log_diff_summary_from_strs(&left, &right, &info, &mut Vec::new())?;
3758 assert!(matched.matched_with_evidence());
3759 assert_eq!(matched.compared_left, 1);
3760 assert_eq!(matched.compared_right, 1);
3761
3762 let divergent_info = right.replace("write(1, 0x2, 1)", "write(1, 0x6, 1)");
3764 let diverged =
3765 super::log_diff_summary_from_strs(&left, divergent_info, &info, &mut Vec::new())?;
3766 assert!(diverged.diff_found);
3767
3768 let full_trace = super::LogDiffOpts {
3771 comparison: super::LogComparisonMode::FullTrace,
3772 no_color: true,
3773 ..Default::default()
3774 };
3775 let debug_diverged =
3776 super::log_diff_summary_from_strs(left, right, &full_trace, &mut Vec::new())?;
3777 assert!(debug_diverged.diff_found);
3778 Ok(())
3779 }
3780
3781 #[test]
3782 fn test_log_diff_compares_detlog() -> std::io::Result<()> {
3783 let log_file_a = r#"2022-09-06T14:15:47.891501Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x602000-0x623000 rw-p 0 0:0 0 [heap] -> 74b43faf7b78ace9443772ef63a30f66feaf9bd320256c82b8bd880634d19d46
37842022-09-06T14:15:48.903997Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 7984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
37852022-09-06T14:15:48.904049Z INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
37862022-09-06T14:15:48.904049Z INFO detcore: COMMIT 2
37872022-09-06T14:15:48.904782Z INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3788
3789 let log_file_b = r#"2022-09-06T14:15:47.891501Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x602000-0x623000 rw-p 0 0:0 0 [heap] -> 74b43faf7b78ace9443772ef63a30f66feaf9bd320256c82b8bd880634d19d46
37902022-09-06T14:15:47.903997Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 1984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
37912022-09-06T14:15:47.904049Z INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
37922022-09-06T14:15:47.904049Z INFO detcore: COMMIT 1
37932022-09-06T14:15:47.904782Z INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3794 let mut result = Vec::<u8>::new();
3795
3796 let log_options = super::LogDiffOpts {
3797 no_color: true,
3798 git_diff: false,
3799 ..Default::default()
3800 };
3801 super::log_diff_from_strs(log_file_a, log_file_b, &log_options, &mut result)?;
3802
3803 let output = String::from_utf8(result).unwrap();
3804 assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3805 assert!(output.contains("Mismatch at log messages 4 (run 1) and 4 (run 2)"));
3806 assert!(output.contains("INFO detcore: COMMIT 2"));
3807 assert!(output.contains("INFO detcore: COMMIT 1"));
3808
3809 Ok(())
3810 }
3811
3812 #[test]
3813 fn test_filter_deterministic() {
3814 let opts = super::LogDiffOpts {
3815 include_detlogs: vec![
3816 DetLogFilter::Syscall,
3817 DetLogFilter::SyscallResult,
3818 DetLogFilter::Other,
3819 ],
3820 ..Default::default()
3821 };
3822
3823 let v = opts.filter_deterministic(
3824 &[
3825 historical(
3826 1,
3827 "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
3828 ),
3829 historical(
3830 2,
3831 "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15...",
3832 ),
3833 historical(
3834 3,
3835 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3836 ),
3837 ],
3838 );
3839
3840 assert_eq!(
3841 indexed_text(&v),
3842 vec![
3843 (
3844 2,
3845 "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15..."
3846 ),
3847 (
3848 3,
3849 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3850 ),
3851 ]
3852 );
3853 }
3854
3855 #[test]
3856 fn test_filter_deterministic_with_filter() {
3857 let opts = super::LogDiffOpts {
3858 include_detlogs: vec![DetLogFilter::Syscall],
3859 skip_commit: true,
3860 ..Default::default()
3861 };
3862
3863 let v = opts.filter_deterministic(
3864 &[
3865 historical(
3866 1,
3867 "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
3868 ),
3869 historical(2, "INFO DETLOG detcore:[syscall] syscall 1"),
3870 historical(
3871 3,
3872 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
3873 ),
3874 ],
3875 );
3876 assert_eq!(
3877 indexed_text(&v),
3878 vec![(2, "INFO DETLOG detcore:[syscall] syscall 1")]
3879 );
3880 }
3881
3882 #[test]
3888 fn test_filter_deterministic_drops_io_polling_bookkeeping() {
3889 let opts = super::LogDiffOpts::default();
3890 let v = opts.filter_deterministic(&[
3891 historical(
3892 0,
3893 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {InternalIOPolling: W}, on previously committed 1s",
3894 ),
3895 historical(
3896 1,
3897 "DEBUG detcore::scheduler: DETLOG [sched-step1] advancing committed_time from 1 to 2",
3898 ),
3899 historical(
3900 2,
3901 "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)",
3902 ),
3903 historical(
3904 3,
3905 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s",
3906 ),
3907 ]);
3908 assert_eq!(
3911 indexed_text(&v),
3912 vec![
3913 (
3914 2,
3915 "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)"
3916 ),
3917 (
3918 3,
3919 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
3920 ),
3921 ]
3922 );
3923 }
3924
3925 #[test]
3926 fn test_filter_deterministic_drops_sabre_internal_pipe_resource_turn() {
3927 let opts = super::LogDiffOpts::default();
3928 let v = opts.filter_deterministic(&[
3929 historical(
3930 0,
3931 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 1s [sabre-internal-pipe-io]",
3932 ),
3933 historical(
3934 1,
3935 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 2s",
3936 ),
3937 ]);
3938
3939 assert_eq!(
3940 indexed_text(&v),
3941 vec![(
3942 1,
3943 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 2s"
3944 )]
3945 );
3946 }
3947
3948 #[test]
3949 fn test_filter_deterministic_drops_sabre_loopback_poll_yield() {
3950 let opts = super::LogDiffOpts::default();
3951 let v = opts.filter_deterministic(&[
3952 historical(
3953 0,
3954 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {SchedYield: W}, on previously committed 1s [sabre-loopback-poll-zero-timeout]",
3955 ),
3956 historical(
3957 1,
3958 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {SchedYield: W}, on previously committed 2s",
3959 ),
3960 ]);
3961
3962 assert_eq!(
3963 indexed_text(&v),
3964 vec![(
3965 1,
3966 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {SchedYield: W}, on previously committed 2s"
3967 )]
3968 );
3969 }
3970
3971 #[test]
3976 fn test_log_diff_ignores_extra_io_poll_retries() -> std::io::Result<()> {
3977 let common_head = "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall][detcore, dtid 5] inbound syscall: poll(0x1000, 1, -1) = ?";
3978 let common_tail = "2022-09-06T14:15:47.100000Z INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: poll(0x1000, 1, -1) = Ok(1)";
3979 let poll_retry = "2022-09-06T14:15:47.050000Z INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {InternalIOPolling: W}, on previously committed 1s\n2022-09-06T14:15:47.050000Z DEBUG detcore::scheduler: DETLOG [sched-step1] advancing committed_time from 1 to 2";
3980
3981 let run_a = format!("{common_head}\n{poll_retry}\n{common_tail}");
3982 let run_b = format!("{common_head}\n{poll_retry}\n{poll_retry}\n{common_tail}");
3984
3985 let opts = super::LogDiffOpts {
3986 no_color: true,
3987 strip_lines: true,
3988 ..Default::default()
3989 };
3990 assert!(!super::log_diff_from_strs(
3992 &run_a,
3993 &run_b,
3994 &opts,
3995 &mut Vec::new()
3996 )?);
3997
3998 let run_c = run_a.replace("= Ok(1)", "= Err(Errno(EBADF))");
4001 assert!(super::log_diff_from_strs(
4002 &run_a,
4003 &run_c,
4004 &opts,
4005 &mut Vec::new()
4006 )?);
4007 Ok(())
4008 }
4009
4010 #[test]
4016 fn canonical_address_only_difference_compares_equal() -> std::io::Result<()> {
4017 let run_a =
4018 "2022-09-06T14:15:47.000000Z INFO detcore: [t] p=<hostaddr 0x1111> q=<hostaddr 0x2222>
40192022-09-06T14:15:48.000000Z INFO detcore: [t] use <hostaddr 0x1111> then <hostaddr 0x2222>";
4020 let run_b =
4021 "2022-09-06T14:15:47.000000Z INFO detcore: [t] p=<hostaddr 0xaaaa> q=<hostaddr 0xbbbb>
40222022-09-06T14:15:48.000000Z INFO detcore: [t] use <hostaddr 0xaaaa> then <hostaddr 0xbbbb>";
4023
4024 let canonical = super::LogDiffOpts {
4025 comparison: super::LogComparisonMode::FullTrace,
4026 canonicalize_addresses: true,
4027 no_color: true,
4028 ..Default::default()
4029 };
4030 assert!(
4031 !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4032 "address-only (ASLR-shift) difference must compare EQUAL under canonicalization"
4033 );
4034
4035 let raw = super::LogDiffOpts {
4038 comparison: super::LogComparisonMode::FullTrace,
4039 canonicalize_addresses: false,
4040 no_color: true,
4041 ..Default::default()
4042 };
4043 assert!(
4044 super::log_diff_from_strs(run_a, run_b, &raw, &mut Vec::new())?,
4045 "raw comparison must still see the differing addresses"
4046 );
4047 Ok(())
4048 }
4049
4050 #[test]
4055 fn canonical_allocation_order_difference_compares_unequal() -> std::io::Result<()> {
4056 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] alloc <hostaddr 0x1111>
40602022-09-06T14:15:48.000000Z INFO detcore: [t] alloc <hostaddr 0x2222>
40612022-09-06T14:15:49.000000Z INFO detcore: [t] pair <hostaddr 0x1111> <hostaddr 0x2222>";
4062 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: [t] alloc <hostaddr 0xbbbb>
40632022-09-06T14:15:48.000000Z INFO detcore: [t] alloc <hostaddr 0xaaaa>
40642022-09-06T14:15:49.000000Z INFO detcore: [t] pair <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4065
4066 let canonical = super::LogDiffOpts {
4067 comparison: super::LogComparisonMode::FullTrace,
4068 canonicalize_addresses: true,
4069 no_color: true,
4070 ..Default::default()
4071 };
4072 assert!(
4073 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4074 "an allocation-order difference must compare UNEQUAL under canonicalization"
4075 );
4076
4077 let stripped = super::LogDiffOpts {
4080 comparison: super::LogComparisonMode::FullTrace,
4081 strip_lines: true,
4082 no_color: true,
4083 ..Default::default()
4084 };
4085 assert!(
4086 !super::log_diff_from_strs(run_a, run_b, &stripped, &mut Vec::new())?,
4087 "wholesale stripping erases the allocation-order difference (the defect)"
4088 );
4089 Ok(())
4090 }
4091
4092 #[test]
4096 fn canonical_aliasing_difference_compares_unequal() -> std::io::Result<()> {
4097 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] two <hostaddr 0x1111> <hostaddr 0x1111>";
4098 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: [t] two <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4099
4100 let canonical = super::LogDiffOpts {
4101 comparison: super::LogComparisonMode::FullTrace,
4102 canonicalize_addresses: true,
4103 no_color: true,
4104 ..Default::default()
4105 };
4106 assert!(
4107 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4108 "an aliasing difference (1,1 vs 1,2) must compare UNEQUAL"
4109 );
4110 Ok(())
4111 }
4112
4113 #[test]
4122 fn canonical_syscall_arg_hex_difference_compares_unequal() -> std::io::Result<()> {
4123 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4124 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x6) at <hostaddr 0xaaaa>";
4125
4126 let canonical = super::LogDiffOpts {
4127 comparison: super::LogComparisonMode::FullTrace,
4128 canonicalize_addresses: true,
4129 no_color: true,
4130 ..Default::default()
4131 };
4132 assert!(
4133 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4134 "a bare syscall-argument hex difference (0x2 vs 0x6) must compare UNEQUAL: \
4135 it is reproducible and NOT a host address"
4136 );
4137
4138 let addr_only_a = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4143 let addr_only_b = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0xaaaa>";
4144 assert!(
4145 !super::log_diff_from_strs(addr_only_a, addr_only_b, &canonical, &mut Vec::new())?,
4146 "an address-only difference alongside an identical syscall arg must compare EQUAL"
4147 );
4148 Ok(())
4149 }
4150
4151 #[test]
4157 fn canonical_virtual_time_difference_compares_unequal() -> std::io::Result<()> {
4158 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: COMMIT turn 5 at time 100";
4159 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: COMMIT turn 5 at time 200";
4160
4161 let canonical = super::LogDiffOpts {
4162 comparison: super::LogComparisonMode::FullTrace,
4163 canonicalize_addresses: true,
4164 no_color: true,
4165 ..Default::default()
4166 };
4167 assert!(
4168 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4169 "a virtual-time (decimal) difference must compare UNEQUAL under canonicalization"
4170 );
4171 Ok(())
4172 }
4173
4174 #[test]
4179 fn canonical_wall_clock_prefix_difference_compares_equal() -> std::io::Result<()> {
4180 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] use 0x1111
41812022-09-06T14:15:48.000000Z INFO detcore: [t] use 0x1111";
4182 let run_b = "Apr 09 06:08:03.100 INFO detcore: [t] use 0x1111
4184Jun 09 06:49:17.742 INFO detcore: [t] use 0x1111";
4185
4186 let canonical = super::LogDiffOpts {
4187 comparison: super::LogComparisonMode::FullTrace,
4188 canonicalize_addresses: true,
4189 no_color: true,
4190 ..Default::default()
4191 };
4192 assert!(
4193 !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4194 "a wall-clock-prefix-only difference must compare EQUAL"
4195 );
4196 Ok(())
4197 }
4198
4199 #[test]
4200 fn one_log_canonical_info_preserves_values_and_only_rewrites_marked_addresses() {
4201 let log = "2026-08-13T01:02:03.000000Z INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<hostaddr 0xaaaa>\n\
42022026-08-13T01:02:03.000001Z DEBUG detcore: diagnostic=999\n\
42032026-08-13T01:02:03.000002Z INFO detcore: DETLOG count=42 bare=0x6 marked=<hostaddr 0xaaaa> other=<hostaddr 0xbbbb>";
4204
4205 assert_eq!(
4206 super::canonical_info_from_str(log).expect("fixture log is fully tagged"),
4207 vec![
4208 "INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<addr1>",
4209 "INFO detcore: DETLOG count=42 bare=0x6 marked=<addr1> other=<addr2>",
4210 ]
4211 );
4212 }
4213
4214 #[test]
4215 fn first_log_divergence_reports_preceding_commit_turn_and_virtual_time() -> std::io::Result<()>
4216 {
4217 let left = "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
42182026-08-13T01:02:03.000001Z INFO detcore: DETLOG count=42";
4219 let right = "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
42202026-08-13T01:02:04.000001Z INFO detcore: DETLOG count=43";
4221 let opts = super::LogDiffOpts {
4222 comparison: super::LogComparisonMode::Info,
4223 canonicalize_addresses: true,
4224 no_color: true,
4225 ..Default::default()
4226 };
4227
4228 let diverged = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4229 assert!(diverged.diff_found);
4230 assert_eq!(diverged.first_divergent_scheduler_turn, Some(17));
4231 assert_eq!(
4232 diverged.first_divergent_virtual_nanoseconds,
4233 Some(12_345_678_901)
4234 );
4235 assert_eq!(
4236 diverged.first_divergent_left_message.as_deref(),
4237 Some("INFO detcore: DETLOG count=42")
4238 );
4239 assert_eq!(
4240 diverged.first_divergent_right_message.as_deref(),
4241 Some("INFO detcore: DETLOG count=43")
4242 );
4243
4244 let matched = super::log_diff_summary_from_strs(left, left, &opts, &mut Vec::new())?;
4245 assert!(matched.matched_with_evidence());
4246 assert_eq!(matched.first_divergent_scheduler_turn, None);
4247 assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4248 assert_eq!(matched.first_divergent_left_message, None);
4249 assert_eq!(matched.first_divergent_right_message, None);
4250 Ok(())
4251 }
4252
4253 #[test]
4254 fn first_divergent_messages_keep_event_content_not_separate_positions() -> std::io::Result<()> {
4255 let left = "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 110, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed 1_767_225_600.031_999_250s";
4256 let right = "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 111, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed 1_767_225_600.031_994_250s";
4257 let opts = super::LogDiffOpts {
4258 comparison: super::LogComparisonMode::Info,
4259 canonicalize_addresses: true,
4260 no_color: true,
4261 ..Default::default()
4262 };
4263
4264 let same_event = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4265 assert!(same_event.diff_found);
4266 assert_eq!(
4267 same_event.first_divergent_left_message, same_event.first_divergent_right_message,
4268 "turn and committed-time values are recorded separately, so they must not split one event into two"
4269 );
4270 assert_eq!(
4271 same_event.first_divergent_left_message.as_deref(),
4272 Some(
4273 "INFO detcore::scheduler: COMMIT turn <NUM>, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed <NANOSECONDS>"
4274 )
4275 );
4276 assert_eq!(
4277 super::first_divergent_message(&historical(
4278 0,
4279 "INFO detcore::scheduler: COMMIT turn 110 at time 123\npayload at time 456"
4280 )),
4281 "INFO detcore::scheduler: COMMIT turn <NUM> at time <NANOSECONDS>\npayload at time 456",
4282 "only the structured first line carries the separately recorded position"
4283 );
4284
4285 let after_longer_shared_prefix = super::log_diff_summary_from_strs(
4286 format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{left}"),
4287 format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{right}"),
4288 &opts,
4289 &mut Vec::new(),
4290 )?;
4291 assert_ne!(
4292 same_event.first_divergent_record, after_longer_shared_prefix.first_divergent_record,
4293 "a longer shared trace must move the record observation in this fixture"
4294 );
4295 assert_eq!(
4296 (
4297 same_event.first_divergent_left_message.as_deref(),
4298 same_event.first_divergent_right_message.as_deref(),
4299 ),
4300 (
4301 after_longer_shared_prefix
4302 .first_divergent_left_message
4303 .as_deref(),
4304 after_longer_shared_prefix
4305 .first_divergent_right_message
4306 .as_deref(),
4307 ),
4308 "the same event after a longer trace must keep the same compared messages"
4309 );
4310
4311 let different_event = super::log_diff_summary_from_strs(
4312 left,
4313 "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 111, dettid 2 using resources {Device(ContainerStderr): W}, on previously committed 1_767_225_600.031_994_250s",
4314 &opts,
4315 &mut Vec::new(),
4316 )?;
4317 assert_ne!(
4318 different_event.first_divergent_left_message,
4319 different_event.first_divergent_right_message,
4320 "different event content must remain distinguishable"
4321 );
4322 Ok(())
4323 }
4324
4325 #[test]
4326 fn log_divergence_without_commit_metadata_reports_no_position() -> std::io::Result<()> {
4327 let opts = super::LogDiffOpts {
4328 comparison: super::LogComparisonMode::Info,
4329 canonicalize_addresses: true,
4330 no_color: true,
4331 ..Default::default()
4332 };
4333 let summary = super::log_diff_summary_from_strs(
4334 "INFO detcore: DETLOG count=42",
4335 "INFO detcore: DETLOG count=43",
4336 &opts,
4337 &mut Vec::new(),
4338 )?;
4339 assert!(summary.diff_found);
4340 assert_eq!(summary.first_divergent_scheduler_turn, None);
4341 assert_eq!(summary.first_divergent_virtual_nanoseconds, None);
4342 Ok(())
4343 }
4344
4345 #[test]
4351 fn empty_selection_is_a_no_result_not_a_match() -> std::io::Result<()> {
4352 let canonical = super::LogDiffOpts {
4353 comparison: super::LogComparisonMode::FullTrace,
4354 canonicalize_addresses: true,
4355 no_color: true,
4356 ..Default::default()
4357 };
4358 let summary = super::log_diff_summary_from_strs("", "", &canonical, &mut Vec::new())?;
4359 assert!(!summary.diff_found, "two empty logs do not differ");
4360 assert_eq!(summary.compared_left, 0);
4361 assert_eq!(summary.compared_right, 0);
4362 assert!(
4363 !summary.matched_with_evidence(),
4364 "zero compared messages must never count as a verified match"
4365 );
4366 Ok(())
4367 }
4368
4369 #[test]
4372 fn nonempty_identical_selection_is_a_match_with_evidence() -> std::io::Result<()> {
4373 let run = "Apr 09 06:08:03.100 INFO detcore: [t] finish syscall: close(2) = Ok(0)
4374Apr 09 06:08:03.200 INFO detcore: [t] finish syscall: exit_group(0)";
4375 let canonical = super::LogDiffOpts {
4376 comparison: super::LogComparisonMode::FullTrace,
4377 canonicalize_addresses: true,
4378 no_color: true,
4379 ..Default::default()
4380 };
4381 let summary = super::log_diff_summary_from_strs(run, run, &canonical, &mut Vec::new())?;
4382 assert!(!summary.diff_found);
4383 assert_eq!(summary.compared_left, 2);
4384 assert_eq!(summary.compared_right, 2);
4385 assert!(
4386 summary.matched_with_evidence(),
4387 "a real, nonempty, identical comparison must count as a match"
4388 );
4389 Ok(())
4390 }
4391
4392 #[test]
4393 fn test_filter_infos() {
4394 let v = super::filter_infos(&[
4395 historical(
4396 0,
4397 "DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000",
4398 ),
4399 historical(
4400 1,
4401 "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ...",
4402 ),
4403 ]);
4404 assert_eq!(
4405 indexed_text(&v),
4406 vec![(
4407 1,
4408 "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ..."
4409 )]
4410 );
4411 }
4412
4413 #[test]
4414 fn test_extract_log_messages() {
4415 let s = "
4416Jan 09 06:08:03.100 INFO detcore: [detcore, dtid 2] finish syscall: close(2) = Ok(0)
4417Feb 09 06:49:17.742 DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000
4418Apr 09 06:49:17.742 INFO detcore::scheduler: [scheduler] >>>>>>>
4419
4420 COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000
4421Jan 09 06:49:03.100 INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, r14: 0, r13: 1, r12: 946684799000118840, rbp: 140737488344736, rbx: 0, r11: 518, r10: 140737488342434, r9: 0, r8: 1, rax: 0, rcx: 0, rdx: 2, rsi: 0, rdi: 140737354052880, orig_rax: 18446744073709551615, rip: 140737351875567, cs: 51, eflags: 66118, rsp: 140737488344064, ss: 43, fs_base: 0, gs_base: 0, ds: 0, es: 0, fs: 0, gs: 0 }
4422Jun 09 06:49:17.742 TRACE detcore::scheduler: [scheduler] Guest unblocked (<ivar Go>); clear ivars for the next turn on dettid 2
4423";
4424
4425 let v = super::extract_log_messages(s).expect("fixture log is fully tagged");
4426 eprintln!("Split into {} log messages", v.len());
4427 for x in &v {
4428 eprintln!("{:?}", x);
4429 }
4430 assert_eq!(v.len(), 5);
4431 }
4432
4433 #[test]
4434 fn test_canonicalize_addresses_in_line() {
4435 use std::collections::HashMap;
4436 let mut map = HashMap::new();
4437 let mut next = 1usize;
4438 assert_eq!(
4442 super::canonicalize_addresses_in_line(
4443 "a=<hostaddr 0x1111> b=<hostaddr 0x2222> c=<hostaddr 0x1111> raw=0x4444 n=42",
4444 &mut map,
4445 &mut next
4446 ),
4447 "a=<addr1> b=<addr2> c=<addr1> raw=0x4444 n=42"
4448 );
4449 assert_eq!(
4452 super::canonicalize_addresses_in_line(
4453 "use <hostaddr 0x2222> then <hostaddr 0x3333>",
4454 &mut map,
4455 &mut next
4456 ),
4457 "use <addr2> then <addr3>"
4458 );
4459 assert_eq!(
4462 super::canonicalize_addresses_in_line("bare 0x1111", &mut map, &mut next),
4463 "bare 0x1111"
4464 );
4465 }
4466
4467 #[test]
4468 fn test_strip_log() {
4469 assert_eq!(super::strip_log_entry("800.709_180s"), "<NANOSECONDS>");
4470 assert_eq!(super::strip_log_entry("98.91618ms"), "<NUM>");
4471 assert_eq!(super::strip_log_entry("98.91619ms"), "<NUM>");
4472 assert_eq!(super::strip_log_entry("x86_64"), "x86_64");
4473 assert_eq!(
4474 super::strip_log_entry(
4475 "COMMIT turn 66, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946_684_800.709_180_000s"
4476 ),
4477 "COMMIT turn <NUM>, dettid <NUM> using resources {Path(\"/proc/<PID>/fd/<NUM>\"): W} at time <NANOSECONDS>"
4478 );
4479 }
4480
4481 #[test]
4489 fn strip_tmp_path_does_not_swallow_rest_of_line() {
4490 let read = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_RDONLY""#);
4491 let write = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_WRONLY""#);
4492
4493 assert_eq!(read, r#"open path="/tmp/<somewhere>" flags="O_RDONLY""#);
4494 assert_eq!(write, r#"open path="/tmp/<somewhere>" flags="O_WRONLY""#);
4495 assert_ne!(
4496 read, write,
4497 "entries differing after a /tmp path must not collapse to equal"
4498 );
4499 }
4500
4501 #[test]
4505 fn strip_tmp_path_still_erases_a_differing_tmp_path() {
4506 assert_eq!(
4507 super::strip_log_entry(r#"open path="/tmp/hermit-aaaa/f" flags="O_RDONLY""#),
4508 super::strip_log_entry(r#"open path="/tmp/hermit-bbbb/f" flags="O_RDONLY""#),
4509 );
4510 }
4511
4512 #[test]
4516 fn strip_tmp_path_erases_each_path_separately() {
4517 assert_eq!(
4518 super::strip_log_entry(r#"rename from="/tmp/a" to="/tmp/b" ok="1""#),
4519 r#"rename from="/tmp/<somewhere>" to="/tmp/<somewhere>" ok="<NUM>""#
4520 );
4521 }
4522
4523 const KICK_LINE_PREFIX: &str = "Logs contain";
4524 const KICK_LINE_SUFFIX: &str = "scheduler empty-run-queue kick messages";
4525
4526 fn kick_opts() -> super::LogDiffOpts {
4527 super::LogDiffOpts {
4528 comparison: super::LogComparisonMode::Info,
4529 canonicalize_addresses: true,
4530 no_color: true,
4531 ..Default::default()
4532 }
4533 }
4534
4535 fn log_with_kick() -> &'static str {
4539 "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
45402026-08-13T01:02:03.000001Z INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling.\n\
45412026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4542 }
4543
4544 fn log_without_kick() -> &'static str {
4545 "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
45462026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4547 }
4548
4549 fn run_diff(left: &str, right: &str) -> std::io::Result<(super::LogDiffSummary, String)> {
4550 let mut out = Vec::new();
4551 let summary = super::log_diff_summary_from_strs(left, right, &kick_opts(), &mut out)?;
4552 Ok((
4553 summary,
4554 String::from_utf8(out).expect("diff output is utf-8"),
4555 ))
4556 }
4557
4558 #[test]
4562 fn a_matching_pair_records_the_empty_queue_kick_count() -> std::io::Result<()> {
4563 let (kicked, kicked_out) = run_diff(log_with_kick(), log_with_kick())?;
4564 assert!(kicked.matched_with_evidence(), "both-kicked pair must pass");
4565 assert!(
4566 kicked_out.contains(&format!("{KICK_LINE_PREFIX} 1 | 1 {KICK_LINE_SUFFIX}")),
4567 "a passing pair that kicked must retain the count, got:\n{kicked_out}"
4568 );
4569
4570 let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4571 assert!(
4572 quiet.matched_with_evidence(),
4573 "neither-kicked pair must pass"
4574 );
4575 assert!(
4576 quiet_out.contains(&format!("{KICK_LINE_PREFIX} 0 | 0 {KICK_LINE_SUFFIX}")),
4577 "a passing pair that did not kick must say so explicitly, got:\n{quiet_out}"
4578 );
4579 Ok(())
4580 }
4581
4582 #[test]
4587 fn recording_the_kick_count_does_not_move_any_verdict() -> std::io::Result<()> {
4588 let (diverged, diverged_out) = run_diff(log_with_kick(), log_without_kick())?;
4589 assert!(
4590 diverged.diff_found,
4591 "a pair differing only by the kick must still diverge"
4592 );
4593 assert!(
4594 !diverged_out.contains(KICK_LINE_SUFFIX),
4595 "the count is scoped to passing pairs; a diverging pair already \
4596 reproduces the messages in its diff, got:\n{diverged_out}"
4597 );
4598
4599 let (matched, _) = run_diff(log_with_kick(), log_with_kick())?;
4602 assert!(!matched.diff_found);
4603 assert_eq!(matched.first_divergent_scheduler_turn, None);
4604 assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4605 assert_eq!(matched.first_divergent_record, None);
4606 assert_eq!(matched.compared_left, matched.compared_right);
4607 Ok(())
4608 }
4609
4610 const MAPS_LINE_SUFFIX: &str = "scheduler COMMIT records reading /proc/self/maps";
4611
4612 fn log_with_maps_read(committed: &str) -> String {
4616 format!(
4617 "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 10, dettid 3 using resources {{Path(\"/proc/self/maps\"): R}}, on previously committed {committed}\n\
46182026-08-13T01:02:03.000001Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4619 )
4620 }
4621
4622 #[test]
4623 fn custom_side_labels_name_the_maps_read_summary() -> std::io::Result<()> {
4624 let scanned = log_with_maps_read("12.345_678_901s");
4625 let options = super::LogDiffOpts {
4626 comparison: super::LogComparisonMode::Info,
4627 canonicalize_addresses: true,
4628 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
4629 no_color: true,
4630 ..Default::default()
4631 };
4632 let mut output = Vec::new();
4633 let summary = super::log_diff_summary_from_strs(&scanned, &scanned, &options, &mut output)?;
4634 assert!(summary.matched_with_evidence());
4635 let output = String::from_utf8(output).unwrap();
4636 assert!(output.contains(
4637 "(the recording first at turn 10, committed virtual time 12345678901ns, \
4638 the replay first at turn 10, committed virtual time 12345678901ns)"
4639 ));
4640 assert!(!output.contains("run 1") && !output.contains("run 2"));
4641 Ok(())
4642 }
4643
4644 #[test]
4648 fn a_matching_pair_records_the_maps_read_commit() -> std::io::Result<()> {
4649 let scanned = log_with_maps_read("12.345_678_901s");
4650 let (summary, out) = run_diff(&scanned, &scanned)?;
4651 assert!(summary.matched_with_evidence(), "the pair must pass");
4652 assert!(
4653 out.contains(&format!("Logs contain 1 | 1 {MAPS_LINE_SUFFIX}")),
4654 "a passing pair that read the map must retain the record, got:\n{out}"
4655 );
4656 assert!(
4659 out.contains(
4660 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4661 run 2 first at turn 10, committed virtual time 12345678901ns)"
4662 ),
4663 "the retained record must carry the turn and the committed virtual \
4664 time, got:\n{out}"
4665 );
4666
4667 let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4668 assert!(quiet.matched_with_evidence());
4669 assert!(
4670 quiet_out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4671 "a passing pair that never read the map must say so explicitly, \
4672 got:\n{quiet_out}"
4673 );
4674 Ok(())
4675 }
4676
4677 #[test]
4682 fn recording_the_maps_read_commit_does_not_move_any_verdict() -> std::io::Result<()> {
4683 let (diverged, diverged_out) = run_diff(
4684 &log_with_maps_read("12.345_678_901s"),
4685 &log_with_maps_read("12.345_678_902s"),
4686 )?;
4687 assert!(
4688 diverged.diff_found,
4689 "a one-nanosecond difference in the committed time must still diverge"
4690 );
4691 assert_eq!(diverged.first_divergent_scheduler_turn, Some(10));
4692 assert!(
4693 !diverged_out.contains(MAPS_LINE_SUFFIX),
4694 "the record is scoped to passing pairs; a diverging pair already \
4695 prints both times in its diff, got:\n{diverged_out}"
4696 );
4697 Ok(())
4698 }
4699
4700 #[test]
4707 fn both_records_are_retained_under_the_default_comparison_mode() -> std::io::Result<()> {
4708 let default_opts = super::LogDiffOpts {
4709 no_color: true,
4710 ..Default::default()
4711 };
4712 assert_eq!(
4713 default_opts.comparison,
4714 super::LogComparisonMode::Deterministic,
4715 "this test exists to cover the default mode; if the default changes \
4716 it must be re-pointed, not deleted"
4717 );
4718
4719 let mut out = Vec::new();
4720 let summary = super::log_diff_summary_from_strs(
4721 log_with_kick(),
4722 log_without_kick(),
4723 &default_opts,
4724 &mut out,
4725 )?;
4726 let out = String::from_utf8(out).expect("diff output is utf-8");
4727 assert!(
4728 summary.matched_with_evidence(),
4729 "a kick asymmetry is not compared under the default mode, so this \
4730 pair must pass; got:\n{out}"
4731 );
4732 assert!(
4733 out.contains(&format!("Logs contain 1 | 0 {KICK_LINE_SUFFIX}")),
4734 "the asymmetry must be visible per side on the default path, \
4735 got:\n{out}"
4736 );
4737 assert!(
4738 out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4739 "the map-read line must also be emitted on the default path, \
4740 got:\n{out}"
4741 );
4742
4743 let scanned = log_with_maps_read("12.345_678_901s");
4745 let mut out = Vec::new();
4746 let summary =
4747 super::log_diff_summary_from_strs(&scanned, &scanned, &default_opts, &mut out)?;
4748 let out = String::from_utf8(out).expect("diff output is utf-8");
4749 assert!(summary.matched_with_evidence());
4750 assert!(
4751 out.contains(
4752 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4753 run 2 first at turn 10, committed virtual time 12345678901ns)"
4754 ),
4755 "both runs' values must be printed under the default mode, \
4756 got:\n{out}"
4757 );
4758 Ok(())
4759 }
4760
4761 #[test]
4769 fn a_stripped_pass_shows_both_runs_diverging_map_read_times() -> std::io::Result<()> {
4770 let opts = super::LogDiffOpts {
4771 strip_lines: true,
4772 no_color: true,
4773 ..Default::default()
4774 };
4775 let mut out = Vec::new();
4776 let summary = super::log_diff_summary_from_strs(
4777 log_with_maps_read("12.345_678_901s"),
4778 log_with_maps_read("12.345_678_902s"),
4779 &opts,
4780 &mut out,
4781 )?;
4782 let out = String::from_utf8(out).expect("diff output is utf-8");
4783 assert!(
4784 summary.matched_with_evidence(),
4785 "the stripped comparator normalizes the times, so this pair passes; \
4786 got:\n{out}"
4787 );
4788 assert!(
4789 out.contains(
4790 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4791 run 2 first at turn 10, committed virtual time 12345678902ns)"
4792 ),
4793 "a passing pair whose runs committed at DIFFERENT times must show \
4794 both values; showing one would report agreement on a real drift, \
4795 got:\n{out}"
4796 );
4797 Ok(())
4798 }
4799
4800 #[test]
4803 fn a_run_without_the_maps_read_is_named_not_borrowed() {
4804 assert_eq!(super::describe_maps_commit(None), "no such record");
4805 assert_eq!(
4806 super::describe_maps_commit(Some((10, Some(12_345_678_901)))),
4807 "first at turn 10, committed virtual time 12345678901ns"
4808 );
4809 assert_eq!(
4810 super::describe_maps_commit(Some((10, None))),
4811 "first at turn 10, committed virtual time unrecorded"
4812 );
4813 }
4814
4815 #[test]
4819 fn only_a_maps_read_commit_is_counted() {
4820 assert_eq!(
4821 super::maps_read_commits(&[
4822 historical(
4823 0,
4824 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
4825 ),
4826 historical(
4827 1,
4828 "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #257: openat(-100, \"/proc/self/maps\", 0x0) = Ok(4)"
4829 ),
4830 ]),
4831 (0, None),
4832 "a COMMIT on another path, and a syscall naming the map, are both \
4833 excluded"
4834 );
4835 assert_eq!(
4836 super::maps_read_commits(&[historical(
4837 0,
4838 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 10, dettid 3 using resources {Path(\"/proc/self/maps\"): R}, on previously committed 12.345_678_901s"
4839 )]),
4840 (1, Some((10, Some(12_345_678_901))))
4841 );
4842 }
4843
4844 #[test]
4847 fn only_the_kick_message_is_counted() {
4848 assert_eq!(
4849 super::count_empty_queue_kicks(&[
4850 historical(
4851 0,
4852 "INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4853 ),
4854 historical(
4855 1,
4856 "INFO detcore::scheduler: COMMIT turn 18, dettid 2, on previously committed 2s"
4857 ),
4858 ]),
4859 0
4860 );
4861 assert_eq!(
4862 super::count_empty_queue_kicks(&[historical(
4863 0,
4864 "INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling."
4865 )]),
4866 1
4867 );
4868 }
4869}