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 LOG_CAP_STOP_LINE: &str = "HERMIT_LOG_CAP class=log-cap";
62
63pub use detcore_model::logdiff_policy::CANON_ADDRESS_ORDINAL_V1;
64pub use detcore_model::logdiff_policy::STRIP_WALL_CLOCK_PREFIX_V1;
65
66pub fn log_was_truncated(log_text: &str) -> bool {
97 let trimmed = log_text.trim_end_matches(['\n', '\r']);
98 ends_with_whole_line(trimmed, TRUNCATION_MARKER)
99 || ends_with_whole_line(trimmed, LOG_CAP_STOP_LINE)
100}
101
102fn ends_with_whole_line(text: &str, line: &str) -> bool {
104 if !text.ends_with(line) {
105 return false;
106 }
107 let line_start = text.len() - line.len();
109 line_start == 0 || text.as_bytes()[line_start - 1] == b'\n'
110}
111
112#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
114pub enum LogComparisonMode {
115 #[default]
117 Deterministic,
118 Info,
122 FullTrace,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ComparisonSideLabels {
133 pub left: String,
135 pub right: String,
137}
138
139impl ComparisonSideLabels {
140 pub fn new(left: impl Into<String>, right: impl Into<String>) -> Self {
142 Self {
143 left: left.into(),
144 right: right.into(),
145 }
146 }
147}
148
149impl Default for ComparisonSideLabels {
150 fn default() -> Self {
151 Self::new("run 1", "run 2")
152 }
153}
154
155#[derive(Debug, Parser, Clone)]
157pub struct LogDiffOpts {
158 #[clap(long = "unsafe-strip-lines")]
164 pub strip_lines: bool,
165
166 #[clap(long = "canonicalize-host-addresses")]
196 pub canonicalize_addresses: bool,
197
198 #[clap(skip)]
200 pub comparison: LogComparisonMode,
201
202 #[clap(skip)]
205 pub side_labels: ComparisonSideLabels,
206
207 #[clap(skip)]
210 pub require_structured_events: bool,
211
212 #[clap(long)]
217 pub print_logs: bool,
218
219 #[clap(long, default_value = "20")]
221 pub limit: u64,
222
223 #[clap(long)]
225 pub ignore_lines: Vec<String>,
226
227 #[clap(long, default_value = "0")]
230 pub syscall_history: u64,
231 #[clap(long)]
233 pub no_color: bool,
234
235 #[clap(long)]
237 pub skip_commit: bool,
238
239 #[clap(long)]
241 pub skip_detlog: bool,
242
243 #[clap(long)]
245 pub git_diff: bool,
246
247 #[clap(long, default_values = &["syscall", "syscallresult", "other"])]
250 pub include_detlogs: Vec<DetLogFilter>,
251}
252
253impl LogDiffOpts {
254 fn is_skip(&self, filter: DetLogFilter) -> bool {
255 !self.include_detlogs.contains(&filter)
256 }
257
258 fn skip_detlog(&self, entry: &LogMessage<'_>) -> bool {
259 if self.skip_detlog {
260 return true;
261 }
262
263 if is_detlog_syscall(entry) && self.is_skip(DetLogFilter::Syscall) {
264 return true;
265 }
266 if is_detlog_syscall_result(entry) && self.is_skip(DetLogFilter::SyscallResult) {
267 return true;
268 }
269
270 if !is_detlog_syscall(entry)
271 && !is_detlog_syscall_result(entry)
272 && self.is_skip(DetLogFilter::Other)
273 {
274 return true;
275 }
276
277 false
278 }
279
280 fn filter_deterministic<'a>(&self, v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
281 v.iter()
282 .filter_map(|message| {
283 if (is_detlog(message)
284 && !self.skip_detlog(message)
285 && !is_scheduler_committed_time(message))
286 || (is_commit(message)
287 && !self.skip_commit
288 && !is_internal_io_poll_commit(message))
289 {
290 Some(*message)
291 } else {
292 None
293 }
294 })
295 .collect()
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum LogNormalization {
301 Exact,
302 Stripped,
303 Canonical,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312struct LogComparisonPolicy {
313 comparison: LogComparisonMode,
314 normalization: LogNormalization,
315}
316
317impl LogComparisonPolicy {
318 fn from_options(options: &LogDiffOpts) -> Self {
319 let normalization = if options.strip_lines {
320 LogNormalization::Stripped
321 } else if options.canonicalize_addresses {
322 LogNormalization::Canonical
323 } else {
324 LogNormalization::Exact
325 };
326 Self {
327 comparison: options.comparison,
328 normalization,
329 }
330 }
331
332 fn name(self) -> &'static str {
333 match (self.comparison, self.normalization) {
334 (LogComparisonMode::Deterministic, LogNormalization::Exact) => "Deterministic",
335 (LogComparisonMode::Deterministic, LogNormalization::Stripped) => "Stripped",
336 (LogComparisonMode::Deterministic, LogNormalization::Canonical) => {
337 "Deterministic with Canonical host-address normalization"
338 }
339 (LogComparisonMode::Info, LogNormalization::Exact) => "Info",
340 (LogComparisonMode::Info, LogNormalization::Stripped) => {
341 "Info with Stripped normalization"
342 }
343 (LogComparisonMode::Info, LogNormalization::Canonical) => "Canonical",
344 (LogComparisonMode::FullTrace, LogNormalization::Exact) => "FullTrace",
345 (LogComparisonMode::FullTrace, LogNormalization::Stripped) => {
346 "FullTrace with Stripped normalization"
347 }
348 (LogComparisonMode::FullTrace, LogNormalization::Canonical) => {
349 "FullTrace with Canonical host-address normalization"
350 }
351 }
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
357pub enum DetLogFilter {
358 Syscall,
360 SyscallResult,
362 Other,
364}
365
366impl FromStr for DetLogFilter {
367 type Err = anyhow::Error;
368
369 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
370 match s.to_lowercase().as_str() {
371 "syscall" => Ok(DetLogFilter::Syscall),
372 "syscallresult" => Ok(DetLogFilter::SyscallResult),
373 "other" => Ok(DetLogFilter::Other),
374 _ => Err(anyhow::Error::msg(format!(
375 "unknown value {} for DetLogFilter",
376 s
377 ))),
378 }
379 }
380}
381
382impl Default for LogDiffOpts {
385 fn default() -> Self {
386 let v: Vec<String> = vec![];
387 LogDiffOpts::parse_from(v.iter())
388 }
389}
390
391pub fn strip_log_entry(log: &str) -> String {
408 static RE0: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b0[xX][A-Fa-f0-9]+\b").unwrap());
413
414 static RE1: LazyLock<Regex> =
424 LazyLock::new(|| Regex::new(r"\b[\d][\d_]*(?:\.[\d][\d_]*)?(?:ns|us|µs|ms)?\b").unwrap());
425
426 static RE2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"/tmp/[^"]*""#).unwrap());
433
434 static RE3: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"/proc/[\d]+/").unwrap());
436
437 static RE4: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[\d][\d_.]*s\b").unwrap());
440
441 let log = RE4.replace_all(log, "<NANOSECONDS>");
442 let log = RE3.replace_all(&log, "/proc/<PID>/");
443 let log = RE0.replace_all(&log, "<ADDR>");
444 let log = RE1.replace_all(&log, "<NUM>");
445 let log = RE2.replace_all(&log, "/tmp/<somewhere>\"");
446 String::from(log)
447}
448
449pub fn host_addr(addr: usize) -> String {
461 format!("<hostaddr {addr:#x}>")
462}
463
464fn canonicalize_addresses_in_line(
488 line: &str,
489 map: &mut HashMap<String, usize>,
490 next: &mut usize,
491) -> String {
492 static RE_HOSTADDR: LazyLock<Regex> =
495 LazyLock::new(|| Regex::new(r"<hostaddr (0[xX][A-Fa-f0-9]+)>").unwrap());
496
497 RE_HOSTADDR
498 .replace_all(line, |caps: ®ex::Captures| {
499 let addr = &caps[1];
500 let ord = match map.get(addr) {
501 Some(existing) => *existing,
502 None => {
503 let assigned = *next;
504 *next += 1;
505 map.insert(addr.to_string(), assigned);
506 assigned
507 }
508 };
509 format!("<addr{ord}>")
510 })
511 .into_owned()
512}
513
514fn messages_for_comparison(
515 messages: &[LogMessage<'_>],
516 policy: LogComparisonPolicy,
517) -> Vec<String> {
518 match policy.normalization {
519 LogNormalization::Stripped => messages
520 .iter()
521 .map(|message| strip_log_entry(message.text))
522 .collect(),
523 LogNormalization::Canonical => {
524 let mut addresses = HashMap::new();
525 let mut next_address = 1usize;
526 messages
527 .iter()
528 .map(|message| {
529 canonicalize_addresses_in_line(message.text, &mut addresses, &mut next_address)
530 })
531 .collect()
532 }
533 LogNormalization::Exact => messages
534 .iter()
535 .map(|message| message.text.to_owned())
536 .collect(),
537 }
538}
539
540#[cfg(test)]
541fn canonical_info_from_str(contents: &str) -> std::io::Result<Vec<String>> {
542 canonical_info_from_str_with_filter(contents, |_| true)
543}
544
545fn canonical_info_from_str_with_filter(
546 contents: &str,
547 keep_record: impl Fn(&str) -> bool,
548) -> std::io::Result<Vec<String>> {
549 let info = filter_infos(
550 &extract_log_messages(contents)?
551 .into_iter()
552 .filter(|record| keep_record(record.text))
553 .collect::<Vec<_>>(),
554 );
555 let opts = LogDiffOpts {
556 canonicalize_addresses: true,
557 comparison: LogComparisonMode::Info,
558 ..Default::default()
559 };
560 Ok(messages_for_comparison(
561 &info,
562 LogComparisonPolicy::from_options(&opts),
563 ))
564}
565
566pub fn write_canonical_info(file: &Path, writer: &mut impl Write) -> std::io::Result<usize> {
574 write_canonical_info_with_filter(file, writer, |_| true)
575}
576
577pub fn write_bitwise_info_v1_bytes(
585 bytes: &[u8],
586 side_label: &str,
587 writer: &mut impl Write,
588) -> std::io::Result<usize> {
589 let contents = std::str::from_utf8(bytes).map_err(|error| {
590 std::io::Error::new(
591 std::io::ErrorKind::InvalidData,
592 format!("{side_label} is not UTF-8: {error}"),
593 )
594 })?;
595 if log_was_truncated(contents) {
596 return Err(std::io::Error::new(
597 std::io::ErrorKind::InvalidData,
598 format!("{side_label} was truncated at the configured size bound"),
599 ));
600 }
601 let records = extract_log_messages(contents)
602 .map_err(|error| std::io::Error::new(error.kind(), format!("{side_label} {error}")))?;
603 validate_structured_events(side_label, &records, true)?;
604 let info = filter_infos(&records);
605 let options = bitwise_info_v1_options(ComparisonSideLabels::new(side_label, side_label));
606 let messages = messages_for_comparison(&info, LogComparisonPolicy::from_options(&options));
607 for message in &messages {
608 writeln!(writer, "{message}")?;
609 }
610 Ok(messages.len())
611}
612
613pub fn write_canonical_info_with_filter(
621 file: &Path,
622 writer: &mut impl Write,
623 keep_record: impl Fn(&str) -> bool,
624) -> std::io::Result<usize> {
625 let bytes = std::fs::read(file)?;
626 let contents = std::str::from_utf8(&bytes).map_err(|error| {
627 std::io::Error::new(
628 std::io::ErrorKind::InvalidData,
629 format!("{} is not UTF-8: {error}", file.display()),
630 )
631 })?;
632 let messages = canonical_info_from_str_with_filter(contents, keep_record)?;
633 for message in &messages {
634 writeln!(writer, "{message}")?;
635 }
636 Ok(messages.len())
637}
638
639static RECORD_START: LazyLock<Regex> = LazyLock::new(|| {
646 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) +")
647 .unwrap()
648});
649
650fn record_starts(contents: &str) -> Vec<usize> {
652 RECORD_START
653 .find_iter(contents)
654 .map(|m| m.start())
655 .collect()
656}
657
658pub fn complete_record_count(contents: &str) -> usize {
666 record_starts(contents).len().saturating_sub(1)
667}
668
669pub fn take_complete_records(contents: &str, n: usize) -> Option<&str> {
675 let starts = record_starts(contents);
676 if n == 0 {
677 return Some(&contents[..0]);
678 }
679 starts.get(n).map(|end| &contents[..*end])
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
704struct LogMessage<'a> {
705 index: usize,
706 text: &'a str,
707 event: Option<DetLogEvent>,
708}
709
710fn extract_log_messages(contents: &str) -> std::io::Result<Vec<LogMessage<'_>>> {
711 let ts = &*RECORD_START;
712 let tag = Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE) ").unwrap();
713 ts.split(contents) .enumerate()
715 .map(|(i, s)| (i, s.trim()))
716 .filter(|(_, s)| !s.is_empty())
717 .map(|(i, s)| {
718 if !tag.is_match(s) {
720 return Err(std::io::Error::new(
721 std::io::ErrorKind::InvalidData,
722 format!(
723 "log line {i} has no ERROR/WARN/INFO/DEBUG/TRACE tag, so it cannot be \
724 placed in the compared record stream: {s}"
725 ),
726 ));
727 }
728 let (text, record) = DetLogRecord::split(s).map_err(|error| {
729 std::io::Error::new(
730 std::io::ErrorKind::InvalidData,
731 format!("log record {i} has an invalid structured DETLOG result: {error}"),
732 )
733 })?;
734 Ok(LogMessage {
735 index: i,
736 text,
737 event: record.map(|record| record.event),
738 })
739 })
740 .collect()
741}
742
743pub fn log_record_events(
747 contents: &str,
748) -> std::io::Result<Vec<(usize, &str, Option<DetLogEvent>)>> {
749 Ok(extract_log_messages(contents)?
750 .into_iter()
751 .map(|message| (message.index, message.text, message.event))
752 .collect())
753}
754
755fn is_info(message: &LogMessage<'_>) -> bool {
756 message.text.starts_with("INFO ")
757}
758
759fn historical_is_commit(line: &str) -> bool {
760 line.contains(" COMMIT ")
761}
762
763fn historical_is_detlog(line: &str) -> bool {
764 line.contains(" DETLOG ")
765}
766
767fn is_commit(message: &LogMessage<'_>) -> bool {
768 match message.event {
769 Some(DetLogEvent::SchedulerCommit { .. }) => true,
770 Some(_) => false,
771 None => historical_is_commit(message.text),
772 }
773}
774
775fn is_detlog(message: &LogMessage<'_>) -> bool {
776 match message.event {
777 Some(
778 DetLogEvent::Other
779 | DetLogEvent::Syscall
780 | DetLogEvent::SyscallResult { .. }
781 | DetLogEvent::SchedulerCommittedTime,
782 ) => true,
783 Some(_) => false,
784 None => historical_is_detlog(message.text),
785 }
786}
787
788fn is_internal_io_poll_commit(message: &LogMessage<'_>) -> bool {
806 match message.event {
807 Some(DetLogEvent::SchedulerCommit {
808 internal_io_poll, ..
809 }) => internal_io_poll,
810 Some(_) => false,
811 None => historical_is_commit(message.text) && message.text.contains("{InternalIOPolling: "),
812 }
813}
814
815fn is_scheduler_committed_time(message: &LogMessage<'_>) -> bool {
824 match message.event {
825 Some(DetLogEvent::SchedulerCommittedTime) => true,
826 Some(_) => false,
827 None => message.text.contains("advancing committed_time from "),
828 }
829}
830
831fn is_detcore(message: &LogMessage<'_>) -> bool {
832 static PREFIX: LazyLock<Regex> =
833 LazyLock::new(|| Regex::new("^(ERROR|WARN|INFO|DEBUG|TRACE).* detcore:").unwrap());
834
835 PREFIX.is_match(message.text)
836}
837
838fn is_detlog_syscall(message: &LogMessage<'_>) -> bool {
839 match message.event {
840 Some(DetLogEvent::Syscall | DetLogEvent::SyscallResult { .. }) => true,
841 Some(_) => false,
842 None => historical_is_detlog(message.text) && message.text.contains("[syscall]"),
843 }
844}
845
846fn is_detlog_syscall_result(message: &LogMessage<'_>) -> bool {
847 match message.event {
848 Some(DetLogEvent::SyscallResult { .. }) => true,
849 Some(_) => false,
850 None => is_detlog_syscall(message) && message.text.contains("finish syscall"),
851 }
852}
853
854fn event_matches_human_record(event: DetLogEvent, text: &str) -> bool {
855 match event {
856 DetLogEvent::Other
857 | DetLogEvent::Syscall
858 | DetLogEvent::SyscallResult { .. }
859 | DetLogEvent::SchedulerCommittedTime => historical_is_detlog(text),
860 DetLogEvent::SchedulerCommit { .. } => historical_is_commit(text),
861 DetLogEvent::SchedulerEmptyQueueKick => text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
862 }
863}
864
865fn validate_structured_events(
866 label: &str,
867 messages: &[LogMessage<'_>],
868 require: bool,
869) -> std::io::Result<()> {
870 for message in messages {
871 let is_semantic_record = historical_is_detlog(message.text)
872 || historical_is_commit(message.text)
873 || message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK);
874 if require && is_semantic_record && message.event.is_none() {
875 return Err(std::io::Error::new(
876 std::io::ErrorKind::InvalidData,
877 format!(
878 "{label} log record {} is missing its structured DETLOG result",
879 message.index
880 ),
881 ));
882 }
883 if let Some(event) = message.event
884 && !event_matches_human_record(event, message.text)
885 {
886 return Err(std::io::Error::new(
887 std::io::ErrorKind::InvalidData,
888 format!(
889 "{label} log record {} has structured DETLOG kind {:?} that disagrees with its human record",
890 message.index, event
891 ),
892 ));
893 }
894 }
895 Ok(())
896}
897
898fn _truncate_messages(_v: &[&str]) -> String {
901 unimplemented!()
902}
903
904fn filter_infos<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
905 v.iter()
906 .filter(|message| is_info(message))
907 .copied()
908 .collect()
909}
910
911fn filter_detcore<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
912 v.iter()
913 .filter(|message| is_detcore(message))
914 .copied()
915 .collect()
916}
917
918const SCHEDULER_EMPTY_QUEUE_KICK: &str = "zero threads left anywhere, fizzling.";
942
943fn count_empty_queue_kicks(v: &[LogMessage<'_>]) -> usize {
945 v.iter()
946 .filter(|message| match message.event {
947 Some(DetLogEvent::SchedulerEmptyQueueKick) => true,
948 Some(_) => false,
949 None => message.text.contains(SCHEDULER_EMPTY_QUEUE_KICK),
950 })
951 .count()
952}
953
954const RUNTIME_MAPS_READ_RESOURCE: &str = r#"Path("/proc/self/maps")"#;
971
972fn describe_maps_commit(first: Option<(u64, Option<u64>)>) -> String {
976 match first {
977 Some((turn, Some(nanoseconds))) => {
978 format!("first at turn {turn}, committed virtual time {nanoseconds}ns")
979 }
980 Some((turn, None)) => format!("first at turn {turn}, committed virtual time unrecorded"),
981 None => "no such record".to_string(),
982 }
983}
984
985fn maps_read_commits(v: &[LogMessage<'_>]) -> (usize, Option<(u64, Option<u64>)>) {
988 let mut count = 0;
989 let mut first = None;
990 for message in v {
991 let reads_runtime_maps = match message.event {
992 Some(DetLogEvent::SchedulerCommit {
993 runtime_maps_read, ..
994 }) => runtime_maps_read,
995 Some(_) => false,
996 None => message.text.contains(RUNTIME_MAPS_READ_RESOURCE),
997 };
998 if !reads_runtime_maps {
999 continue;
1000 }
1001 let Some(position) = commit_position(message) else {
1002 continue;
1003 };
1004 count += 1;
1005 if first.is_none() {
1006 first = Some(position);
1007 }
1008 }
1009 (count, first)
1010}
1011
1012fn filter_ignored<'a>(lines: Vec<LogMessage<'a>>, omits: &Vec<String>) -> Vec<LogMessage<'a>> {
1013 lines
1014 .into_iter()
1015 .filter(|message| {
1016 let mut keep = true;
1017 for omit in omits {
1018 if message.text.contains(omit) {
1019 keep = false
1020 }
1021 }
1022 keep
1023 })
1024 .collect()
1025}
1026
1027fn collect_syscalls<'a>(v: &[LogMessage<'a>]) -> Vec<LogMessage<'a>> {
1028 v.iter()
1029 .filter(|entry| is_detlog_syscall(entry))
1030 .copied()
1031 .collect()
1032}
1033
1034fn matched_prefix_length(compared_left: &[String], compared_right: &[String]) -> usize {
1042 compared_left
1043 .iter()
1044 .zip(compared_right)
1045 .take_while(|(left, right)| left == right)
1046 .count()
1047}
1048
1049fn matched_prefix_for_verdict(
1062 diff_found: bool,
1063 prefix: usize,
1064 compared_left: usize,
1065 compared_right: usize,
1066) -> Option<usize> {
1067 let consistent = if diff_found {
1068 prefix < compared_left.max(compared_right)
1069 } else {
1070 prefix == compared_left && prefix == compared_right
1071 };
1072 consistent.then_some(prefix)
1073}
1074
1075fn first_different_message_indices(
1076 left: &[LogMessage<'_>],
1077 compared_left: &[String],
1078 right: &[LogMessage<'_>],
1079 compared_right: &[String],
1080) -> Option<(Option<usize>, Option<usize>)> {
1081 let common = compared_left.len().min(compared_right.len());
1082 let position = matched_prefix_length(compared_left, compared_right);
1083
1084 if position < common {
1085 return Some((Some(left[position].index), Some(right[position].index)));
1086 }
1087
1088 match compared_left.len().cmp(&compared_right.len()) {
1089 Ordering::Less => Some((None, Some(right[common].index))),
1090 Ordering::Greater => Some((Some(left[common].index), None)),
1091 Ordering::Equal => None,
1092 }
1093}
1094
1095fn first_divergent_message(message: &LogMessage<'_>) -> String {
1103 static FINISHED_SYSCALL: LazyLock<Regex> =
1104 LazyLock::new(|| Regex::new(r"(finish syscall #)[0-9][0-9_]*").unwrap());
1105 static COMMIT_TURN: LazyLock<Regex> =
1106 LazyLock::new(|| Regex::new(r"(\bCOMMIT turn )[0-9][0-9_]*\b").unwrap());
1107 static COMMITTED_TIME: LazyLock<Regex> = LazyLock::new(|| {
1108 Regex::new(r"(\b(?:at time|on previously committed) )[0-9][0-9_]*(?:\.[0-9_]+)?(?:ns|s)?\b")
1109 .unwrap()
1110 });
1111
1112 let (first_line, continuation) = match message.text.split_once('\n') {
1113 Some((first_line, continuation)) => (first_line, Some(continuation)),
1114 None => (message.text, None),
1115 };
1116 let first_line =
1117 if is_detlog_syscall_result(message) && finished_syscall_number(message).is_some() {
1118 FINISHED_SYSCALL
1119 .replace_all(first_line, "${1}<NUM>")
1120 .into_owned()
1121 } else {
1122 first_line.to_string()
1123 };
1124 let first_line = if is_commit(message) && commit_position(message).is_some() {
1125 let first_line = COMMIT_TURN.replace_all(&first_line, "${1}<NUM>");
1126 COMMITTED_TIME
1127 .replace_all(&first_line, "${1}<NANOSECONDS>")
1128 .into_owned()
1129 } else {
1130 first_line
1131 };
1132 match continuation {
1133 Some(continuation) => format!("{first_line}\n{continuation}"),
1134 None => first_line,
1135 }
1136}
1137
1138fn compared_message_at_record(
1139 records: &[LogMessage<'_>],
1140 compared: &[String],
1141 record: Option<usize>,
1142) -> Option<String> {
1143 let record = record?;
1144 let position = records.iter().position(|message| message.index == record)?;
1145 let original = records.get(position)?;
1146 let prepared = LogMessage {
1147 index: original.index,
1148 text: compared.get(position)?,
1149 event: original.event,
1150 };
1151 Some(first_divergent_message(&prepared))
1152}
1153
1154fn parse_underscored_u64(value: &str) -> Option<u64> {
1155 value.replace('_', "").parse().ok()
1156}
1157
1158fn parse_virtual_nanoseconds(value: &str, unit: Option<&str>) -> Option<u64> {
1159 match unit {
1160 None | Some("ns") if !value.contains('.') => parse_underscored_u64(value),
1161 Some("s") => {
1162 let value = value.replace('_', "");
1163 let (seconds, fraction) = value.split_once('.').unwrap_or((&value, ""));
1164 if fraction.len() > 9 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
1165 return None;
1166 }
1167 let seconds = seconds.parse::<u64>().ok()?;
1168 let fraction = if fraction.is_empty() {
1169 0
1170 } else {
1171 let digits = fraction.parse::<u64>().ok()?;
1172 digits.checked_mul(10_u64.pow((9 - fraction.len()) as u32))?
1173 };
1174 seconds.checked_mul(1_000_000_000)?.checked_add(fraction)
1175 }
1176 _ => None,
1177 }
1178}
1179
1180fn historical_commit_position(message: &str) -> Option<(u64, Option<u64>)> {
1181 static TURN: LazyLock<Regex> =
1182 LazyLock::new(|| Regex::new(r"\bCOMMIT turn ([0-9][0-9_]*)\b").unwrap());
1183 static TIME: LazyLock<Regex> = LazyLock::new(|| {
1184 Regex::new(r"\b(?:at time|on previously committed) ([0-9][0-9_]*(?:\.[0-9_]+)?)(ns|s)?\b")
1185 .unwrap()
1186 });
1187
1188 let turn = parse_underscored_u64(TURN.captures(message)?.get(1)?.as_str())?;
1189 let virtual_nanoseconds = TIME.captures(message).and_then(|captures| {
1190 parse_virtual_nanoseconds(
1191 captures.get(1)?.as_str(),
1192 captures.get(2).map(|unit| unit.as_str()),
1193 )
1194 });
1195 Some((turn, virtual_nanoseconds))
1196}
1197
1198fn commit_position(message: &LogMessage<'_>) -> Option<(u64, Option<u64>)> {
1199 match message.event {
1200 Some(DetLogEvent::SchedulerCommit {
1201 scheduler_turn,
1202 virtual_nanoseconds,
1203 ..
1204 }) => Some((scheduler_turn, Some(virtual_nanoseconds))),
1205 Some(_) => None,
1206 None => historical_commit_position(message.text),
1207 }
1208}
1209
1210fn commit_position_at_or_before(
1211 messages: &[LogMessage<'_>],
1212 message_index: usize,
1213) -> Option<(u64, Option<u64>)> {
1214 messages
1215 .iter()
1216 .rev()
1217 .filter(|message| message.index <= message_index)
1218 .find_map(commit_position)
1219}
1220
1221fn historical_finished_syscall_number(line: &str) -> Option<u64> {
1227 let rest = line.split("finish syscall #").nth(1)?;
1228 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1229 digits.parse().ok()
1230}
1231
1232fn finished_syscall_number(message: &LogMessage<'_>) -> Option<u64> {
1233 match message.event {
1234 Some(DetLogEvent::SyscallResult {
1235 finished_syscall_number,
1236 }) => Some(finished_syscall_number),
1237 Some(_) => None,
1238 None => historical_finished_syscall_number(message.text),
1239 }
1240}
1241
1242fn finished_syscall_at_or_before(v: &[LogMessage<'_>], message_index: usize) -> Option<u64> {
1256 v.iter()
1257 .rev()
1258 .filter(|message| message.index <= message_index)
1259 .find_map(finished_syscall_number)
1260}
1261
1262fn syscall_at_or_before<'a>(
1263 syscalls: &'a [LogMessage<'a>],
1264 index: usize,
1265) -> Option<LogMessage<'a>> {
1266 syscalls
1267 .iter()
1268 .rev()
1269 .find(|message| message.index <= index)
1270 .copied()
1271}
1272
1273fn sentence_case_label(label: &str) -> String {
1274 let mut characters = label.chars();
1275 match characters.next() {
1276 Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
1277 None => String::new(),
1278 }
1279}
1280
1281fn write_syscall_context(
1282 w: &mut impl std::io::Write,
1283 left_index: usize,
1284 right_index: usize,
1285 left_syscalls: &[LogMessage<'_>],
1286 right_syscalls: &[LogMessage<'_>],
1287 labels: &ComparisonSideLabels,
1288 history_count: u64,
1289) -> std::io::Result<()> {
1290 if history_count == 0 {
1291 return Ok(());
1292 }
1293
1294 let left_current = syscall_at_or_before(left_syscalls, left_index);
1295 let right_current = syscall_at_or_before(right_syscalls, right_index);
1296 if left_current.is_none() && right_current.is_none() {
1297 return Ok(());
1298 }
1299
1300 writeln!(w, "Divergent syscall context:")?;
1301 for (label, current) in [
1302 (labels.left.as_str(), left_current),
1303 (labels.right.as_str(), right_current),
1304 ] {
1305 if let Some(syscall) = current {
1306 writeln!(
1307 w,
1308 " {label}, log message {}: {}",
1309 syscall.index, syscall.text
1310 )?;
1311 } else {
1312 writeln!(w, " {label}: <no syscall observed>")?;
1313 }
1314 }
1315
1316 let history_limit = usize::try_from(history_count).unwrap_or(usize::MAX);
1317 for (label, index, syscalls) in [
1318 (labels.left.as_str(), left_index, left_syscalls),
1319 (labels.right.as_str(), right_index, right_syscalls),
1320 ] {
1321 let history_boundary =
1322 syscall_at_or_before(syscalls, index).map_or(index, |current| current.index);
1323 let mut history = syscalls
1324 .iter()
1325 .rev()
1326 .filter(|entry| entry.index < history_boundary && is_detlog_syscall_result(entry))
1327 .take(history_limit)
1328 .copied()
1329 .collect::<Vec<_>>();
1330 history.reverse();
1331 if !history.is_empty() {
1332 writeln!(w, " Prior completed syscalls for {label}:")?;
1333 for syscall in history {
1334 writeln!(w, " {}", syscall.text)?;
1335 }
1336 }
1337 }
1338 writeln!(w)?;
1339
1340 Ok(())
1341}
1342
1343pub struct Comparison<'a> {
1347 left: &'a str,
1348 right: &'a str,
1349 no_color: bool,
1350}
1351impl<'a> Comparison<'a> {
1352 pub fn new(no_color: bool, left: &'a str, right: &'a str) -> Comparison<'a> {
1356 Comparison {
1357 left,
1358 right,
1359 no_color,
1360 }
1361 }
1362}
1363impl<'a> Display for Comparison<'a> {
1364 fn fmt(&self, f: &mut Formatter) -> Result {
1365 if self.no_color {
1366 writeln!(f, "Diff < left / right > :")?;
1367 writeln!(f, "<\"{}\"", self.left)?;
1368 writeln!(f, ">\"{}\"", self.right)
1369 } else {
1370 pretty_assertions::Comparison::new(&self.left, &self.right).fmt(f)
1371 }
1372 }
1373}
1374
1375fn diff_vecs(
1386 which: &str,
1387 left: (&[LogMessage<'_>], &[String]),
1388 right: (&[LogMessage<'_>], &[String]),
1389 opts: &LogDiffOpts,
1390 w: &mut impl std::io::Write,
1391 left_syscalls: &[LogMessage<'_>],
1392 right_syscalls: &[LogMessage<'_>],
1393) -> std::io::Result<bool> {
1394 let (v1, compared_left) = left;
1395 let (v2, compared_right) = right;
1396 writeln!(w, " Comparing {which} messages...\n")?;
1397 if v1.is_empty() && v2.is_empty() {
1398 return Ok(false);
1399 }
1400
1401 let mut diff_count = 0;
1402 for (position, (left, right)) in v1.iter().zip(v2.iter()).enumerate() {
1403 let left_compared = &compared_left[position];
1404 let right_compared = &compared_right[position];
1405 if left_compared == right_compared {
1406 continue;
1407 }
1408
1409 if diff_count >= opts.limit && opts.limit != 0 {
1410 writeln!(
1411 w,
1412 "More than {} differences, eliding the rest...",
1413 opts.limit
1414 )?;
1415 break;
1416 }
1417
1418 write!(
1419 w,
1420 "({which}) Mismatch at log messages {} ({}) and {} ({}): {}",
1421 left.index,
1422 opts.side_labels.left,
1423 right.index,
1424 opts.side_labels.right,
1425 Comparison::new(opts.no_color, left_compared, right_compared)
1426 )?;
1427 if opts.strip_lines || opts.canonicalize_addresses {
1428 write!(
1429 w,
1430 "({which}) Original entries before normalization: {}",
1431 Comparison::new(opts.no_color, left.text, right.text)
1432 )?;
1433 }
1434 write_syscall_context(
1435 w,
1436 left.index,
1437 right.index,
1438 left_syscalls,
1439 right_syscalls,
1440 &opts.side_labels,
1441 opts.syscall_history,
1442 )?;
1443
1444 diff_count += 1;
1445 }
1446
1447 match v1.len().cmp(&v2.len()) {
1448 Ordering::Less => {
1449 writeln!(
1450 w,
1451 "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1452 sentence_case_label(&opts.side_labels.right),
1453 v2.len() - v1.len(),
1454 opts.side_labels.left,
1455 )?;
1456 diff_count += 1;
1457 let start = v2.len() - std::cmp::min(10, v2.len() - v1.len());
1458 for message in &compared_right[start..] {
1459 writeln!(w, "{message}")?;
1460 }
1461 }
1462 Ordering::Greater => {
1463 writeln!(
1464 w,
1465 "{} contains {} extra messages not matched in {}. Displaying up to 10:",
1466 sentence_case_label(&opts.side_labels.left),
1467 v1.len() - v2.len(),
1468 opts.side_labels.right,
1469 )?;
1470 diff_count += 1;
1471 let start = v1.len() - std::cmp::min(10, v1.len() - v2.len());
1472 for message in &compared_left[start..] {
1473 writeln!(w, "{message}")?;
1474 }
1475 }
1476 Ordering::Equal => {}
1477 }
1478
1479 Ok(diff_count > 0)
1480}
1481
1482fn write_compared_messages(
1483 writer: &mut impl std::io::Write,
1484 messages: &[String],
1485) -> std::io::Result<()> {
1486 for message in messages {
1487 writeln!(writer, "{message}")?;
1488 }
1489 Ok(())
1490}
1491
1492fn write_compared_logs(
1493 writer: &mut impl std::io::Write,
1494 policy: LogComparisonPolicy,
1495 compared_left: &[String],
1496 compared_right: &[String],
1497 labels: &ComparisonSideLabels,
1498) -> std::io::Result<()> {
1499 writeln!(writer, "Comparison policy: {}", policy.name())?;
1500 writeln!(writer, "--- begin {} compared log ---", labels.left)?;
1501 write_compared_messages(writer, compared_left)?;
1502 writeln!(writer, "--- end {} compared log ---", labels.left)?;
1503 writeln!(writer, "--- begin {} compared log ---", labels.right)?;
1504 write_compared_messages(writer, compared_right)?;
1505 writeln!(writer, "--- end {} compared log ---", labels.right)?;
1506 Ok(())
1507}
1508
1509fn git_diff(
1510 which: &str,
1511 left: (&[LogMessage<'_>], &[String]),
1512 right: (&[LogMessage<'_>], &[String]),
1513 opts: &LogDiffOpts,
1514 w: &mut impl std::io::Write,
1515 left_syscalls: &[LogMessage<'_>],
1516 right_syscalls: &[LogMessage<'_>],
1517) -> std::io::Result<bool> {
1518 let (v1, compared_left) = left;
1519 let (v2, compared_right) = right;
1520 writeln!(w, " Comparing {which} messages...\n")?;
1521
1522 let mut file1 = NamedTempFile::new()?;
1523 let mut file2 = NamedTempFile::new()?;
1524
1525 write_compared_messages(&mut file1, compared_left)?;
1526 write_compared_messages(&mut file2, compared_right)?;
1527
1528 match Command::new("git")
1529 .args(["diff", "--color", "--color-words", "-w"])
1530 .arg(file1.path())
1531 .arg(file2.path())
1532 .status()
1533 {
1534 Ok(code) => Ok(!code.success()),
1535 Err(error) => {
1536 eprintln!("Error launching git, falling back to basic diff: {error}");
1537 diff_vecs(
1538 which,
1539 (v1, compared_left),
1540 (v2, compared_right),
1541 opts,
1542 w,
1543 left_syscalls,
1544 right_syscalls,
1545 )
1546 }
1547 }
1548}
1549
1550#[derive(Debug, Clone, PartialEq, Eq)]
1558pub struct LogDiffSummary {
1559 pub diff_found: bool,
1561 pub compared_left: usize,
1563 pub compared_right: usize,
1565 pub first_divergent_scheduler_turn: Option<u64>,
1568 pub first_divergent_virtual_nanoseconds: Option<u64>,
1571 pub first_divergent_record: Option<usize>,
1575 pub first_divergent_record_positions: Option<(Option<usize>, Option<usize>)>,
1579 pub matched_prefix_messages: Option<usize>,
1594 pub first_divergent_syscall: Option<u64>,
1599 pub first_divergent_left_message: Option<String>,
1603 pub first_divergent_right_message: Option<String>,
1606 pub refusal_reason: Option<String>,
1622}
1623
1624impl LogDiffSummary {
1625 pub fn matched_with_evidence(&self) -> bool {
1628 self.refusal_reason.is_none()
1629 && !self.diff_found
1630 && self.compared_left > 0
1631 && self.compared_right > 0
1632 }
1633}
1634
1635pub fn log_diff(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> bool {
1652 log_diff_detailed(file_a, file_b, opts).diff_found
1653}
1654
1655pub fn log_diff_detailed(file_a: &Path, file_b: &Path, opts: &LogDiffOpts) -> LogDiffSummary {
1658 try_log_diff_detailed(file_a, file_b, opts).expect("could not read or compare log inputs")
1659}
1660
1661pub fn try_log_diff_detailed(
1664 file_a: &Path,
1665 file_b: &Path,
1666 opts: &LogDiffOpts,
1667) -> std::io::Result<LogDiffSummary> {
1668 try_log_diff_detailed_with_filter(file_a, file_b, opts, |_| true)
1669}
1670
1671pub fn try_compare_bitwise_info_v1(
1680 file_a: &Path,
1681 file_b: &Path,
1682 side_labels: ComparisonSideLabels,
1683) -> std::io::Result<LogDiffSummary> {
1684 try_compare_bitwise_info_v1_with_records(file_a, file_b, side_labels)
1685 .map(|(summary, _, _)| summary)
1686}
1687
1688pub fn try_compare_bitwise_info_v1_with_records(
1690 file_a: &Path,
1691 file_b: &Path,
1692 side_labels: ComparisonSideLabels,
1693) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1694 let bytes_a = std::fs::read(file_a)?;
1695 let bytes_b = std::fs::read(file_b)?;
1696 try_compare_bitwise_info_v1_bytes_with_records(&bytes_a, &bytes_b, side_labels)
1697}
1698
1699pub fn try_compare_bitwise_info_v1_bytes_with_records(
1701 bytes_a: &[u8],
1702 bytes_b: &[u8],
1703 side_labels: ComparisonSideLabels,
1704) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1705 try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1706 bytes_a,
1707 bytes_b,
1708 side_labels,
1709 BitwiseInfoV1Diagnostics::default(),
1710 &mut std::io::stderr(),
1711 )
1712}
1713
1714#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1720pub struct BitwiseInfoV1Diagnostics {
1721 pub difference_limit: u64,
1723 pub syscall_history: u64,
1725 pub no_color: bool,
1727 pub print_logs: bool,
1729}
1730
1731impl Default for BitwiseInfoV1Diagnostics {
1732 fn default() -> Self {
1733 Self {
1734 difference_limit: 20,
1735 syscall_history: 5,
1736 no_color: false,
1737 print_logs: false,
1738 }
1739 }
1740}
1741
1742pub fn try_compare_bitwise_info_v1_with_records_and_diagnostics(
1744 file_a: &Path,
1745 file_b: &Path,
1746 side_labels: ComparisonSideLabels,
1747 diagnostics: BitwiseInfoV1Diagnostics,
1748 writer: &mut impl Write,
1749) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1750 let bytes_a = std::fs::read(file_a)?;
1751 let bytes_b = std::fs::read(file_b)?;
1752 try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1753 &bytes_a,
1754 &bytes_b,
1755 side_labels,
1756 diagnostics,
1757 writer,
1758 )
1759}
1760
1761pub fn try_compare_bitwise_info_v1_with_diagnostics(
1763 file_a: &Path,
1764 file_b: &Path,
1765 side_labels: ComparisonSideLabels,
1766 diagnostics: BitwiseInfoV1Diagnostics,
1767 writer: &mut impl Write,
1768) -> std::io::Result<LogDiffSummary> {
1769 try_compare_bitwise_info_v1_with_records_and_diagnostics(
1770 file_a,
1771 file_b,
1772 side_labels,
1773 diagnostics,
1774 writer,
1775 )
1776 .map(|(summary, _, _)| summary)
1777}
1778
1779pub fn try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
1781 bytes_a: &[u8],
1782 bytes_b: &[u8],
1783 side_labels: ComparisonSideLabels,
1784 diagnostics: BitwiseInfoV1Diagnostics,
1785 writer: &mut impl Write,
1786) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1787 let str_a = std::str::from_utf8(bytes_a).map_err(|error| {
1788 std::io::Error::new(
1789 std::io::ErrorKind::InvalidData,
1790 format!("{} is not UTF-8: {error}", side_labels.left),
1791 )
1792 })?;
1793 let str_b = std::str::from_utf8(bytes_b).map_err(|error| {
1794 std::io::Error::new(
1795 std::io::ErrorKind::InvalidData,
1796 format!("{} is not UTF-8: {error}", side_labels.right),
1797 )
1798 })?;
1799 let mut options = bitwise_info_v1_options(side_labels);
1800 options.limit = diagnostics.difference_limit;
1801 options.syscall_history = diagnostics.syscall_history;
1802 options.no_color = diagnostics.no_color;
1803 options.print_logs = diagnostics.print_logs;
1804 let records_a = record_count(str_a);
1805 let records_b = record_count(str_b);
1806 let summary = log_diff_summary_from_strs_with_filter(str_a, str_b, &options, writer, |_| true)?;
1807 Ok((summary, records_a, records_b))
1808}
1809
1810fn bitwise_info_v1_options(side_labels: ComparisonSideLabels) -> LogDiffOpts {
1812 LogDiffOpts {
1813 strip_lines: false,
1814 canonicalize_addresses: true,
1815 comparison: LogComparisonMode::Info,
1816 side_labels,
1817 require_structured_events: true,
1818 print_logs: false,
1819 limit: 20,
1820 ignore_lines: Vec::new(),
1821 syscall_history: 5,
1822 no_color: false,
1823 skip_commit: false,
1824 skip_detlog: false,
1825 git_diff: false,
1826 include_detlogs: vec![
1827 DetLogFilter::Syscall,
1828 DetLogFilter::SyscallResult,
1829 DetLogFilter::Other,
1830 ],
1831 }
1832}
1833
1834pub fn try_log_diff_detailed_with_filter(
1842 file_a: &Path,
1843 file_b: &Path,
1844 opts: &LogDiffOpts,
1845 keep_record: impl Fn(&str) -> bool,
1846) -> std::io::Result<LogDiffSummary> {
1847 let vec_a = std::fs::read(file_a)?;
1851 let vec_b = std::fs::read(file_b)?;
1852 let str_a = String::from_utf8_lossy(&vec_a);
1853 let str_b = String::from_utf8_lossy(&vec_b);
1854 log_diff_summary_from_strs_with_filter(str_a, str_b, opts, &mut std::io::stderr(), keep_record)
1855}
1856
1857pub fn record_count(contents: &str) -> usize {
1860 record_starts(contents).len()
1861}
1862
1863pub fn try_log_diff_with_records(
1867 file_a: &Path,
1868 file_b: &Path,
1869 opts: &LogDiffOpts,
1870) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1871 try_log_diff_with_records_and_filter(file_a, file_b, opts, |_| true)
1872}
1873
1874pub fn try_log_diff_with_records_and_filter(
1878 file_a: &Path,
1879 file_b: &Path,
1880 opts: &LogDiffOpts,
1881 keep_record: impl Fn(&str) -> bool,
1882) -> std::io::Result<(LogDiffSummary, usize, usize)> {
1883 let vec_a = std::fs::read(file_a)?;
1884 let vec_b = std::fs::read(file_b)?;
1885 let str_a = String::from_utf8_lossy(&vec_a);
1886 let str_b = String::from_utf8_lossy(&vec_b);
1887 let records_a = record_count(&str_a);
1888 let records_b = record_count(&str_b);
1889 let summary = log_diff_summary_from_strs_with_filter(
1890 &str_a,
1891 &str_b,
1892 opts,
1893 &mut std::io::stderr(),
1894 keep_record,
1895 )?;
1896 Ok((summary, records_a, records_b))
1897}
1898
1899#[derive(Debug, Clone, PartialEq, Eq)]
1906pub struct PrefixComparison {
1907 pub summary: LogDiffSummary,
1909 pub records_available_left: usize,
1911 pub records_available_right: usize,
1913 pub records_compared: usize,
1915}
1916
1917impl PrefixComparison {
1918 pub fn one_side_is_ahead(&self) -> bool {
1921 self.records_available_left != self.records_available_right
1922 }
1923}
1924
1925pub fn compare_complete_prefix(
1933 contents_a: &str,
1934 contents_b: &str,
1935 opts: &LogDiffOpts,
1936 w: &mut impl std::io::Write,
1937) -> std::io::Result<PrefixComparison> {
1938 compare_complete_prefix_with_filter(contents_a, contents_b, opts, w, |_| true)
1939}
1940
1941pub fn compare_complete_bitwise_info_v1_prefix(
1951 bytes_a: &[u8],
1952 bytes_b: &[u8],
1953 side_labels: ComparisonSideLabels,
1954 diagnostics: BitwiseInfoV1Diagnostics,
1955 w: &mut impl std::io::Write,
1956) -> std::io::Result<PrefixComparison> {
1957 let contents_a = std::str::from_utf8(bytes_a).map_err(|error| {
1958 std::io::Error::new(
1959 std::io::ErrorKind::InvalidData,
1960 format!("{} is not UTF-8: {error}", side_labels.left),
1961 )
1962 })?;
1963 let contents_b = std::str::from_utf8(bytes_b).map_err(|error| {
1964 std::io::Error::new(
1965 std::io::ErrorKind::InvalidData,
1966 format!("{} is not UTF-8: {error}", side_labels.right),
1967 )
1968 })?;
1969
1970 let truncated_a = log_was_truncated(contents_a);
1971 let truncated_b = log_was_truncated(contents_b);
1972 if truncated_a || truncated_b {
1973 let which_side = match (truncated_a, truncated_b) {
1974 (true, true) => "both logs were",
1975 (true, false) => "the first log was",
1976 (false, true) => "the second log was",
1977 (false, false) => unreachable!("guarded by the condition above"),
1978 };
1979 return Err(std::io::Error::new(
1980 std::io::ErrorKind::InvalidData,
1981 format!(
1982 "{which_side} truncated at the configured size bound; the discarded tail was never written"
1983 ),
1984 ));
1985 }
1986
1987 let records_available_left = complete_record_count(contents_a);
1988 let records_available_right = complete_record_count(contents_b);
1989 for (label, contents, available) in [
1990 (
1991 side_labels.left.as_str(),
1992 contents_a,
1993 records_available_left,
1994 ),
1995 (
1996 side_labels.right.as_str(),
1997 contents_b,
1998 records_available_right,
1999 ),
2000 ] {
2001 let complete = take_complete_records(contents, available)
2002 .expect("the complete-record count always identifies its own prefix");
2003 let records = extract_log_messages(complete)
2004 .map_err(|error| std::io::Error::new(error.kind(), format!("{label} {error}")))?;
2005 validate_structured_events(label, &records, true)?;
2006 }
2007
2008 let records_compared = records_available_left.min(records_available_right);
2009 let prefix_a = take_complete_records(contents_a, records_compared)
2010 .expect("common prefix never exceeds either side's complete record count");
2011 let prefix_b = take_complete_records(contents_b, records_compared)
2012 .expect("common prefix never exceeds either side's complete record count");
2013 let mut options = bitwise_info_v1_options(side_labels);
2014 options.limit = diagnostics.difference_limit;
2015 options.syscall_history = diagnostics.syscall_history;
2016 options.no_color = diagnostics.no_color;
2017 options.print_logs = diagnostics.print_logs;
2018 let summary =
2019 log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, &options, w, |_| true)?;
2020 Ok(PrefixComparison {
2021 summary,
2022 records_available_left,
2023 records_available_right,
2024 records_compared,
2025 })
2026}
2027
2028pub fn compare_complete_prefix_with_filter(
2032 contents_a: &str,
2033 contents_b: &str,
2034 opts: &LogDiffOpts,
2035 w: &mut impl std::io::Write,
2036 keep_record: impl Fn(&str) -> bool,
2037) -> std::io::Result<PrefixComparison> {
2038 let records_available_left = complete_record_count(contents_a);
2039 let records_available_right = complete_record_count(contents_b);
2040 let records_compared = records_available_left.min(records_available_right);
2041 let prefix_a = take_complete_records(contents_a, records_compared)
2043 .expect("common prefix never exceeds either side's complete record count");
2044 let prefix_b = take_complete_records(contents_b, records_compared)
2045 .expect("common prefix never exceeds either side's complete record count");
2046 let summary = log_diff_summary_from_strs_with_filter(prefix_a, prefix_b, opts, w, keep_record)?;
2047 Ok(PrefixComparison {
2048 summary,
2049 records_available_left,
2050 records_available_right,
2051 records_compared,
2052 })
2053}
2054
2055pub fn is_unobserved_receive(line: &str) -> bool {
2063 static RECORD: LazyLock<Regex> = LazyLock::new(|| {
2064 Regex::new(
2065 r#"^\S+\s+INFO (?:[\w.]+(?:\{.*?\})?:)*\s?detcore::io_buffers: DETLOG \[iobuf\]\[dtid \d+\] recvmsg in fd=\S+ unobserved ret=\d+ reason=[a-z-]+(?: [a-z]+=\S+)? DETLOG_RECORD=\{"schema":1,"event":\{"kind":"other"\}\}$"#,
2066 )
2067 .unwrap()
2068 });
2069 RECORD.is_match(line)
2070}
2071
2072pub fn count_unobserved_receives(path: &Path) -> std::io::Result<usize> {
2074 use std::io::BufRead;
2075 let mut count = 0;
2076 for line in std::io::BufReader::new(std::fs::File::open(path)?).split(b'\n') {
2077 if is_unobserved_receive(&String::from_utf8_lossy(&line?)) {
2078 count += 1;
2079 }
2080 }
2081 Ok(count)
2082}
2083
2084#[cfg(test)]
2087fn log_diff_from_strs(
2088 file_a_str: impl AsRef<str>,
2089 file_b_str: impl AsRef<str>,
2090 opts: &LogDiffOpts,
2091 w: &mut impl std::io::Write,
2092) -> std::io::Result<bool> {
2093 Ok(log_diff_summary_from_strs(file_a_str, file_b_str, opts, w)?.diff_found)
2094}
2095
2096#[cfg(test)]
2097fn log_diff_summary_from_strs(
2098 file_a_str: impl AsRef<str>,
2099 file_b_str: impl AsRef<str>,
2100 opts: &LogDiffOpts,
2101 w: &mut impl std::io::Write,
2102) -> std::io::Result<LogDiffSummary> {
2103 log_diff_summary_from_strs_with_filter(file_a_str, file_b_str, opts, w, |_| true)
2104}
2105
2106pub fn log_diff_summary_from_strs_with_filter(
2114 file_a_str: impl AsRef<str>,
2115 file_b_str: impl AsRef<str>,
2116 opts: &LogDiffOpts,
2117 w: &mut impl std::io::Write,
2118 keep_record: impl Fn(&str) -> bool,
2119) -> std::io::Result<LogDiffSummary> {
2120 let truncated_a = log_was_truncated(file_a_str.as_ref());
2130 let truncated_b = log_was_truncated(file_b_str.as_ref());
2131 if truncated_a || truncated_b {
2132 let which_side = match (truncated_a, truncated_b) {
2133 (true, true) => "both logs were",
2134 (true, false) => "the first log was",
2135 (false, true) => "the second log was",
2136 (false, false) => unreachable!("guarded by the condition above"),
2137 };
2138 let refusal_reason = format!(
2139 "{which_side} truncated at the configured size bound (the log ends with the bounded writer's truncation marker, or with the stop line of hermit's --max-log-bytes cap). 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 that bound), or a larger --max-log-bytes."
2140 );
2141 writeln!(
2142 w,
2143 "REFUSING to compare: {refusal_reason} This is a NO-RESULT, not a difference and not a match."
2144 )?;
2145 return Ok(LogDiffSummary {
2146 diff_found: true,
2147 compared_left: 0,
2148 compared_right: 0,
2149 first_divergent_scheduler_turn: None,
2150 first_divergent_virtual_nanoseconds: None,
2151 first_divergent_record: None,
2152 first_divergent_record_positions: None,
2153 matched_prefix_messages: None,
2154 first_divergent_syscall: None,
2155 first_divergent_left_message: None,
2156 first_divergent_right_message: None,
2157 refusal_reason: Some(refusal_reason),
2158 });
2159 }
2160
2161 let extracted_a = extract_log_messages(file_a_str.as_ref())?;
2162 let extracted_b = extract_log_messages(file_b_str.as_ref())?;
2163 validate_structured_events(
2164 opts.side_labels.left.as_str(),
2165 &extracted_a,
2166 opts.require_structured_events,
2167 )?;
2168 validate_structured_events(
2169 opts.side_labels.right.as_str(),
2170 &extracted_b,
2171 opts.require_structured_events,
2172 )?;
2173 let all_a = filter_ignored(
2174 extracted_a
2175 .into_iter()
2176 .filter(|record| keep_record(record.text))
2177 .collect(),
2178 &opts.ignore_lines,
2179 );
2180 let all_b = filter_ignored(
2181 extracted_b
2182 .into_iter()
2183 .filter(|record| keep_record(record.text))
2184 .collect(),
2185 &opts.ignore_lines,
2186 );
2187
2188 writeln!(
2189 w,
2190 "Logs contain {} | {} messages total",
2191 all_a.len(),
2192 all_b.len(),
2193 )?;
2194
2195 let detcore_a = filter_detcore(&all_a);
2196 let detcore_b = filter_detcore(&all_b);
2197 let infos_a = filter_infos(&all_a);
2198 let infos_b = filter_infos(&all_b);
2199 let detlogs_a = opts.filter_deterministic(&detcore_a);
2200 let detlogs_b = opts.filter_deterministic(&detcore_b);
2201 let left_syscalls = collect_syscalls(&all_a);
2202 let right_syscalls = collect_syscalls(&all_b);
2203 writeln!(
2204 w,
2205 "Logs contain {} | {} detcore-specific messages",
2206 detcore_a.len(),
2207 detcore_b.len(),
2208 )?;
2209 writeln!(
2210 w,
2211 "Logs contain {} | {} INFO messages",
2212 infos_a.len(),
2213 infos_b.len(),
2214 )?;
2215 writeln!(
2216 w,
2217 "Logs contain {} | {} DETLOG & scheduler COMMIT messages",
2218 detlogs_a.len(),
2219 detlogs_b.len(),
2220 )?;
2221
2222 let policy = LogComparisonPolicy::from_options(opts);
2223
2224 if policy.normalization == LogNormalization::Stripped {
2225 writeln!(
2226 w,
2227 "Normalizing known nondeterministic numerical data before comparison..."
2228 )?;
2229 } else if policy.normalization == LogNormalization::Canonical {
2230 writeln!(
2231 w,
2232 "Canonicalizing host addresses (ordinal by first appearance); comparing everything else exactly..."
2233 )?;
2234 }
2235
2236 let (which, compared_a, compared_b) = match policy.comparison {
2237 LogComparisonMode::Deterministic => ("DETLOG", &detlogs_a, &detlogs_b),
2238 LogComparisonMode::Info => ("INFO", &infos_a, &infos_b),
2239 LogComparisonMode::FullTrace => ("full trace", &all_a, &all_b),
2240 };
2241
2242 let prepared_a = messages_for_comparison(compared_a, policy);
2247 let prepared_b = messages_for_comparison(compared_b, policy);
2248
2249 if opts.print_logs {
2250 write_compared_logs(w, policy, &prepared_a, &prepared_b, &opts.side_labels)?;
2251 }
2252
2253 let first_different =
2254 first_different_message_indices(compared_a, &prepared_a, compared_b, &prepared_b);
2255 let first_position_candidate = first_different.and_then(|(left_index, right_index)| {
2256 left_index
2257 .and_then(|index| commit_position_at_or_before(&all_a, index))
2258 .or_else(|| right_index.and_then(|index| commit_position_at_or_before(&all_b, index)))
2259 });
2260
2261 let first_divergent_syscall_candidate = first_different.and_then(|(left, right)| {
2262 left.and_then(|index| finished_syscall_at_or_before(&all_a, index))
2263 .or_else(|| right.and_then(|index| finished_syscall_at_or_before(&all_b, index)))
2264 });
2265 let first_divergent_left_message = first_different
2266 .and_then(|(left, _)| compared_message_at_record(compared_a, &prepared_a, left));
2267 let first_divergent_right_message = first_different
2268 .and_then(|(_, right)| compared_message_at_record(compared_b, &prepared_b, right));
2269
2270 let diff_found = if opts.git_diff {
2271 git_diff(
2272 which,
2273 (compared_a, &prepared_a),
2274 (compared_b, &prepared_b),
2275 opts,
2276 w,
2277 &left_syscalls,
2278 &right_syscalls,
2279 )?
2280 } else {
2281 diff_vecs(
2282 which,
2283 (compared_a, &prepared_a),
2284 (compared_b, &prepared_b),
2285 opts,
2286 w,
2287 &left_syscalls,
2288 &right_syscalls,
2289 )?
2290 };
2291
2292 let summary = LogDiffSummary {
2293 diff_found,
2294 compared_left: compared_a.len(),
2295 compared_right: compared_b.len(),
2296 first_divergent_scheduler_turn: diff_found
2297 .then_some(first_position_candidate)
2298 .flatten()
2299 .map(|(turn, _)| turn),
2300 first_divergent_record_positions: diff_found.then_some(first_different).flatten(),
2301 first_divergent_virtual_nanoseconds: diff_found
2302 .then_some(first_position_candidate)
2303 .flatten()
2304 .and_then(|(_, time)| time),
2305 first_divergent_record: diff_found
2306 .then_some(first_different)
2307 .flatten()
2308 .and_then(|(left_index, right_index)| left_index.or(right_index)),
2309 matched_prefix_messages: matched_prefix_for_verdict(
2313 diff_found,
2314 matched_prefix_length(&prepared_a, &prepared_b),
2315 compared_a.len(),
2316 compared_b.len(),
2317 ),
2318 first_divergent_syscall: diff_found
2319 .then_some(first_divergent_syscall_candidate)
2320 .flatten(),
2321 first_divergent_left_message: diff_found.then_some(first_divergent_left_message).flatten(),
2322 first_divergent_right_message: diff_found
2323 .then_some(first_divergent_right_message)
2324 .flatten(),
2325 refusal_reason: None,
2327 };
2328
2329 if diff_found {
2330 writeln!(w, "Done processing logs, differences found.")?;
2331 } else if summary.compared_left == 0 && summary.compared_right == 0 {
2332 writeln!(
2336 w,
2337 "Done processing logs, but ZERO {which} messages were selected on either side: \
2338 nothing was compared (no-result, not a match)."
2339 )?;
2340 } else {
2341 writeln!(
2342 w,
2343 "Done processing logs, no substantive differences found ({} | {} {which} messages compared).",
2344 summary.compared_left, summary.compared_right,
2345 )?;
2346 writeln!(
2357 w,
2358 "Logs contain {} | {} scheduler empty-run-queue kick messages",
2359 count_empty_queue_kicks(&infos_a),
2360 count_empty_queue_kicks(&infos_b),
2361 )?;
2362 let (maps_left, first_left) = maps_read_commits(&infos_a);
2383 let (maps_right, first_right) = maps_read_commits(&infos_b);
2384 let positions = if first_left.is_none() && first_right.is_none() {
2385 String::new()
2386 } else {
2387 format!(
2388 " ({} {}, {} {})",
2389 opts.side_labels.left,
2390 describe_maps_commit(first_left),
2391 opts.side_labels.right,
2392 describe_maps_commit(first_right),
2393 )
2394 };
2395 writeln!(
2396 w,
2397 "Logs contain {maps_left} | {maps_right} scheduler COMMIT records reading /proc/self/maps{positions}",
2398 )?;
2399 }
2400 Ok(summary)
2401}
2402
2403#[cfg(test)]
2404mod test {
2405 use clap::CommandFactory;
2406 use clap::Parser;
2407 use pretty_assertions::assert_eq;
2408
2409 use super::finished_syscall_at_or_before;
2410 use super::finished_syscall_number;
2411 use crate::detlog::DetLogEvent;
2412 use crate::logdiff::DetLogFilter;
2413
2414 fn record(second: usize, body: &str) -> String {
2417 format!("Apr 09 06:08:{second:02}.100 INFO detcore: {body}\n")
2418 }
2419
2420 fn structured_record(second: usize, body: &str, event: DetLogEvent) -> String {
2421 record(
2422 second,
2423 &format!("{body}{}", crate::detlog::record_suffix(event)),
2424 )
2425 }
2426
2427 fn historical(index: usize, text: &str) -> super::LogMessage<'_> {
2428 super::LogMessage {
2429 index,
2430 text,
2431 event: None,
2432 }
2433 }
2434
2435 fn indexed_text<'a>(messages: &'a [super::LogMessage<'a>]) -> Vec<(usize, &'a str)> {
2436 messages
2437 .iter()
2438 .map(|message| (message.index, message.text))
2439 .collect()
2440 }
2441
2442 fn info_opts() -> super::LogDiffOpts {
2443 super::LogDiffOpts {
2444 comparison: super::LogComparisonMode::Info,
2445 ..Default::default()
2446 }
2447 }
2448
2449 fn compare(left: &str, right: &str) -> super::PrefixComparison {
2450 super::compare_complete_prefix(left, right, &info_opts(), &mut Vec::new())
2451 .expect("comparing in-memory strings cannot fail on I/O")
2452 }
2453
2454 fn temp_log(contents: &str) -> tempfile::NamedTempFile {
2455 let file = tempfile::NamedTempFile::new().expect("create temporary log");
2456 std::fs::write(file.path(), contents).expect("write temporary log");
2457 file
2458 }
2459
2460 #[test]
2461 fn bitwise_info_v1_binds_the_complete_policy() {
2462 let labels = super::ComparisonSideLabels::new("left", "right");
2463 let options = super::bitwise_info_v1_options(labels.clone());
2464 assert!(!options.strip_lines);
2465 assert!(options.canonicalize_addresses);
2466 assert_eq!(options.comparison, super::LogComparisonMode::Info);
2467 assert_eq!(options.side_labels, labels);
2468 assert!(options.require_structured_events);
2469 assert!(!options.print_logs);
2470 assert_eq!(options.limit, 20);
2471 assert!(options.ignore_lines.is_empty());
2472 assert_eq!(options.syscall_history, 5);
2473 assert!(!options.no_color);
2474 assert!(!options.skip_commit);
2475 assert!(!options.skip_detlog);
2476 assert!(!options.git_diff);
2477 assert_eq!(
2478 options.include_detlogs,
2479 [
2480 DetLogFilter::Syscall,
2481 DetLogFilter::SyscallResult,
2482 DetLogFilter::Other,
2483 ]
2484 );
2485 }
2486
2487 #[test]
2488 fn bitwise_info_v1_matches_and_renders_current_records() -> std::io::Result<()> {
2489 let left_text = structured_record(
2490 1,
2491 &format!("DETLOG allocation={}", super::host_addr(0x1000)),
2492 DetLogEvent::Other,
2493 );
2494 let right_text = structured_record(
2495 1,
2496 &format!("DETLOG allocation={}", super::host_addr(0x9000)),
2497 DetLogEvent::Other,
2498 );
2499 let left = temp_log(&left_text);
2500 let right = temp_log(&right_text);
2501 let summary = super::try_compare_bitwise_info_v1(
2502 left.path(),
2503 right.path(),
2504 super::ComparisonSideLabels::new("left", "right"),
2505 )?;
2506 assert!(summary.matched_with_evidence());
2507 assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2508
2509 let mut rendered = Vec::new();
2510 assert_eq!(
2511 super::write_bitwise_info_v1_bytes(left_text.as_bytes(), "left", &mut rendered)?,
2512 1
2513 );
2514 let rendered = String::from_utf8(rendered).unwrap();
2515 assert!(rendered.contains("<addr1>"));
2516 assert!(!rendered.contains("0x1000"));
2517 Ok(())
2518 }
2519
2520 #[test]
2521 fn bitwise_info_v1_reports_first_divergence() -> std::io::Result<()> {
2522 let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2523 let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2524 let mut diagnostic = Vec::new();
2525 let (summary, records_left, records_right) =
2526 super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2527 left.as_bytes(),
2528 right.as_bytes(),
2529 super::ComparisonSideLabels::new("left", "right"),
2530 super::BitwiseInfoV1Diagnostics {
2531 difference_limit: 1,
2532 syscall_history: 0,
2533 no_color: true,
2534 print_logs: false,
2535 },
2536 &mut diagnostic,
2537 )?;
2538 assert!(summary.diff_found);
2539 assert!(summary.refusal_reason.is_none());
2540 assert_eq!(summary.first_divergent_record, Some(1));
2541 assert_eq!((summary.compared_left, summary.compared_right), (1, 1));
2542 assert_eq!((records_left, records_right), (1, 1));
2543 assert!(
2544 String::from_utf8(diagnostic)
2545 .unwrap()
2546 .contains("payload=left")
2547 );
2548 Ok(())
2549 }
2550
2551 #[test]
2552 fn bitwise_info_v1_diagnostics_do_not_change_the_verdict() -> std::io::Result<()> {
2553 let left = structured_record(1, "DETLOG payload=left", DetLogEvent::Other);
2554 let right = structured_record(1, "DETLOG payload=right", DetLogEvent::Other);
2555 let baseline = super::try_compare_bitwise_info_v1_bytes_with_records(
2556 left.as_bytes(),
2557 right.as_bytes(),
2558 super::ComparisonSideLabels::default(),
2559 )?;
2560 let mut diagnostic = Vec::new();
2561 let varied = super::try_compare_bitwise_info_v1_bytes_with_records_and_diagnostics(
2562 left.as_bytes(),
2563 right.as_bytes(),
2564 super::ComparisonSideLabels::default(),
2565 super::BitwiseInfoV1Diagnostics {
2566 difference_limit: 0,
2567 syscall_history: 10,
2568 no_color: true,
2569 print_logs: true,
2570 },
2571 &mut diagnostic,
2572 )?;
2573 assert_eq!(baseline, varied);
2574 assert!(!diagnostic.is_empty());
2575 Ok(())
2576 }
2577
2578 #[test]
2579 fn bitwise_info_v1_refuses_empty_missing_and_unreadable_inputs() -> std::io::Result<()> {
2580 let empty_left = temp_log("");
2581 let empty_right = temp_log("");
2582 let summary = super::try_compare_bitwise_info_v1(
2583 empty_left.path(),
2584 empty_right.path(),
2585 super::ComparisonSideLabels::default(),
2586 )?;
2587 assert!(!summary.matched_with_evidence());
2588 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2589
2590 let missing_parent = tempfile::tempdir()?;
2591 let missing = missing_parent.path().join("missing.log");
2592 assert!(
2593 super::try_compare_bitwise_info_v1(
2594 &missing,
2595 empty_right.path(),
2596 super::ComparisonSideLabels::default(),
2597 )
2598 .is_err()
2599 );
2600 let directory = tempfile::tempdir()?;
2601 assert!(
2602 super::try_compare_bitwise_info_v1(
2603 directory.path(),
2604 empty_right.path(),
2605 super::ComparisonSideLabels::default(),
2606 )
2607 .is_err()
2608 );
2609 Ok(())
2610 }
2611
2612 #[test]
2613 fn bitwise_info_v1_refuses_invalid_utf8_and_truncation() -> std::io::Result<()> {
2614 let valid = structured_record(1, "DETLOG payload", DetLogEvent::Other);
2615 let mut invalid = valid.clone().into_bytes();
2616 invalid.insert(invalid.len() - 1, 0x80);
2617 let error = super::try_compare_bitwise_info_v1_bytes_with_records(
2618 &invalid,
2619 valid.as_bytes(),
2620 super::ComparisonSideLabels::new("left", "right"),
2621 )
2622 .expect_err("invalid UTF-8 must refuse");
2623 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2624 assert!(error.to_string().contains("left"));
2625
2626 let truncated = temp_log(&format!("{valid}{}\n", super::TRUNCATION_MARKER));
2627 let complete = temp_log(&valid);
2628 let summary = super::try_compare_bitwise_info_v1(
2629 truncated.path(),
2630 complete.path(),
2631 super::ComparisonSideLabels::default(),
2632 )?;
2633 assert!(summary.diff_found);
2634 assert!(
2635 summary
2636 .refusal_reason
2637 .as_deref()
2638 .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
2639 "the typed result must retain the truncation refusal cause"
2640 );
2641 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
2642 Ok(())
2643 }
2644
2645 #[test]
2646 fn bitwise_info_v1_requires_current_structured_events() {
2647 let historical = temp_log(&record(1, "DETLOG stable"));
2648 let error = super::try_compare_bitwise_info_v1(
2649 historical.path(),
2650 historical.path(),
2651 super::ComparisonSideLabels::new("left", "right"),
2652 )
2653 .expect_err("prose-only DETLOG must refuse");
2654 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2655 assert!(
2656 error
2657 .to_string()
2658 .contains("missing its structured DETLOG result")
2659 );
2660 }
2661
2662 #[test]
2663 fn bitwise_info_v1_complete_prefix_withholds_the_unfinished_tail() -> std::io::Result<()> {
2664 let common = structured_record(1, "DETLOG common", DetLogEvent::Other);
2665 let left = format!(
2666 "{common}{}",
2667 structured_record(2, "DETLOG unfinished-left", DetLogEvent::Other)
2668 );
2669 let right = format!(
2670 "{common}{}",
2671 structured_record(2, "DETLOG unfinished-right", DetLogEvent::Other)
2672 );
2673 let comparison = super::compare_complete_bitwise_info_v1_prefix(
2674 left.as_bytes(),
2675 right.as_bytes(),
2676 super::ComparisonSideLabels::new("left", "right"),
2677 super::BitwiseInfoV1Diagnostics::default(),
2678 &mut Vec::new(),
2679 )?;
2680 assert_eq!(comparison.records_available_left, 1);
2681 assert_eq!(comparison.records_available_right, 1);
2682 assert_eq!(comparison.records_compared, 1);
2683 assert!(comparison.summary.matched_with_evidence());
2684 assert_eq!(comparison.summary.compared_left, 1);
2685 assert_eq!(comparison.summary.compared_right, 1);
2686 Ok(())
2687 }
2688
2689 #[test]
2690 fn a_record_is_complete_only_once_the_next_one_starts() {
2691 assert_eq!(super::complete_record_count(""), 0);
2692
2693 let one = record(1, "first");
2695 assert_eq!(super::complete_record_count(&one), 0);
2696
2697 let two = format!("{}{}", record(1, "first"), record(2, "second"));
2699 assert_eq!(super::complete_record_count(&two), 1);
2700
2701 let three = format!("{two}{}", record(3, "third"));
2702 assert_eq!(super::complete_record_count(&three), 2);
2703 }
2704
2705 #[test]
2706 fn a_multiline_record_counts_once_and_is_not_split_at_its_newlines() {
2707 let multiline = format!(
2708 "{}{}",
2709 record(1, "first\n continued detail\n more detail"),
2710 record(2, "second")
2711 );
2712 assert_eq!(super::complete_record_count(&multiline), 1);
2715
2716 let prefix = super::take_complete_records(&multiline, 1).unwrap();
2717 assert!(prefix.contains("continued detail"));
2718 assert!(prefix.contains("more detail"));
2719 assert!(!prefix.contains("second"));
2720 }
2721
2722 #[test]
2723 fn asking_past_the_written_end_is_none_not_a_short_answer() {
2724 let two = format!("{}{}", record(1, "first"), record(2, "second"));
2725 assert_eq!(super::take_complete_records(&two, 0), Some(""));
2726 assert!(super::take_complete_records(&two, 1).is_some());
2727 assert_eq!(super::take_complete_records(&two, 2), None);
2730 assert_eq!(super::take_complete_records(&two, 99), None);
2731 }
2732
2733 #[test]
2734 fn a_half_written_final_record_is_never_a_difference() {
2735 let left = format!(
2738 "{}{}{}",
2739 record(1, "same"),
2740 record(2, "same"),
2741 record(3, "TAIL-LEFT")
2742 );
2743 let right = format!(
2744 "{}{}{}",
2745 record(1, "same"),
2746 record(2, "same"),
2747 record(3, "TAIL-RIGHT-AND-LONGER")
2748 );
2749
2750 let comparison = compare(&left, &right);
2751 assert!(
2752 !comparison.summary.diff_found,
2753 "an unfinished record must not read as a divergence"
2754 );
2755 assert_eq!(comparison.records_compared, 2);
2756 assert!(!comparison.one_side_is_ahead());
2757 }
2758
2759 #[test]
2760 fn a_difference_inside_the_completed_prefix_is_found() {
2761 let left = format!(
2762 "{}{}{}",
2763 record(1, "same"),
2764 record(2, "LEFT"),
2765 record(3, "tail")
2766 );
2767 let right = format!(
2768 "{}{}{}",
2769 record(1, "same"),
2770 record(2, "RIGHT"),
2771 record(3, "tail")
2772 );
2773
2774 let comparison = compare(&left, &right);
2775 assert!(comparison.summary.diff_found);
2776 assert_eq!(comparison.records_compared, 2);
2777 }
2778
2779 #[test]
2780 fn comparison_is_bounded_by_the_shorter_log_and_says_so() {
2781 let ahead = format!(
2782 "{}{}{}{}{}",
2783 record(1, "same"),
2784 record(2, "same"),
2785 record(3, "same"),
2786 record(4, "same"),
2787 record(5, "same")
2788 );
2789 let behind = format!(
2790 "{}{}{}",
2791 record(1, "same"),
2792 record(2, "same"),
2793 record(3, "same")
2794 );
2795
2796 let comparison = compare(&ahead, &behind);
2797 assert!(!comparison.summary.diff_found);
2798 assert_eq!(comparison.records_available_left, 4);
2799 assert_eq!(comparison.records_available_right, 2);
2800 assert_eq!(comparison.records_compared, 2);
2802 assert!(
2803 comparison.one_side_is_ahead(),
2804 "the caller must be able to see the comparison was reading-bound"
2805 );
2806 }
2807
2808 #[test]
2809 fn the_first_differing_record_is_located_not_just_bounded() {
2810 let build = |marker: &str| {
2812 (1..=81)
2813 .map(|index| {
2814 let body = if index == 13 { marker } else { "same" };
2815 record(index % 60, &format!("record {index} {body}"))
2816 })
2817 .collect::<String>()
2818 };
2819 let left = build("LEFT");
2820 let right = build("RIGHT");
2821
2822 let found = compare(&left, &right).summary.first_divergent_record;
2823 assert_eq!(
2824 found,
2825 Some(13),
2826 "bisection must name the record, not merely the prefix that contains it"
2827 );
2828 }
2829
2830 #[test]
2831 fn identical_logs_have_no_first_divergent_record() {
2832 let same = format!("{}{}{}", record(1, "a"), record(2, "b"), record(3, "c"));
2833 assert_eq!(compare(&same, &same).summary.first_divergent_record, None);
2834 assert_eq!(compare("", "").summary.first_divergent_record, None);
2836 }
2837
2838 #[test]
2844 fn matched_prefix_counts_leading_equal_compared_messages() {
2845 let log = |bodies: &[&str]| {
2846 bodies
2847 .iter()
2848 .enumerate()
2849 .map(|(index, body)| record(index + 1, body))
2850 .collect::<String>()
2851 };
2852 let summary = |left: &str, right: &str| {
2853 super::log_diff_summary_from_strs(left, right, &info_opts(), &mut Vec::new())
2854 .expect("comparing in-memory strings cannot fail on I/O")
2855 };
2856
2857 let same = log(&["a", "b", "c"]);
2858 let identical = summary(&same, &same);
2859 assert!(!identical.diff_found);
2860 assert_eq!(identical.matched_prefix_messages, Some(3));
2861
2862 let at_first = summary(&log(&["X", "b", "c"]), &log(&["Y", "b", "c"]));
2863 assert!(at_first.diff_found);
2864 assert_eq!(at_first.matched_prefix_messages, Some(0));
2865 assert_eq!(at_first.first_divergent_record, Some(1));
2866
2867 let at_third = summary(&log(&["a", "b", "X", "d"]), &log(&["a", "b", "Y", "d"]));
2868 assert_eq!(at_third.matched_prefix_messages, Some(2));
2869 assert_eq!(at_third.first_divergent_record, Some(3));
2870
2871 let shorter = summary(&log(&["a", "b"]), &log(&["a", "b", "c", "d"]));
2872 assert!(shorter.diff_found);
2873 assert_eq!((shorter.compared_left, shorter.compared_right), (2, 4));
2874 assert_eq!(shorter.matched_prefix_messages, Some(2));
2875
2876 let with_debug = format!(
2877 "{}Apr 09 06:08:02.100 DEBUG detcore: not compared\n{}",
2878 record(1, "a"),
2879 record(3, "X")
2880 );
2881 let unit_mismatch = summary(&with_debug, &log(&["a", "Y"]));
2882 assert_eq!(unit_mismatch.matched_prefix_messages, Some(1));
2883 assert_eq!(unit_mismatch.first_divergent_record, Some(3));
2884
2885 let empty = summary("", "");
2886 assert_eq!(empty.matched_prefix_messages, Some(0));
2887 }
2888
2889 #[test]
2894 fn a_matched_prefix_is_reported_only_where_the_exact_scan_agrees_with_the_verdict() {
2895 for (case, diff_found, prefix, left, right, expected) in [
2897 ("full match", false, 3, 3, 3, Some(3)),
2898 ("empty match", false, 0, 0, 0, Some(0)),
2899 ("divergence inside both", true, 2, 4, 4, Some(2)),
2900 ("divergence at the first message", true, 0, 3, 3, Some(0)),
2901 ("strict prefix", true, 2, 2, 5, Some(2)),
2902 ("match over unequal counts", false, 2, 1, 2, None),
2903 ("match of a strict prefix", false, 1, 1, 2, None),
2904 ("match the exact scan stops inside", false, 1, 2, 2, None),
2905 ("divergence with nothing unmatched", true, 3, 3, 3, None),
2906 ("divergence of two empty streams", true, 0, 0, 0, None),
2907 ] {
2908 assert_eq!(
2909 super::matched_prefix_for_verdict(diff_found, prefix, left, right),
2910 expected,
2911 "{case}"
2912 );
2913 }
2914 }
2915
2916 #[test]
2922 fn a_git_diff_match_over_unequal_counts_reports_no_matched_prefix() {
2923 let left = "Apr 09 06:08:01.100 INFO detcore: a\nINFO detcore: b\n";
2924 let right = format!("{}{}", record(1, "a"), record(2, "b"));
2925
2926 let git_opts = super::LogDiffOpts {
2927 git_diff: true,
2928 ..info_opts()
2929 };
2930 let git = super::log_diff_summary_from_strs(left, &right, &git_opts, &mut Vec::new())
2931 .expect("comparing in-memory strings cannot fail on I/O");
2932 assert_eq!((git.compared_left, git.compared_right), (1, 2));
2933 assert!(
2934 !git.diff_found,
2935 "git diff -w must accept the split record (this test needs git on PATH)"
2936 );
2937 assert_eq!(
2938 git.matched_prefix_messages, None,
2939 "a match over 1 | 2 compared messages has no prefix covering both streams"
2940 );
2941
2942 let exact = super::log_diff_summary_from_strs(left, &right, &info_opts(), &mut Vec::new())
2943 .expect("comparing in-memory strings cannot fail on I/O");
2944 assert_eq!((exact.compared_left, exact.compared_right), (1, 2));
2945 assert!(exact.diff_found);
2946 assert_eq!(exact.matched_prefix_messages, Some(0));
2947 }
2948
2949 #[test]
2958 fn an_untagged_line_is_refused_by_name_rather_than_panicking() {
2959 let log = "detcore-dbt: background client thread entered\n";
2967 let error = super::extract_log_messages(log)
2968 .expect_err("an untagged line must refuse, not be admitted");
2969 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2970 let message = error.to_string();
2971 assert!(
2972 message.contains("detcore-dbt: background client thread entered"),
2973 "the refusal must name the offending line, got: {message}"
2974 );
2975 assert!(
2976 message.contains("no ERROR/WARN/INFO/DEBUG/TRACE tag"),
2977 "the refusal must say why, got: {message}"
2978 );
2979 }
2980
2981 #[test]
2984 fn a_fully_tagged_log_still_parses() {
2985 let log = "2026-08-24T20:16:17.897469Z INFO detcore: DETLOG a\n\
2986 2026-08-24T20:16:17.897470Z WARN detcore: b\n";
2987 let records = super::extract_log_messages(log).expect("tagged log parses");
2988 assert_eq!(records.len(), 2);
2989 }
2990
2991 #[test]
2994 fn historical_syscall_numbers_remain_readable() {
2995 assert_eq!(
2996 finished_syscall_number(&historical(
2997 0,
2998 "DETLOG [syscall][detcore, dtid 3] finish syscall #37: write(1, 0x5, 6) = Ok(6)"
2999 )),
3000 Some(37)
3001 );
3002 assert_eq!(
3004 finished_syscall_number(&historical(
3005 0,
3006 "DETLOG [syscall][detcore, dtid 3] inbound syscall: brk(NULL) = ?"
3007 )),
3008 None
3009 );
3010 assert_eq!(
3011 finished_syscall_number(&historical(0, "no syscall here")),
3012 None
3013 );
3014 }
3015
3016 #[test]
3023 fn the_syscall_count_is_the_last_one_completed_before_the_divergence() {
3024 let finished = |n: u64| format!("finish syscall #{n}: write(1, 0x5, 6) = Ok(6)");
3025 let a = finished(2);
3026 let b = finished(37);
3027 let syscalls = vec![historical(10, a.as_str()), historical(90, b.as_str())];
3028 assert_eq!(finished_syscall_at_or_before(&syscalls, 98), Some(37));
3029 assert_eq!(finished_syscall_at_or_before(&syscalls, 90), Some(37));
3030 assert_eq!(finished_syscall_at_or_before(&syscalls, 50), Some(2));
3031 assert_eq!(
3032 finished_syscall_at_or_before(&syscalls, 9),
3033 None,
3034 "a divergence before any syscall completed has no syscall count, \
3035 and that is a state rather than a missing value"
3036 );
3037 }
3038
3039 #[test]
3040 fn structured_positions_and_syscall_counts_are_authoritative() -> std::io::Result<()> {
3041 let run = |turn: u64, time: u64, syscall: u64, value: u64| {
3042 format!(
3043 "{}{}{}",
3044 structured_record(
3045 1,
3046 "COMMIT turn 999 at time 999",
3047 DetLogEvent::SchedulerCommit {
3048 scheduler_turn: turn,
3049 virtual_nanoseconds: time,
3050 internal_io_poll: false,
3051 runtime_maps_read: false,
3052 },
3053 ),
3054 structured_record(
3055 2,
3056 "DETLOG [syscall] finish syscall #999: write = Ok(1)",
3057 DetLogEvent::SyscallResult {
3058 finished_syscall_number: syscall,
3059 },
3060 ),
3061 structured_record(3, &format!("DETLOG value={value}"), DetLogEvent::Other),
3062 )
3063 };
3064 let options = super::LogDiffOpts {
3065 require_structured_events: true,
3066 ..Default::default()
3067 };
3068
3069 let original = super::log_diff_summary_from_strs(
3070 run(17, 123, 37, 1),
3071 run(17, 123, 37, 2),
3072 &options,
3073 &mut Vec::new(),
3074 )?;
3075 assert_eq!(original.first_divergent_scheduler_turn, Some(17));
3076 assert_eq!(original.first_divergent_virtual_nanoseconds, Some(123));
3077 assert_eq!(original.first_divergent_syscall, Some(37));
3078
3079 let mutated = super::log_diff_summary_from_strs(
3080 run(18, 124, 38, 1),
3081 run(18, 124, 38, 2),
3082 &options,
3083 &mut Vec::new(),
3084 )?;
3085 assert_eq!(mutated.first_divergent_scheduler_turn, Some(18));
3086 assert_eq!(mutated.first_divergent_virtual_nanoseconds, Some(124));
3087 assert_eq!(mutated.first_divergent_syscall, Some(38));
3088 Ok(())
3089 }
3090
3091 #[test]
3092 fn current_verification_refuses_a_missing_structured_record_by_name() {
3093 let options = super::LogDiffOpts {
3094 require_structured_events: true,
3095 ..Default::default()
3096 };
3097 let error = super::log_diff_summary_from_strs(
3098 record(1, "DETLOG value=1"),
3099 record(1, "DETLOG value=1"),
3100 &options,
3101 &mut Vec::new(),
3102 )
3103 .expect_err("current verification must not fall back to prose");
3104 assert!(
3105 error
3106 .to_string()
3107 .contains("missing its structured DETLOG result"),
3108 "refusal must name the missing result: {error}"
3109 );
3110 }
3111
3112 #[test]
3113 fn structured_kind_not_the_human_tag_selects_the_syscall_class() {
3114 let text = "INFO detcore: DETLOG [syscall] inbound syscall: read = ?";
3115 let other = super::LogMessage {
3116 index: 0,
3117 text,
3118 event: Some(DetLogEvent::Other),
3119 };
3120 let syscall = super::LogMessage {
3121 event: Some(DetLogEvent::Syscall),
3122 ..other
3123 };
3124 assert!(!super::is_detlog_syscall(&other));
3125 assert!(super::is_detlog_syscall(&syscall));
3126 }
3127
3128 #[test]
3129 fn structured_scheduler_flags_control_filtering_and_retained_counts() {
3130 let text = "INFO detcore::scheduler: COMMIT turn 999 at time 999";
3131 let internal = super::LogMessage {
3132 index: 0,
3133 text,
3134 event: Some(DetLogEvent::SchedulerCommit {
3135 scheduler_turn: 17,
3136 virtual_nanoseconds: 123,
3137 internal_io_poll: true,
3138 runtime_maps_read: false,
3139 }),
3140 };
3141 let maps_read = super::LogMessage {
3142 event: Some(DetLogEvent::SchedulerCommit {
3143 scheduler_turn: 17,
3144 virtual_nanoseconds: 123,
3145 internal_io_poll: false,
3146 runtime_maps_read: true,
3147 }),
3148 ..internal
3149 };
3150
3151 assert!(
3152 super::LogDiffOpts::default()
3153 .filter_deterministic(&[internal])
3154 .is_empty()
3155 );
3156 assert_eq!(
3157 super::LogDiffOpts::default()
3158 .filter_deterministic(&[maps_read])
3159 .len(),
3160 1
3161 );
3162 assert_eq!(super::maps_read_commits(&[internal]), (0, None));
3163 assert_eq!(
3164 super::maps_read_commits(&[maps_read]),
3165 (1, Some((17, Some(123))))
3166 );
3167 }
3168
3169 #[test]
3170 fn nothing_written_yet_is_a_no_result_not_a_match() {
3171 let comparison = compare(&record(1, "first"), &record(1, "first"));
3173 assert_eq!(comparison.records_compared, 0);
3174 assert!(!comparison.summary.diff_found);
3175 assert!(
3176 !comparison.summary.matched_with_evidence(),
3177 "comparing zero records must never report a match"
3178 );
3179 }
3180
3181 #[test]
3182 fn unsafe_strip_lines_cli_name_and_warning_are_explicit() {
3183 let options = super::LogDiffOpts::try_parse_from(["log-diff", "--unsafe-strip-lines"])
3184 .expect("the explicitly unsafe spelling should parse");
3185 assert!(options.strip_lines);
3186
3187 assert!(super::LogDiffOpts::try_parse_from(["log-diff", "--strip-lines"]).is_err());
3188
3189 let mut help = Vec::new();
3190 super::LogDiffOpts::command()
3191 .write_long_help(&mut help)
3192 .expect("write clap help");
3193 let help = String::from_utf8(help).expect("help is UTF-8");
3194 assert!(help.contains("--unsafe-strip-lines"));
3195 assert!(help.contains("erases timestamps and syscall values"));
3196 assert!(help.contains("make a failing parity diff pass"));
3197 assert!(help.contains("doing so is cheating"));
3198 assert!(!help.contains("--strip-lines"));
3199 }
3200
3201 #[test]
3202 fn test_compare_with_no_color() {
3203 let str1 = "test1";
3204 let str2 = "test2";
3205
3206 assert_eq!(
3207 format!("{}", super::Comparison::new(true, str1, str2))
3208 .split('\n')
3209 .collect::<Vec<&str>>(),
3210 ["Diff < left / right > :", "<\"test1\"", ">\"test2\"", "",]
3211 );
3212 }
3213
3214 #[test]
3215 fn test_compare_with_color() {
3216 let str1 = "test1";
3217 let str2 = "test2";
3218
3219 assert_eq!(
3220 format!("{}", super::Comparison::new(false, str1, str2))
3221 .split('\n')
3222 .collect::<Vec<&str>>(),
3223 [
3224 "\u{1b}[1mDiff\u{1b}[0m \u{1b}[31m< left\u{1b}[0m / \u{1b}[32mright >\u{1b}[0m :",
3225 "\u{1b}[31m<\"test\u{1b}[0m\u{1b}[1;48;5;52;31m1\u{1b}[0m\u{1b}[31m\"\u{1b}[0m",
3226 "\u{1b}[32m>\"test\u{1b}[0m\u{1b}[1;48;5;22;32m2\u{1b}[0m\u{1b}[32m\"\u{1b}[0m",
3227 "",
3228 ]
3229 );
3230 }
3231
3232 #[test]
3239 fn truncated_logs_are_refused_and_untruncated_logs_still_match() -> std::io::Result<()> {
3240 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)";
3241 let marked = format!("{body}\n{}\n", super::TRUNCATION_MARKER);
3242 let options = super::LogDiffOpts {
3243 no_color: true,
3244 ..Default::default()
3245 };
3246
3247 let clean = super::log_diff_summary_from_strs(body, body, &options, &mut Vec::new())?;
3250 assert!(!clean.diff_found, "identical untruncated logs must match");
3251 assert!(
3252 clean.matched_with_evidence(),
3253 "the untruncated match must carry nonzero compared counts, got {clean:?}"
3254 );
3255 assert!(clean.refusal_reason.is_none());
3256
3257 for (label, left, right) in [
3260 ("left", marked.as_str(), body),
3261 ("right", body, marked.as_str()),
3262 ("both", marked.as_str(), marked.as_str()),
3263 ] {
3264 let mut out = Vec::new();
3265 let summary = super::log_diff_summary_from_strs(left, right, &options, &mut out)?;
3266 assert!(
3267 summary.diff_found,
3268 "{label}: a truncated log must not be reported as a match"
3269 );
3270 assert_eq!(
3271 (summary.compared_left, summary.compared_right),
3272 (0, 0),
3273 "{label}: nothing was compared, so the counts must not claim otherwise"
3274 );
3275 assert!(
3276 summary
3277 .refusal_reason
3278 .as_deref()
3279 .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
3280 "{label}: the typed result must retain the printed refusal cause"
3281 );
3282 assert!(
3283 !summary.matched_with_evidence(),
3284 "{label}: the evidence predicate must also refuse"
3285 );
3286 assert_eq!(
3287 summary.matched_prefix_messages, None,
3288 "{label}: a refused comparison measured no matched prefix"
3289 );
3290 let text = String::from_utf8(out).unwrap();
3291 assert!(
3292 text.contains("REFUSING to compare"),
3293 "{label}: the refusal must be stated, got: {text}"
3294 );
3295 assert!(
3296 !text.contains("no substantive differences found"),
3297 "{label}: a refusal must never print the match line, got: {text}"
3298 );
3299 }
3300
3301 Ok(())
3302 }
3303
3304 #[test]
3311 fn a_log_stopped_by_the_cap_is_refused_like_a_truncated_one() -> std::io::Result<()> {
3312 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)";
3313 let stop = format!(
3314 "hermit: log output exceeded --max-log-bytes=64K (65536 bytes); aborting the run and \
3315 killing the guest process tree (exit 123). Lower --log / RUST_LOG verbosity, or raise \
3316 --max-log-bytes, to let the run finish.\n{}\n",
3317 super::LOG_CAP_STOP_LINE
3318 );
3319 let stopped = format!("{body}\n{stop}");
3320 let marked_then_stopped = format!("{body}\n{}\n{stop}", super::TRUNCATION_MARKER);
3321 let options = super::LogDiffOpts {
3322 no_color: true,
3323 ..Default::default()
3324 };
3325 for (label, left, right) in [
3326 ("left", stopped.as_str(), body),
3327 ("right", body, stopped.as_str()),
3328 ("both", stopped.as_str(), stopped.as_str()),
3329 ("marker then stop", marked_then_stopped.as_str(), body),
3330 ] {
3331 let mut out = Vec::new();
3332 let summary = super::log_diff_summary_from_strs(left, right, &options, &mut out)?;
3333 assert!(summary.diff_found, "{label}: {summary:?}");
3334 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
3335 assert!(
3336 summary
3337 .refusal_reason
3338 .as_deref()
3339 .is_some_and(|reason| reason.contains("truncated at the configured size bound")),
3340 "{label}: {summary:?}"
3341 );
3342 assert!(!summary.matched_with_evidence(), "{label}");
3343 }
3344
3345 let line = super::LOG_CAP_STOP_LINE;
3346 for (label, text) in [
3347 (
3348 "stop line inside the final DETLOG line",
3349 format!(
3350 "{body}\n2022-09-06T14:15:49.000000Z INFO detcore: DETLOG [syscall] inbound \
3351 syscall: statx(-100, 0x7fff -> \"/tmp/{line}\", AtFlags(0x0), 2, 0x7fff) = ?"
3352 ),
3353 ),
3354 ("stop line followed by more log", format!("{line}\n{body}")),
3355 (
3356 "stop line at end of file but mid-line",
3357 format!("{body}\nx {line}"),
3358 ),
3359 ] {
3360 assert!(!super::log_was_truncated(&text), "{label}");
3361 }
3362 assert!(super::log_was_truncated(&stopped));
3363 assert!(super::log_was_truncated(&marked_then_stopped));
3364 Ok(())
3365 }
3366
3367 #[test]
3378 fn marker_text_in_guest_content_is_not_truncation() -> std::io::Result<()> {
3379 let options = super::LogDiffOpts {
3380 no_color: true,
3381 ..Default::default()
3382 };
3383 let marker = super::TRUNCATION_MARKER;
3384 let guest_path_line = format!(
3387 "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] inbound syscall: \
3388 statx(-100, 0x7fff -> \"/tmp/{marker} probe\", AtFlags(AT_NO_AUTOMOUNT), 2, 0x7fff) \
3389 = ?"
3390 );
3391 let tail_line = "2022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: \
3392 write(1, 0x2000, 1) = Ok(1)";
3393
3394 for (label, text) in [
3395 (
3397 "marker inside the final DETLOG line",
3398 guest_path_line.clone(),
3399 ),
3400 (
3404 "marker on its own line, followed by more log",
3405 format!("{guest_path_line}\n{marker}\n{tail_line}"),
3406 ),
3407 (
3409 "marker at end of file but mid-line",
3410 format!("{tail_line}\nsomething {marker}"),
3411 ),
3412 ] {
3413 assert!(
3414 !super::log_was_truncated(&text),
3415 "{label}: an untruncated log must not be classified as truncated"
3416 );
3417 let mut out = Vec::new();
3418 let summary = super::log_diff_summary_from_strs(&text, &text, &options, &mut out)?;
3419 let printed = String::from_utf8(out).unwrap();
3420 assert!(
3421 !printed.contains("REFUSING to compare"),
3422 "{label}: must be compared, not refused, got: {printed}"
3423 );
3424 assert!(
3425 !summary.diff_found,
3426 "{label}: identical logs must compare equal, got {summary:?}"
3427 );
3428 assert!(
3429 summary.matched_with_evidence(),
3430 "{label}: the match must carry nonzero compared counts, got {summary:?}"
3431 );
3432 }
3433
3434 let really_truncated = format!("{guest_path_line}\n{marker}\n");
3437 assert!(
3438 super::log_was_truncated(&really_truncated),
3439 "a log ending in the marker line IS truncated and must still be caught"
3440 );
3441 let mut out = Vec::new();
3442 let summary = super::log_diff_summary_from_strs(
3443 &really_truncated,
3444 &really_truncated,
3445 &options,
3446 &mut out,
3447 )?;
3448 assert!(summary.diff_found, "real truncation must still be refused");
3449 assert_eq!((summary.compared_left, summary.compared_right), (0, 0));
3450 assert!(
3451 String::from_utf8(out)
3452 .unwrap()
3453 .contains("REFUSING to compare"),
3454 "real truncation must still print the refusal"
3455 );
3456
3457 Ok(())
3458 }
3459
3460 const UNOBSERVED: &str = r#"2026-10-07T08:56:08.497110Z INFO detcore::io_buffers: DETLOG [iobuf][dtid 3] recvmsg in fd=4 unobserved ret=16 reason=iovec-short covered=1 DETLOG_RECORD={"schema":1,"event":{"kind":"other"}}"#;
3461 const HASHED: &str = r#"2026-10-07T08:56:08.497000Z INFO detcore::io_buffers: DETLOG [iobuf][dtid 3] write out fd=1 0x404070+3->4e7f92057ff4026cb921ad4502638b3392880999758f79640cb2043686104341 DETLOG_RECORD={"schema":1,"event":{"kind":"other"}}"#;
3462
3463 #[test]
3466 fn only_whole_unobserved_receive_records_are_counted() {
3467 assert!(super::is_unobserved_receive(UNOBSERVED));
3468 let forwarded = UNOBSERVED.replace(
3469 "2026-10-07T08:56:08.497110Z ",
3470 "1970-01-01T00:00:00.000000Z ",
3471 );
3472 assert!(super::is_unobserved_receive(&forwarded));
3473 let decoy = r#"2026-10-07T08:56:08.497110Z INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #3: openat(-100, "/tmp/DETLOG [iobuf][dtid 3] recvmsg in fd=4 unobserved ret=16 reason=x", 0) = Ok(3) DETLOG_RECORD={"schema":1,"event":{"kind":"other"}}"#;
3474 assert!(!super::is_unobserved_receive(decoy));
3475 assert!(!super::is_unobserved_receive(HASHED));
3476 let spanned = UNOBSERVED.replace(
3478 " INFO detcore::io_buffers:",
3479 " INFO syscall.intercept{tid=3 syscall=recvmsg args=SyscallArgs { arg0: 4, arg5: 0 }}: detcore::io_buffers:",
3480 );
3481 assert!(super::is_unobserved_receive(&spanned));
3482 let spanned_decoy = decoy.replace(
3483 " INFO detcore:",
3484 " INFO syscall.intercept{tid=3 syscall=openat}: detcore:",
3485 );
3486 assert!(!super::is_unobserved_receive(&spanned_decoy));
3487 let directory = tempfile::tempdir().unwrap();
3488 let log = directory.path().join("log");
3489 std::fs::write(
3490 &log,
3491 format!("{HASHED}\n{UNOBSERVED}\n{decoy}\n{UNOBSERVED}\n"),
3492 )
3493 .unwrap();
3494 assert_eq!(super::count_unobserved_receives(&log).unwrap(), 2);
3495 }
3496
3497 #[test]
3502 fn ordinary_verify_compares_unobserved_receive_records() {
3503 let opts = super::LogDiffOpts {
3504 strip_lines: true,
3505 canonicalize_addresses: false,
3506 comparison: super::LogComparisonMode::Deterministic,
3507 require_structured_events: true,
3508 ..Default::default()
3509 };
3510 let compare = |a: &str, b: &str| {
3511 let mut output = Vec::new();
3512 super::log_diff_from_strs(a, b, &opts, &mut output).unwrap()
3513 };
3514 let both = format!("{HASHED}\n{UNOBSERVED}\n");
3515 assert!(!compare(&both, &both));
3516 assert!(compare(&format!("{HASHED}\n"), &both));
3517 }
3518
3519 #[test]
3520 fn test_log_diff_with_color() -> std::io::Result<()> {
3521 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)";
3522 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)";
3523 let mut result = Vec::<u8>::new();
3524
3525 super::log_diff_from_strs(
3526 str1,
3527 str2,
3528 &super::LogDiffOpts {
3529 limit: 1,
3530 strip_lines: false,
3531 canonicalize_addresses: false,
3532 comparison: super::LogComparisonMode::Deterministic,
3533 side_labels: super::ComparisonSideLabels::default(),
3534 require_structured_events: false,
3535 print_logs: false,
3536 syscall_history: 5,
3537 no_color: false,
3538 skip_commit: false,
3539 skip_detlog: false,
3540 git_diff: false,
3541 ignore_lines: Vec::new(),
3542 include_detlogs: vec![
3543 DetLogFilter::Syscall,
3544 DetLogFilter::SyscallResult,
3545 DetLogFilter::Other,
3546 ],
3547 },
3548 &mut result,
3549 )?;
3550
3551 let output = String::from_utf8(result).unwrap();
3552 assert!(output.contains(" Comparing DETLOG messages..."));
3553 assert!(output.contains("Mismatch at log messages 0 (run 1) and 0 (run 2)"));
3554 assert!(output.contains("run 1, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #11"));
3555 assert!(output.contains("run 2, log message 0: INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #15"));
3556 assert!(!output.contains("eliding the rest"));
3557
3558 Ok(())
3559 }
3560
3561 #[test]
3562 fn test_log_diff_reports_each_runs_syscall_context() -> std::io::Result<()> {
3563 let log_a = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
35642022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"#;
3565 let log_b = r#"2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: read(3, 0x1000, 1) = Ok(1)
35662022-09-06T14:15:48.000000Z INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"#;
3567 let mut result = Vec::new();
3568 let options = super::LogDiffOpts {
3569 no_color: true,
3570 syscall_history: 1,
3571 ..Default::default()
3572 };
3573
3574 assert!(super::log_diff_from_strs(
3575 log_a,
3576 log_b,
3577 &options,
3578 &mut result
3579 )?);
3580
3581 let output = String::from_utf8(result).unwrap();
3582 assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3583 assert!(output.contains("run 1, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x2000, 1) = Ok(1)"));
3584 assert!(output.contains("run 2, log message 2: INFO detcore: DETLOG [syscall] finish syscall #2: write(1, 0x3000, 1) = Ok(1)"));
3585 assert!(output.contains("Prior completed syscalls for run 1:"));
3586 assert!(output.contains("Prior completed syscalls for run 2:"));
3587 assert_eq!(output.matches("finish syscall #1: read").count(), 2);
3588 Ok(())
3589 }
3590
3591 #[test]
3592 fn custom_side_labels_cover_mismatch_history_and_tail_diagnostics() -> std::io::Result<()> {
3593 let left = format!(
3594 "{}{}",
3595 record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3596 record(2, "DETLOG [syscall] finish syscall #2: write = Ok(1)"),
3597 );
3598 let right = format!(
3599 "{}{}",
3600 record(1, "DETLOG [syscall] finish syscall #1: read = Ok(1)"),
3601 record(2, "DETLOG [syscall] finish syscall #2: write = Err(5)"),
3602 );
3603 let options = super::LogDiffOpts {
3604 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3605 syscall_history: 1,
3606 no_color: true,
3607 ..Default::default()
3608 };
3609 let mut output = Vec::new();
3610 assert!(super::log_diff_from_strs(
3611 &left,
3612 &right,
3613 &options,
3614 &mut output
3615 )?);
3616 let output = String::from_utf8(output).unwrap();
3617 assert!(output.contains("Mismatch at log messages 2 (the recording) and 2 (the replay)"));
3618 assert!(output.contains("the recording, log message 2:"));
3619 assert!(output.contains("the replay, log message 2:"));
3620 assert!(output.contains("Prior completed syscalls for the recording:"));
3621 assert!(output.contains("Prior completed syscalls for the replay:"));
3622 assert!(!output.contains("run 1") && !output.contains("run 2"));
3623
3624 let left = record(1, "DETLOG stable");
3625 let right = format!("{left}{}", record(2, "DETLOG extra"));
3626 let mut output = Vec::new();
3627 assert!(super::log_diff_from_strs(
3628 &left,
3629 &right,
3630 &options,
3631 &mut output
3632 )?);
3633 let output = String::from_utf8(output).unwrap();
3634 assert!(
3635 output.contains("The replay contains 1 extra messages not matched in the recording.")
3636 );
3637 assert!(!output.contains("run 1") && !output.contains("run 2"));
3638
3639 let mut output = Vec::new();
3640 assert!(super::log_diff_from_strs(
3641 &right,
3642 &left,
3643 &options,
3644 &mut output
3645 )?);
3646 let output = String::from_utf8(output).unwrap();
3647 assert!(
3648 output.contains("The recording contains 1 extra messages not matched in the replay.")
3649 );
3650 Ok(())
3651 }
3652
3653 fn printed_labeled_log<'a>(output: &'a str, label: &str) -> &'a str {
3654 let start = format!("--- begin {label} compared log ---\n");
3655 let end = format!("--- end {label} compared log ---\n");
3656 output
3657 .split_once(&start)
3658 .expect("printed log start marker")
3659 .1
3660 .split_once(&end)
3661 .expect("printed log end marker")
3662 .0
3663 }
3664
3665 #[test]
3666 fn custom_side_labels_name_printed_logs() -> std::io::Result<()> {
3667 let options = super::LogDiffOpts {
3668 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
3669 print_logs: true,
3670 no_color: true,
3671 ..Default::default()
3672 };
3673 let mut output = Vec::new();
3674 let summary = super::log_diff_summary_from_strs(
3675 record(1, "DETLOG recorded"),
3676 record(1, "DETLOG replayed"),
3677 &options,
3678 &mut output,
3679 )?;
3680 assert!(summary.diff_found);
3681 let output = String::from_utf8(output).unwrap();
3682 assert_eq!(
3683 printed_labeled_log(&output, "the recording"),
3684 "INFO detcore: DETLOG recorded\n"
3685 );
3686 assert_eq!(
3687 printed_labeled_log(&output, "the replay"),
3688 "INFO detcore: DETLOG replayed\n"
3689 );
3690 assert!(!output.contains("begin run 1") && !output.contains("begin run 2"));
3691 Ok(())
3692 }
3693
3694 fn printed_log(output: &str, run: u8) -> &str {
3695 let start = format!("--- begin run {run} compared log ---\n");
3696 let end = format!("--- end run {run} compared log ---\n");
3697 output
3698 .split_once(&start)
3699 .expect("printed log start marker")
3700 .1
3701 .split_once(&end)
3702 .expect("printed log end marker")
3703 .0
3704 }
3705
3706 #[test]
3707 fn printed_logs_are_the_exact_selected_comparator_inputs() -> std::io::Result<()> {
3708 let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG value=101\n\
37092026-08-15T01:02:03.000001Z INFO unrelated: omitted value=303";
3710 let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG value=202\n\
37112026-08-15T04:05:06.000001Z INFO unrelated: omitted value=404";
3712
3713 let exact = super::LogDiffOpts {
3714 print_logs: true,
3715 no_color: true,
3716 ..Default::default()
3717 };
3718 let mut exact_output = Vec::new();
3719 let exact_summary =
3720 super::log_diff_summary_from_strs(left, right, &exact, &mut exact_output)?;
3721 let exact_output = String::from_utf8(exact_output).unwrap();
3722
3723 assert!(exact_summary.diff_found);
3724 assert!(exact_output.contains("Comparison policy: Deterministic\n"));
3725 assert_eq!(
3726 printed_log(&exact_output, 1).as_bytes(),
3727 b"INFO detcore: DETLOG value=101\n"
3728 );
3729 assert_eq!(
3730 printed_log(&exact_output, 2).as_bytes(),
3731 b"INFO detcore: DETLOG value=202\n"
3732 );
3733
3734 let stripped = super::LogDiffOpts {
3735 strip_lines: true,
3736 print_logs: true,
3737 no_color: true,
3738 ..Default::default()
3739 };
3740 let mut stripped_output = Vec::new();
3741 let stripped_summary =
3742 super::log_diff_summary_from_strs(left, right, &stripped, &mut stripped_output)?;
3743 let stripped_output = String::from_utf8(stripped_output).unwrap();
3744
3745 assert!(stripped_summary.matched_with_evidence());
3746 assert!(stripped_output.contains("Comparison policy: Stripped\n"));
3747 assert_eq!(
3748 printed_log(&stripped_output, 1).as_bytes(),
3749 b"INFO detcore: DETLOG value=<NUM>\n"
3750 );
3751 assert_eq!(
3752 printed_log(&stripped_output, 1),
3753 printed_log(&stripped_output, 2)
3754 );
3755 assert_ne!(
3756 printed_log(&exact_output, 1),
3757 printed_log(&stripped_output, 1)
3758 );
3759 Ok(())
3760 }
3761
3762 #[test]
3763 fn printed_policy_name_tracks_the_selected_scope_and_normalization() -> std::io::Result<()> {
3764 let log = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n\
37652026-08-15T01:02:03.000001Z DEBUG unrelated: diagnostic=2";
3766
3767 let cases = [
3768 (
3769 super::LogDiffOpts {
3770 comparison: super::LogComparisonMode::Info,
3771 print_logs: true,
3772 no_color: true,
3773 ..Default::default()
3774 },
3775 "Comparison policy: Info\n",
3776 "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\n",
3777 ),
3778 (
3779 super::LogDiffOpts {
3780 comparison: super::LogComparisonMode::FullTrace,
3781 print_logs: true,
3782 no_color: true,
3783 ..Default::default()
3784 },
3785 "Comparison policy: FullTrace\n",
3786 "INFO detcore: DETLOG stable=1 address=<hostaddr 0xaaaa>\nDEBUG unrelated: diagnostic=2\n",
3787 ),
3788 (
3789 super::LogDiffOpts {
3790 comparison: super::LogComparisonMode::Deterministic,
3791 canonicalize_addresses: true,
3792 print_logs: true,
3793 no_color: true,
3794 ..Default::default()
3795 },
3796 "Comparison policy: Deterministic with Canonical host-address normalization\n",
3797 "INFO detcore: DETLOG stable=1 address=<addr1>\n",
3798 ),
3799 (
3800 super::LogDiffOpts {
3801 comparison: super::LogComparisonMode::Deterministic,
3802 strip_lines: true,
3803 print_logs: true,
3804 no_color: true,
3805 ..Default::default()
3806 },
3807 "Comparison policy: Stripped\n",
3808 "INFO detcore: DETLOG stable=<NUM> address=<hostaddr <ADDR>>\n",
3809 ),
3810 ];
3811
3812 for (options, expected_name, expected_log) in cases {
3813 let mut output = Vec::new();
3814 let summary = super::log_diff_summary_from_strs(log, log, &options, &mut output)?;
3815 let output = String::from_utf8(output).unwrap();
3816 assert!(summary.matched_with_evidence());
3817 assert!(output.contains(expected_name), "{output}");
3818 assert_eq!(printed_log(&output, 1), expected_log);
3819 assert_eq!(printed_log(&output, 2), expected_log);
3820 }
3821 Ok(())
3822 }
3823
3824 #[test]
3825 fn printed_canonical_policy_uses_the_info_scope_and_address_ordinals() -> std::io::Result<()> {
3826 let left = "2026-08-15T01:02:03.000000Z INFO detcore: DETLOG stable=1\n\
38272026-08-15T01:02:03.000001Z INFO unrelated: value=101 address=<hostaddr 0xaaaa>";
3828 let right = "2026-08-15T04:05:06.000000Z INFO detcore: DETLOG stable=1\n\
38292026-08-15T04:05:06.000001Z INFO unrelated: value=202 address=<hostaddr 0xbbbb>";
3830
3831 let deterministic = super::LogDiffOpts {
3832 print_logs: true,
3833 no_color: true,
3834 ..Default::default()
3835 };
3836 let mut deterministic_output = Vec::new();
3837 let deterministic_summary = super::log_diff_summary_from_strs(
3838 left,
3839 right,
3840 &deterministic,
3841 &mut deterministic_output,
3842 )?;
3843 let deterministic_output = String::from_utf8(deterministic_output).unwrap();
3844 assert!(deterministic_summary.matched_with_evidence());
3845 assert!(deterministic_output.contains("Comparison policy: Deterministic\n"));
3846 assert_eq!(
3847 printed_log(&deterministic_output, 1).as_bytes(),
3848 b"INFO detcore: DETLOG stable=1\n"
3849 );
3850 assert_eq!(
3851 printed_log(&deterministic_output, 1),
3852 printed_log(&deterministic_output, 2)
3853 );
3854
3855 let options = super::LogDiffOpts {
3856 comparison: super::LogComparisonMode::Info,
3857 canonicalize_addresses: true,
3858 print_logs: true,
3859 no_color: true,
3860 ..Default::default()
3861 };
3862 let mut output = Vec::new();
3863 let summary = super::log_diff_summary_from_strs(left, right, &options, &mut output)?;
3864 let output = String::from_utf8(output).unwrap();
3865
3866 assert!(summary.diff_found);
3867 assert!(output.contains("Comparison policy: Canonical\n"));
3868 assert_eq!(
3869 printed_log(&output, 1).as_bytes(),
3870 b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=101 address=<addr1>\n"
3871 );
3872 assert_eq!(
3873 printed_log(&output, 2).as_bytes(),
3874 b"INFO detcore: DETLOG stable=1\nINFO unrelated: value=202 address=<addr1>\n"
3875 );
3876 Ok(())
3877 }
3878
3879 #[test]
3880 fn test_full_trace_detects_unnormalized_timing_difference() -> std::io::Result<()> {
3881 let log_a = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 100) = Ok(0)";
3882 let log_b = "INFO detcore: DETLOG [syscall] finish syscall #1: clock_gettime(CLOCK_MONOTONIC, 101) = Ok(0)";
3883 let normalized = super::LogDiffOpts {
3884 strip_lines: true,
3885 no_color: true,
3886 ..Default::default()
3887 };
3888
3889 assert!(!super::log_diff_from_strs(
3890 log_a,
3891 log_b,
3892 &normalized,
3893 &mut Vec::new()
3894 )?);
3895
3896 let verbose = super::LogDiffOpts {
3897 comparison: super::LogComparisonMode::FullTrace,
3898 strip_lines: false,
3899 syscall_history: 1,
3900 no_color: true,
3901 ..Default::default()
3902 };
3903 let mut result = Vec::new();
3904 assert!(super::log_diff_from_strs(
3905 log_a,
3906 log_b,
3907 &verbose,
3908 &mut result
3909 )?);
3910
3911 let output = String::from_utf8(result).unwrap();
3912 assert!(output.contains("Comparing full trace messages"));
3913 assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 100)"));
3914 assert!(output.contains("clock_gettime(CLOCK_MONOTONIC, 101)"));
3915 assert!(output.contains("run 1"));
3916 assert!(output.contains("run 2"));
3917 Ok(())
3918 }
3919
3920 #[test]
3921 fn info_scope_compares_info_exactly_without_promoting_debug_diagnostics() -> std::io::Result<()>
3922 {
3923 let stable_info = "2026-08-06T01:00:00.000000Z INFO detcore: DETLOG [syscall] finish syscall #1: write(1, 0x2, 1) = Ok(1)";
3924 let left = format!(
3925 "{stable_info}\n2026-08-06T01:00:00.000001Z DEBUG detcore: diagnostic host timing=100"
3926 );
3927 let right = format!(
3928 "{stable_info}\n2026-08-06T01:00:00.000002Z DEBUG detcore: diagnostic host timing=200"
3929 );
3930 let info = super::LogDiffOpts {
3931 comparison: super::LogComparisonMode::Info,
3932 no_color: true,
3933 ..Default::default()
3934 };
3935
3936 let matched = super::log_diff_summary_from_strs(&left, &right, &info, &mut Vec::new())?;
3939 assert!(matched.matched_with_evidence());
3940 assert_eq!(matched.compared_left, 1);
3941 assert_eq!(matched.compared_right, 1);
3942
3943 let divergent_info = right.replace("write(1, 0x2, 1)", "write(1, 0x6, 1)");
3945 let diverged =
3946 super::log_diff_summary_from_strs(&left, divergent_info, &info, &mut Vec::new())?;
3947 assert!(diverged.diff_found);
3948
3949 let full_trace = super::LogDiffOpts {
3952 comparison: super::LogComparisonMode::FullTrace,
3953 no_color: true,
3954 ..Default::default()
3955 };
3956 let debug_diverged =
3957 super::log_diff_summary_from_strs(left, right, &full_trace, &mut Vec::new())?;
3958 assert!(debug_diverged.diff_found);
3959 Ok(())
3960 }
3961
3962 #[test]
3963 fn test_log_diff_compares_detlog() -> std::io::Result<()> {
3964 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
39652022-09-06T14:15:48.903997Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 7984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
39662022-09-06T14:15:48.904049Z INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
39672022-09-06T14:15:48.904049Z INFO detcore: COMMIT 2
39682022-09-06T14:15:48.904782Z INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3969
3970 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
39712022-09-06T14:15:47.903997Z INFO detcore: DETLOG [memory][detcore, dtid 3] 0x7ffffffdd000-0x7ffffffff000 rw-p 0 0:0 0 [stack] -> 1984d1aaf386fce67eaa926624ecc1d5a4105828e4f286ee59cc69c0491cd5fe
39722022-09-06T14:15:47.904049Z INFO detcore: DETLOG [syscall][detcore, dtid 3] inbound syscall: write(1, 0x6022a0, 70) = ?
39732022-09-06T14:15:47.904049Z INFO detcore: COMMIT 1
39742022-09-06T14:15:47.904782Z INFO detcore::scheduler: [sched-step5] >>>>>>>"#;
3975 let mut result = Vec::<u8>::new();
3976
3977 let log_options = super::LogDiffOpts {
3978 no_color: true,
3979 git_diff: false,
3980 ..Default::default()
3981 };
3982 super::log_diff_from_strs(log_file_a, log_file_b, &log_options, &mut result)?;
3983
3984 let output = String::from_utf8(result).unwrap();
3985 assert!(output.contains("Mismatch at log messages 2 (run 1) and 2 (run 2)"));
3986 assert!(output.contains("Mismatch at log messages 4 (run 1) and 4 (run 2)"));
3987 assert!(output.contains("INFO detcore: COMMIT 2"));
3988 assert!(output.contains("INFO detcore: COMMIT 1"));
3989
3990 Ok(())
3991 }
3992
3993 #[test]
3994 fn test_filter_deterministic() {
3995 let opts = super::LogDiffOpts {
3996 include_detlogs: vec![
3997 DetLogFilter::Syscall,
3998 DetLogFilter::SyscallResult,
3999 DetLogFilter::Other,
4000 ],
4001 ..Default::default()
4002 };
4003
4004 let v = opts.filter_deterministic(
4005 &[
4006 historical(
4007 1,
4008 "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
4009 ),
4010 historical(
4011 2,
4012 "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15...",
4013 ),
4014 historical(
4015 3,
4016 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
4017 ),
4018 ],
4019 );
4020
4021 assert_eq!(
4022 indexed_text(&v),
4023 vec![
4024 (
4025 2,
4026 "INFO DETLOG detcore: registers [dtid 3]. user_regs_struct { r15..."
4027 ),
4028 (
4029 3,
4030 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
4031 ),
4032 ]
4033 );
4034 }
4035
4036 #[test]
4037 fn test_filter_deterministic_with_filter() {
4038 let opts = super::LogDiffOpts {
4039 include_detlogs: vec![DetLogFilter::Syscall],
4040 skip_commit: true,
4041 ..Default::default()
4042 };
4043
4044 let v = opts.filter_deterministic(
4045 &[
4046 historical(
4047 1,
4048 "INFO detcore: registers [dtid 3]. user_regs_struct { r15...",
4049 ),
4050 historical(2, "INFO DETLOG detcore:[syscall] syscall 1"),
4051 historical(
4052 3,
4053 "INFO COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000",
4054 ),
4055 ],
4056 );
4057 assert_eq!(
4058 indexed_text(&v),
4059 vec![(2, "INFO DETLOG detcore:[syscall] syscall 1")]
4060 );
4061 }
4062
4063 #[test]
4069 fn test_filter_deterministic_drops_io_polling_bookkeeping() {
4070 let opts = super::LogDiffOpts::default();
4071 let v = opts.filter_deterministic(&[
4072 historical(
4073 0,
4074 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {InternalIOPolling: W}, on previously committed 1s",
4075 ),
4076 historical(
4077 1,
4078 "DEBUG detcore::scheduler: DETLOG [sched-step1] advancing committed_time from 1 to 2",
4079 ),
4080 historical(
4081 2,
4082 "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)",
4083 ),
4084 historical(
4085 3,
4086 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s",
4087 ),
4088 ]);
4089 assert_eq!(
4092 indexed_text(&v),
4093 vec![
4094 (
4095 2,
4096 "INFO detcore: DETLOG [syscall][detcore, dtid 5] finish syscall #9: read(3, 0x1000, 1) = Ok(1)"
4097 ),
4098 (
4099 3,
4100 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
4101 ),
4102 ]
4103 );
4104 }
4105
4106 #[test]
4107 fn test_filter_deterministic_keeps_sabre_internal_pipe_resource_turns() {
4110 let opts = super::LogDiffOpts::default();
4111 let tagged = "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 1s [sabre-internal-pipe-io]";
4112 let untagged = "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Device(ContainerStdout): W}, on previously committed 2s";
4113 let v = opts.filter_deterministic(&[historical(0, tagged), historical(1, untagged)]);
4114
4115 assert_eq!(indexed_text(&v), vec![(0, tagged), (1, untagged)]);
4116 }
4117
4118 #[test]
4119 fn test_filter_deterministic_keeps_sabre_loopback_poll_yields() {
4122 let opts = super::LogDiffOpts::default();
4123 let tagged = "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 17, dettid 5 using resources {SchedYield: W}, on previously committed 1s [sabre-loopback-poll-zero-timeout]";
4124 let untagged = "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {SchedYield: W}, on previously committed 2s";
4125 let v = opts.filter_deterministic(&[historical(0, tagged), historical(1, untagged)]);
4126
4127 assert_eq!(indexed_text(&v), vec![(0, tagged), (1, untagged)]);
4128 }
4129
4130 #[test]
4131 fn test_log_diff_ignores_extra_io_poll_retries() -> std::io::Result<()> {
4132 let common_head = "2022-09-06T14:15:47.000000Z INFO detcore: DETLOG [syscall][detcore, dtid 5] inbound syscall: poll(0x1000, 1, -1) = ?";
4133 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)";
4134 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";
4135
4136 let run_a = format!("{common_head}\n{poll_retry}\n{common_tail}");
4137 let run_b = format!("{common_head}\n{poll_retry}\n{poll_retry}\n{common_tail}");
4139
4140 let opts = super::LogDiffOpts {
4141 no_color: true,
4142 strip_lines: true,
4143 ..Default::default()
4144 };
4145 assert!(!super::log_diff_from_strs(
4147 &run_a,
4148 &run_b,
4149 &opts,
4150 &mut Vec::new()
4151 )?);
4152
4153 let run_c = run_a.replace("= Ok(1)", "= Err(Errno(EBADF))");
4156 assert!(super::log_diff_from_strs(
4157 &run_a,
4158 &run_c,
4159 &opts,
4160 &mut Vec::new()
4161 )?);
4162 Ok(())
4163 }
4164
4165 #[test]
4171 fn canonical_address_only_difference_compares_equal() -> std::io::Result<()> {
4172 let run_a =
4173 "2022-09-06T14:15:47.000000Z INFO detcore: [t] p=<hostaddr 0x1111> q=<hostaddr 0x2222>
41742022-09-06T14:15:48.000000Z INFO detcore: [t] use <hostaddr 0x1111> then <hostaddr 0x2222>";
4175 let run_b =
4176 "2022-09-06T14:15:47.000000Z INFO detcore: [t] p=<hostaddr 0xaaaa> q=<hostaddr 0xbbbb>
41772022-09-06T14:15:48.000000Z INFO detcore: [t] use <hostaddr 0xaaaa> then <hostaddr 0xbbbb>";
4178
4179 let canonical = super::LogDiffOpts {
4180 comparison: super::LogComparisonMode::FullTrace,
4181 canonicalize_addresses: true,
4182 no_color: true,
4183 ..Default::default()
4184 };
4185 assert!(
4186 !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4187 "address-only (ASLR-shift) difference must compare EQUAL under canonicalization"
4188 );
4189
4190 let raw = super::LogDiffOpts {
4193 comparison: super::LogComparisonMode::FullTrace,
4194 canonicalize_addresses: false,
4195 no_color: true,
4196 ..Default::default()
4197 };
4198 assert!(
4199 super::log_diff_from_strs(run_a, run_b, &raw, &mut Vec::new())?,
4200 "raw comparison must still see the differing addresses"
4201 );
4202 Ok(())
4203 }
4204
4205 #[test]
4210 fn canonical_allocation_order_difference_compares_unequal() -> std::io::Result<()> {
4211 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] alloc <hostaddr 0x1111>
42152022-09-06T14:15:48.000000Z INFO detcore: [t] alloc <hostaddr 0x2222>
42162022-09-06T14:15:49.000000Z INFO detcore: [t] pair <hostaddr 0x1111> <hostaddr 0x2222>";
4217 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: [t] alloc <hostaddr 0xbbbb>
42182022-09-06T14:15:48.000000Z INFO detcore: [t] alloc <hostaddr 0xaaaa>
42192022-09-06T14:15:49.000000Z INFO detcore: [t] pair <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4220
4221 let canonical = super::LogDiffOpts {
4222 comparison: super::LogComparisonMode::FullTrace,
4223 canonicalize_addresses: true,
4224 no_color: true,
4225 ..Default::default()
4226 };
4227 assert!(
4228 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4229 "an allocation-order difference must compare UNEQUAL under canonicalization"
4230 );
4231
4232 let stripped = super::LogDiffOpts {
4235 comparison: super::LogComparisonMode::FullTrace,
4236 strip_lines: true,
4237 no_color: true,
4238 ..Default::default()
4239 };
4240 assert!(
4241 !super::log_diff_from_strs(run_a, run_b, &stripped, &mut Vec::new())?,
4242 "wholesale stripping erases the allocation-order difference (the defect)"
4243 );
4244 Ok(())
4245 }
4246
4247 #[test]
4251 fn canonical_aliasing_difference_compares_unequal() -> std::io::Result<()> {
4252 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] two <hostaddr 0x1111> <hostaddr 0x1111>";
4253 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: [t] two <hostaddr 0xaaaa> <hostaddr 0xbbbb>";
4254
4255 let canonical = super::LogDiffOpts {
4256 comparison: super::LogComparisonMode::FullTrace,
4257 canonicalize_addresses: true,
4258 no_color: true,
4259 ..Default::default()
4260 };
4261 assert!(
4262 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4263 "an aliasing difference (1,1 vs 1,2) must compare UNEQUAL"
4264 );
4265 Ok(())
4266 }
4267
4268 #[test]
4277 fn canonical_syscall_arg_hex_difference_compares_unequal() -> std::io::Result<()> {
4278 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4279 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x6) at <hostaddr 0xaaaa>";
4280
4281 let canonical = super::LogDiffOpts {
4282 comparison: super::LogComparisonMode::FullTrace,
4283 canonicalize_addresses: true,
4284 no_color: true,
4285 ..Default::default()
4286 };
4287 assert!(
4288 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4289 "a bare syscall-argument hex difference (0x2 vs 0x6) must compare UNEQUAL: \
4290 it is reproducible and NOT a host address"
4291 );
4292
4293 let addr_only_a = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0x1111>";
4298 let addr_only_b = "2022-09-06T14:15:47.000000Z INFO detcore: flock(fd=3, operation=0x2) at <hostaddr 0xaaaa>";
4299 assert!(
4300 !super::log_diff_from_strs(addr_only_a, addr_only_b, &canonical, &mut Vec::new())?,
4301 "an address-only difference alongside an identical syscall arg must compare EQUAL"
4302 );
4303 Ok(())
4304 }
4305
4306 #[test]
4312 fn canonical_virtual_time_difference_compares_unequal() -> std::io::Result<()> {
4313 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: COMMIT turn 5 at time 100";
4314 let run_b = "2022-09-06T14:15:47.000000Z INFO detcore: COMMIT turn 5 at time 200";
4315
4316 let canonical = super::LogDiffOpts {
4317 comparison: super::LogComparisonMode::FullTrace,
4318 canonicalize_addresses: true,
4319 no_color: true,
4320 ..Default::default()
4321 };
4322 assert!(
4323 super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4324 "a virtual-time (decimal) difference must compare UNEQUAL under canonicalization"
4325 );
4326 Ok(())
4327 }
4328
4329 #[test]
4334 fn canonical_wall_clock_prefix_difference_compares_equal() -> std::io::Result<()> {
4335 let run_a = "2022-09-06T14:15:47.000000Z INFO detcore: [t] use 0x1111
43362022-09-06T14:15:48.000000Z INFO detcore: [t] use 0x1111";
4337 let run_b = "Apr 09 06:08:03.100 INFO detcore: [t] use 0x1111
4339Jun 09 06:49:17.742 INFO detcore: [t] use 0x1111";
4340
4341 let canonical = super::LogDiffOpts {
4342 comparison: super::LogComparisonMode::FullTrace,
4343 canonicalize_addresses: true,
4344 no_color: true,
4345 ..Default::default()
4346 };
4347 assert!(
4348 !super::log_diff_from_strs(run_a, run_b, &canonical, &mut Vec::new())?,
4349 "a wall-clock-prefix-only difference must compare EQUAL"
4350 );
4351 Ok(())
4352 }
4353
4354 #[test]
4355 fn one_log_canonical_info_preserves_values_and_only_rewrites_marked_addresses() {
4356 let log = "2026-08-13T01:02:03.000000Z INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<hostaddr 0xaaaa>\n\
43572026-08-13T01:02:03.000001Z DEBUG detcore: diagnostic=999\n\
43582026-08-13T01:02:03.000002Z INFO detcore: DETLOG count=42 bare=0x6 marked=<hostaddr 0xaaaa> other=<hostaddr 0xbbbb>";
4359
4360 assert_eq!(
4361 super::canonical_info_from_str(log).expect("fixture log is fully tagged"),
4362 vec![
4363 "INFO detcore: COMMIT turn 17 at time 123456 bare=0x2 marked=<addr1>",
4364 "INFO detcore: DETLOG count=42 bare=0x6 marked=<addr1> other=<addr2>",
4365 ]
4366 );
4367 }
4368
4369 #[test]
4370 fn first_log_divergence_reports_preceding_commit_turn_and_virtual_time() -> std::io::Result<()>
4371 {
4372 let left = "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
43732026-08-13T01:02:03.000001Z INFO detcore: DETLOG count=42";
4374 let right = "2026-08-13T01:02:04.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 12.345_678_901s\n\
43752026-08-13T01:02:04.000001Z INFO detcore: DETLOG count=43";
4376 let opts = super::LogDiffOpts {
4377 comparison: super::LogComparisonMode::Info,
4378 canonicalize_addresses: true,
4379 no_color: true,
4380 ..Default::default()
4381 };
4382
4383 let diverged = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4384 assert!(diverged.diff_found);
4385 assert_eq!(diverged.first_divergent_scheduler_turn, Some(17));
4386 assert_eq!(
4387 diverged.first_divergent_virtual_nanoseconds,
4388 Some(12_345_678_901)
4389 );
4390 assert_eq!(
4391 diverged.first_divergent_left_message.as_deref(),
4392 Some("INFO detcore: DETLOG count=42")
4393 );
4394 assert_eq!(
4395 diverged.first_divergent_right_message.as_deref(),
4396 Some("INFO detcore: DETLOG count=43")
4397 );
4398
4399 let matched = super::log_diff_summary_from_strs(left, left, &opts, &mut Vec::new())?;
4400 assert!(matched.matched_with_evidence());
4401 assert_eq!(matched.first_divergent_scheduler_turn, None);
4402 assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4403 assert_eq!(matched.first_divergent_left_message, None);
4404 assert_eq!(matched.first_divergent_right_message, None);
4405 Ok(())
4406 }
4407
4408 #[test]
4409 fn first_divergent_messages_keep_event_content_not_separate_positions() -> std::io::Result<()> {
4410 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";
4411 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";
4412 let opts = super::LogDiffOpts {
4413 comparison: super::LogComparisonMode::Info,
4414 canonicalize_addresses: true,
4415 no_color: true,
4416 ..Default::default()
4417 };
4418
4419 let same_event = super::log_diff_summary_from_strs(left, right, &opts, &mut Vec::new())?;
4420 assert!(same_event.diff_found);
4421 assert_eq!(
4422 same_event.first_divergent_left_message, same_event.first_divergent_right_message,
4423 "turn and committed-time values are recorded separately, so they must not split one event into two"
4424 );
4425 assert_eq!(
4426 same_event.first_divergent_left_message.as_deref(),
4427 Some(
4428 "INFO detcore::scheduler: COMMIT turn <NUM>, dettid 2 using resources {Device(ContainerStdout): W}, on previously committed <NANOSECONDS>"
4429 )
4430 );
4431 assert_eq!(
4432 super::first_divergent_message(&historical(
4433 0,
4434 "INFO detcore::scheduler: COMMIT turn 110 at time 123\npayload at time 456"
4435 )),
4436 "INFO detcore::scheduler: COMMIT turn <NUM> at time <NANOSECONDS>\npayload at time 456",
4437 "only the structured first line carries the separately recorded position"
4438 );
4439
4440 let after_longer_shared_prefix = super::log_diff_summary_from_strs(
4441 format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{left}"),
4442 format!("2026-08-13T01:02:02.000000Z INFO detcore: shared event\n{right}"),
4443 &opts,
4444 &mut Vec::new(),
4445 )?;
4446 assert_ne!(
4447 same_event.first_divergent_record, after_longer_shared_prefix.first_divergent_record,
4448 "a longer shared trace must move the record observation in this fixture"
4449 );
4450 assert_eq!(
4451 (
4452 same_event.first_divergent_left_message.as_deref(),
4453 same_event.first_divergent_right_message.as_deref(),
4454 ),
4455 (
4456 after_longer_shared_prefix
4457 .first_divergent_left_message
4458 .as_deref(),
4459 after_longer_shared_prefix
4460 .first_divergent_right_message
4461 .as_deref(),
4462 ),
4463 "the same event after a longer trace must keep the same compared messages"
4464 );
4465
4466 let different_event = super::log_diff_summary_from_strs(
4467 left,
4468 "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",
4469 &opts,
4470 &mut Vec::new(),
4471 )?;
4472 assert_ne!(
4473 different_event.first_divergent_left_message,
4474 different_event.first_divergent_right_message,
4475 "different event content must remain distinguishable"
4476 );
4477 Ok(())
4478 }
4479
4480 #[test]
4481 fn log_divergence_without_commit_metadata_reports_no_position() -> std::io::Result<()> {
4482 let opts = super::LogDiffOpts {
4483 comparison: super::LogComparisonMode::Info,
4484 canonicalize_addresses: true,
4485 no_color: true,
4486 ..Default::default()
4487 };
4488 let summary = super::log_diff_summary_from_strs(
4489 "INFO detcore: DETLOG count=42",
4490 "INFO detcore: DETLOG count=43",
4491 &opts,
4492 &mut Vec::new(),
4493 )?;
4494 assert!(summary.diff_found);
4495 assert_eq!(summary.first_divergent_scheduler_turn, None);
4496 assert_eq!(summary.first_divergent_virtual_nanoseconds, None);
4497 Ok(())
4498 }
4499
4500 #[test]
4506 fn empty_selection_is_a_no_result_not_a_match() -> std::io::Result<()> {
4507 let canonical = super::LogDiffOpts {
4508 comparison: super::LogComparisonMode::FullTrace,
4509 canonicalize_addresses: true,
4510 no_color: true,
4511 ..Default::default()
4512 };
4513 let summary = super::log_diff_summary_from_strs("", "", &canonical, &mut Vec::new())?;
4514 assert!(!summary.diff_found, "two empty logs do not differ");
4515 assert_eq!(summary.compared_left, 0);
4516 assert_eq!(summary.compared_right, 0);
4517 assert!(
4518 !summary.matched_with_evidence(),
4519 "zero compared messages must never count as a verified match"
4520 );
4521 Ok(())
4522 }
4523
4524 #[test]
4527 fn nonempty_identical_selection_is_a_match_with_evidence() -> std::io::Result<()> {
4528 let run = "Apr 09 06:08:03.100 INFO detcore: [t] finish syscall: close(2) = Ok(0)
4529Apr 09 06:08:03.200 INFO detcore: [t] finish syscall: exit_group(0)";
4530 let canonical = super::LogDiffOpts {
4531 comparison: super::LogComparisonMode::FullTrace,
4532 canonicalize_addresses: true,
4533 no_color: true,
4534 ..Default::default()
4535 };
4536 let summary = super::log_diff_summary_from_strs(run, run, &canonical, &mut Vec::new())?;
4537 assert!(!summary.diff_found);
4538 assert_eq!(summary.compared_left, 2);
4539 assert_eq!(summary.compared_right, 2);
4540 assert!(
4541 summary.matched_with_evidence(),
4542 "a real, nonempty, identical comparison must count as a match"
4543 );
4544 Ok(())
4545 }
4546
4547 #[test]
4548 fn test_filter_infos() {
4549 let v = super::filter_infos(&[
4550 historical(
4551 0,
4552 "DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000",
4553 ),
4554 historical(
4555 1,
4556 "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ...",
4557 ),
4558 ]);
4559 assert_eq!(
4560 indexed_text(&v),
4561 vec![(
4562 1,
4563 "INFO detcore: registers [dtid 3]. user_regs_struct { r15: 140737354129904, ..."
4564 )]
4565 );
4566 }
4567
4568 #[test]
4569 fn test_extract_log_messages() {
4570 let s = "
4571Jan 09 06:08:03.100 INFO detcore: [detcore, dtid 2] finish syscall: close(2) = Ok(0)
4572Feb 09 06:49:17.742 DEBUG detcore::scheduler: [sched-step3] advancing committed_time from 946684799165300000 to 946684799205300000
4573Apr 09 06:49:17.742 INFO detcore::scheduler: [scheduler] >>>>>>>
4574
4575 COMMIT turn 5, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946684799205300000
4576Jan 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 }
4577Jun 09 06:49:17.742 TRACE detcore::scheduler: [scheduler] Guest unblocked (<ivar Go>); clear ivars for the next turn on dettid 2
4578";
4579
4580 let v = super::extract_log_messages(s).expect("fixture log is fully tagged");
4581 eprintln!("Split into {} log messages", v.len());
4582 for x in &v {
4583 eprintln!("{:?}", x);
4584 }
4585 assert_eq!(v.len(), 5);
4586 }
4587
4588 #[test]
4589 fn test_canonicalize_addresses_in_line() {
4590 use std::collections::HashMap;
4591 let mut map = HashMap::new();
4592 let mut next = 1usize;
4593 assert_eq!(
4597 super::canonicalize_addresses_in_line(
4598 "a=<hostaddr 0x1111> b=<hostaddr 0x2222> c=<hostaddr 0x1111> raw=0x4444 n=42",
4599 &mut map,
4600 &mut next
4601 ),
4602 "a=<addr1> b=<addr2> c=<addr1> raw=0x4444 n=42"
4603 );
4604 assert_eq!(
4607 super::canonicalize_addresses_in_line(
4608 "use <hostaddr 0x2222> then <hostaddr 0x3333>",
4609 &mut map,
4610 &mut next
4611 ),
4612 "use <addr2> then <addr3>"
4613 );
4614 assert_eq!(
4617 super::canonicalize_addresses_in_line("bare 0x1111", &mut map, &mut next),
4618 "bare 0x1111"
4619 );
4620 }
4621
4622 #[test]
4623 fn test_strip_log() {
4624 assert_eq!(super::strip_log_entry("800.709_180s"), "<NANOSECONDS>");
4625 assert_eq!(super::strip_log_entry("98.91618ms"), "<NUM>");
4626 assert_eq!(super::strip_log_entry("98.91619ms"), "<NUM>");
4627 assert_eq!(super::strip_log_entry("x86_64"), "x86_64");
4628 assert_eq!(
4629 super::strip_log_entry(
4630 "COMMIT turn 66, dettid 2 using resources {Path(\"/proc/2/fd/1\"): W} at time 946_684_800.709_180_000s"
4631 ),
4632 "COMMIT turn <NUM>, dettid <NUM> using resources {Path(\"/proc/<PID>/fd/<NUM>\"): W} at time <NANOSECONDS>"
4633 );
4634 }
4635
4636 #[test]
4644 fn strip_tmp_path_does_not_swallow_rest_of_line() {
4645 let read = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_RDONLY""#);
4646 let write = super::strip_log_entry(r#"open path="/tmp/scratch" flags="O_WRONLY""#);
4647
4648 assert_eq!(read, r#"open path="/tmp/<somewhere>" flags="O_RDONLY""#);
4649 assert_eq!(write, r#"open path="/tmp/<somewhere>" flags="O_WRONLY""#);
4650 assert_ne!(
4651 read, write,
4652 "entries differing after a /tmp path must not collapse to equal"
4653 );
4654 }
4655
4656 #[test]
4660 fn strip_tmp_path_still_erases_a_differing_tmp_path() {
4661 assert_eq!(
4662 super::strip_log_entry(r#"open path="/tmp/hermit-aaaa/f" flags="O_RDONLY""#),
4663 super::strip_log_entry(r#"open path="/tmp/hermit-bbbb/f" flags="O_RDONLY""#),
4664 );
4665 }
4666
4667 #[test]
4671 fn strip_tmp_path_erases_each_path_separately() {
4672 assert_eq!(
4673 super::strip_log_entry(r#"rename from="/tmp/a" to="/tmp/b" ok="1""#),
4674 r#"rename from="/tmp/<somewhere>" to="/tmp/<somewhere>" ok="<NUM>""#
4675 );
4676 }
4677
4678 const KICK_LINE_PREFIX: &str = "Logs contain";
4679 const KICK_LINE_SUFFIX: &str = "scheduler empty-run-queue kick messages";
4680
4681 fn kick_opts() -> super::LogDiffOpts {
4682 super::LogDiffOpts {
4683 comparison: super::LogComparisonMode::Info,
4684 canonicalize_addresses: true,
4685 no_color: true,
4686 ..Default::default()
4687 }
4688 }
4689
4690 fn log_with_kick() -> &'static str {
4694 "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
46952026-08-13T01:02:03.000001Z INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling.\n\
46962026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4697 }
4698
4699 fn log_without_kick() -> &'static str {
4700 "2026-08-13T01:02:03.000000Z INFO detcore::scheduler: COMMIT turn 17, dettid 2, on previously committed 1s\n\
47012026-08-13T01:02:03.000002Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4702 }
4703
4704 fn run_diff(left: &str, right: &str) -> std::io::Result<(super::LogDiffSummary, String)> {
4705 let mut out = Vec::new();
4706 let summary = super::log_diff_summary_from_strs(left, right, &kick_opts(), &mut out)?;
4707 Ok((
4708 summary,
4709 String::from_utf8(out).expect("diff output is utf-8"),
4710 ))
4711 }
4712
4713 #[test]
4717 fn a_matching_pair_records_the_empty_queue_kick_count() -> std::io::Result<()> {
4718 let (kicked, kicked_out) = run_diff(log_with_kick(), log_with_kick())?;
4719 assert!(kicked.matched_with_evidence(), "both-kicked pair must pass");
4720 assert!(
4721 kicked_out.contains(&format!("{KICK_LINE_PREFIX} 1 | 1 {KICK_LINE_SUFFIX}")),
4722 "a passing pair that kicked must retain the count, got:\n{kicked_out}"
4723 );
4724
4725 let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4726 assert!(
4727 quiet.matched_with_evidence(),
4728 "neither-kicked pair must pass"
4729 );
4730 assert!(
4731 quiet_out.contains(&format!("{KICK_LINE_PREFIX} 0 | 0 {KICK_LINE_SUFFIX}")),
4732 "a passing pair that did not kick must say so explicitly, got:\n{quiet_out}"
4733 );
4734 Ok(())
4735 }
4736
4737 #[test]
4742 fn recording_the_kick_count_does_not_move_any_verdict() -> std::io::Result<()> {
4743 let (diverged, diverged_out) = run_diff(log_with_kick(), log_without_kick())?;
4744 assert!(
4745 diverged.diff_found,
4746 "a pair differing only by the kick must still diverge"
4747 );
4748 assert!(
4749 !diverged_out.contains(KICK_LINE_SUFFIX),
4750 "the count is scoped to passing pairs; a diverging pair already \
4751 reproduces the messages in its diff, got:\n{diverged_out}"
4752 );
4753
4754 let (matched, _) = run_diff(log_with_kick(), log_with_kick())?;
4757 assert!(!matched.diff_found);
4758 assert_eq!(matched.first_divergent_scheduler_turn, None);
4759 assert_eq!(matched.first_divergent_virtual_nanoseconds, None);
4760 assert_eq!(matched.first_divergent_record, None);
4761 assert_eq!(matched.compared_left, matched.compared_right);
4762 Ok(())
4763 }
4764
4765 const MAPS_LINE_SUFFIX: &str = "scheduler COMMIT records reading /proc/self/maps";
4766
4767 fn log_with_maps_read(committed: &str) -> String {
4771 format!(
4772 "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\
47732026-08-13T01:02:03.000001Z INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
4774 )
4775 }
4776
4777 #[test]
4778 fn custom_side_labels_name_the_maps_read_summary() -> std::io::Result<()> {
4779 let scanned = log_with_maps_read("12.345_678_901s");
4780 let options = super::LogDiffOpts {
4781 comparison: super::LogComparisonMode::Info,
4782 canonicalize_addresses: true,
4783 side_labels: super::ComparisonSideLabels::new("the recording", "the replay"),
4784 no_color: true,
4785 ..Default::default()
4786 };
4787 let mut output = Vec::new();
4788 let summary = super::log_diff_summary_from_strs(&scanned, &scanned, &options, &mut output)?;
4789 assert!(summary.matched_with_evidence());
4790 let output = String::from_utf8(output).unwrap();
4791 assert!(output.contains(
4792 "(the recording first at turn 10, committed virtual time 12345678901ns, \
4793 the replay first at turn 10, committed virtual time 12345678901ns)"
4794 ));
4795 assert!(!output.contains("run 1") && !output.contains("run 2"));
4796 Ok(())
4797 }
4798
4799 #[test]
4803 fn a_matching_pair_records_the_maps_read_commit() -> std::io::Result<()> {
4804 let scanned = log_with_maps_read("12.345_678_901s");
4805 let (summary, out) = run_diff(&scanned, &scanned)?;
4806 assert!(summary.matched_with_evidence(), "the pair must pass");
4807 assert!(
4808 out.contains(&format!("Logs contain 1 | 1 {MAPS_LINE_SUFFIX}")),
4809 "a passing pair that read the map must retain the record, got:\n{out}"
4810 );
4811 assert!(
4814 out.contains(
4815 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4816 run 2 first at turn 10, committed virtual time 12345678901ns)"
4817 ),
4818 "the retained record must carry the turn and the committed virtual \
4819 time, got:\n{out}"
4820 );
4821
4822 let (quiet, quiet_out) = run_diff(log_without_kick(), log_without_kick())?;
4823 assert!(quiet.matched_with_evidence());
4824 assert!(
4825 quiet_out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4826 "a passing pair that never read the map must say so explicitly, \
4827 got:\n{quiet_out}"
4828 );
4829 Ok(())
4830 }
4831
4832 #[test]
4837 fn recording_the_maps_read_commit_does_not_move_any_verdict() -> std::io::Result<()> {
4838 let (diverged, diverged_out) = run_diff(
4839 &log_with_maps_read("12.345_678_901s"),
4840 &log_with_maps_read("12.345_678_902s"),
4841 )?;
4842 assert!(
4843 diverged.diff_found,
4844 "a one-nanosecond difference in the committed time must still diverge"
4845 );
4846 assert_eq!(diverged.first_divergent_scheduler_turn, Some(10));
4847 assert!(
4848 !diverged_out.contains(MAPS_LINE_SUFFIX),
4849 "the record is scoped to passing pairs; a diverging pair already \
4850 prints both times in its diff, got:\n{diverged_out}"
4851 );
4852 Ok(())
4853 }
4854
4855 #[test]
4862 fn both_records_are_retained_under_the_default_comparison_mode() -> std::io::Result<()> {
4863 let default_opts = super::LogDiffOpts {
4864 no_color: true,
4865 ..Default::default()
4866 };
4867 assert_eq!(
4868 default_opts.comparison,
4869 super::LogComparisonMode::Deterministic,
4870 "this test exists to cover the default mode; if the default changes \
4871 it must be re-pointed, not deleted"
4872 );
4873
4874 let mut out = Vec::new();
4875 let summary = super::log_diff_summary_from_strs(
4876 log_with_kick(),
4877 log_without_kick(),
4878 &default_opts,
4879 &mut out,
4880 )?;
4881 let out = String::from_utf8(out).expect("diff output is utf-8");
4882 assert!(
4883 summary.matched_with_evidence(),
4884 "a kick asymmetry is not compared under the default mode, so this \
4885 pair must pass; got:\n{out}"
4886 );
4887 assert!(
4888 out.contains(&format!("Logs contain 1 | 0 {KICK_LINE_SUFFIX}")),
4889 "the asymmetry must be visible per side on the default path, \
4890 got:\n{out}"
4891 );
4892 assert!(
4893 out.contains(&format!("Logs contain 0 | 0 {MAPS_LINE_SUFFIX}")),
4894 "the map-read line must also be emitted on the default path, \
4895 got:\n{out}"
4896 );
4897
4898 let scanned = log_with_maps_read("12.345_678_901s");
4900 let mut out = Vec::new();
4901 let summary =
4902 super::log_diff_summary_from_strs(&scanned, &scanned, &default_opts, &mut out)?;
4903 let out = String::from_utf8(out).expect("diff output is utf-8");
4904 assert!(summary.matched_with_evidence());
4905 assert!(
4906 out.contains(
4907 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4908 run 2 first at turn 10, committed virtual time 12345678901ns)"
4909 ),
4910 "both runs' values must be printed under the default mode, \
4911 got:\n{out}"
4912 );
4913 Ok(())
4914 }
4915
4916 #[test]
4924 fn a_stripped_pass_shows_both_runs_diverging_map_read_times() -> std::io::Result<()> {
4925 let opts = super::LogDiffOpts {
4926 strip_lines: true,
4927 no_color: true,
4928 ..Default::default()
4929 };
4930 let mut out = Vec::new();
4931 let summary = super::log_diff_summary_from_strs(
4932 log_with_maps_read("12.345_678_901s"),
4933 log_with_maps_read("12.345_678_902s"),
4934 &opts,
4935 &mut out,
4936 )?;
4937 let out = String::from_utf8(out).expect("diff output is utf-8");
4938 assert!(
4939 summary.matched_with_evidence(),
4940 "the stripped comparator normalizes the times, so this pair passes; \
4941 got:\n{out}"
4942 );
4943 assert!(
4944 out.contains(
4945 "(run 1 first at turn 10, committed virtual time 12345678901ns, \
4946 run 2 first at turn 10, committed virtual time 12345678902ns)"
4947 ),
4948 "a passing pair whose runs committed at DIFFERENT times must show \
4949 both values; showing one would report agreement on a real drift, \
4950 got:\n{out}"
4951 );
4952 Ok(())
4953 }
4954
4955 #[test]
4958 fn a_run_without_the_maps_read_is_named_not_borrowed() {
4959 assert_eq!(super::describe_maps_commit(None), "no such record");
4960 assert_eq!(
4961 super::describe_maps_commit(Some((10, Some(12_345_678_901)))),
4962 "first at turn 10, committed virtual time 12345678901ns"
4963 );
4964 assert_eq!(
4965 super::describe_maps_commit(Some((10, None))),
4966 "first at turn 10, committed virtual time unrecorded"
4967 );
4968 }
4969
4970 #[test]
4974 fn only_a_maps_read_commit_is_counted() {
4975 assert_eq!(
4976 super::maps_read_commits(&[
4977 historical(
4978 0,
4979 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 18, dettid 5 using resources {Path(\"/proc/5/fd/3\"): R}, on previously committed 2s"
4980 ),
4981 historical(
4982 1,
4983 "INFO detcore: DETLOG [syscall][detcore, dtid 3] finish syscall #257: openat(-100, \"/proc/self/maps\", 0x0) = Ok(4)"
4984 ),
4985 ]),
4986 (0, None),
4987 "a COMMIT on another path, and a syscall naming the map, are both \
4988 excluded"
4989 );
4990 assert_eq!(
4991 super::maps_read_commits(&[historical(
4992 0,
4993 "INFO detcore::scheduler: [sched-step5] >>> COMMIT turn 10, dettid 3 using resources {Path(\"/proc/self/maps\"): R}, on previously committed 12.345_678_901s"
4994 )]),
4995 (1, Some((10, Some(12_345_678_901))))
4996 );
4997 }
4998
4999 #[test]
5002 fn only_the_kick_message_is_counted() {
5003 assert_eq!(
5004 super::count_empty_queue_kicks(&[
5005 historical(
5006 0,
5007 "INFO detcore::scheduler: [scheduler] run queue empty, exiting sched_loop."
5008 ),
5009 historical(
5010 1,
5011 "INFO detcore::scheduler: COMMIT turn 18, dettid 2, on previously committed 2s"
5012 ),
5013 ]),
5014 0
5015 );
5016 assert_eq!(
5017 super::count_empty_queue_kicks(&[historical(
5018 0,
5019 "INFO detcore::scheduler: scheduler (step2_process_blocked): zero threads left anywhere, fizzling."
5020 )]),
5021 1
5022 );
5023 }
5024}