Evan's Blog
EvanZhu0721 / 公开仓库

SBMS

镜像已同步

SBMS complete the calculation rules for the default logical desktop topology relationship and the physical dimensions of the displays in the Windows multi-screen collaboration system.

上游 GitHub
READ-ONLY MIRROR

README、源码与 Release 已保存到本站;源码视图固定到 commit a37d6c4b。

main/ root / src
a37d6c4b· 固定源码快照
src/diagnostics.rs24.2 KB
1use std::collections::VecDeque;
2use std::ffi::OsString;
3use std::fs::{self, OpenOptions};
4use std::io::{self, BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Mutex, OnceLock};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11use windows::Win32::Foundation::{
12 CloseHandle, FILETIME, HANDLE, SYSTEMTIME, WAIT_ABANDONED, WAIT_FAILED, WAIT_OBJECT_0,
13};
14use windows::Win32::System::Threading::{
15 CreateMutexW, INFINITE, ReleaseMutex, WaitForSingleObject,
16};
17use windows::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTimeEx};
18use windows::core::w;
19
20const DEFAULT_MAX_FILE_BYTES: u64 = 1536 * 1024;
21const DEFAULT_MEMORY_RECORDS: usize = 200;
22const ROLLING_FILE_COUNT: usize = 3;
23const AUXILIARY_LOG_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
24const WINDOWS_EPOCH_OFFSET_TICKS: u64 = 116_444_736_000_000_000;
25const TICKS_PER_MILLISECOND: u64 = 10_000;
26
27static DIAGNOSTICS: OnceLock<Diagnostics> = OnceLock::new();
28static NEXT_MAPPING_SESSION: AtomicU64 = AtomicU64::new(1);
29
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "UPPERCASE")]
32pub enum Level {
33 Debug,
34 Info,
35 Warn,
36 Error,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct MappingSessionId {
41 process_id: u32,
42 sequence: u64,
43}
44
45impl std::fmt::Display for MappingSessionId {
46 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(formatter, "{}-{}", self.process_id, self.sequence)
48 }
49}
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52pub struct Record {
53 pub time: u64,
54 #[serde(default, skip_serializing_if = "String::is_empty")]
55 pub local_time: String,
56 pub level: Level,
57 pub module: String,
58 pub stage: String,
59 pub mapping_session_id: String,
60 pub message: String,
61}
62
63pub fn init() -> io::Result<&'static Path> {
64 if let Some(diagnostics) = DIAGNOSTICS.get() {
65 return Ok(&diagnostics.path);
66 }
67
68 let path = default_log_path()?;
69 let diagnostics = Diagnostics::open(path, DEFAULT_MAX_FILE_BYTES, DEFAULT_MEMORY_RECORDS)?;
70 let _ = DIAGNOSTICS.set(diagnostics);
71 Ok(&DIAGNOSTICS
72 .get()
73 .expect("diagnostics must be initialized")
74 .path)
75}
76
77pub fn default_log_path() -> io::Result<PathBuf> {
78 let local = std::env::var_os("LOCALAPPDATA")
79 .filter(|value| !value.is_empty())
80 .ok_or_else(|| {
81 io::Error::new(
82 io::ErrorKind::NotFound,
83 "LOCALAPPDATA is unavailable; cannot locate the SBMS log directory",
84 )
85 })?;
86 Ok(PathBuf::from(local)
87 .join("SBMS")
88 .join("logs")
89 .join("sbms.log"))
90}
91
92pub fn new_mapping_session_id() -> MappingSessionId {
93 MappingSessionId {
94 process_id: std::process::id(),
95 sequence: NEXT_MAPPING_SESSION.fetch_add(1, Ordering::Relaxed),
96 }
97}
98
99pub fn log(
100 level: Level,
101 module: impl Into<String>,
102 stage: impl Into<String>,
103 mapping_session_id: Option<MappingSessionId>,
104 message: impl Into<String>,
105) {
106 let time = unix_time_millis();
107 let record = Record {
108 time,
109 local_time: local_time_string(time).unwrap_or_default(),
110 level,
111 module: module.into(),
112 stage: stage.into(),
113 mapping_session_id: mapping_session_id
114 .map(|id| id.to_string())
115 .unwrap_or_else(|| "-".into()),
116 message: message.into(),
117 };
118
119 let result = DIAGNOSTICS
120 .get()
121 .map(|diagnostics| diagnostics.write(record.clone()));
122 if let Some(Err(error)) = result {
123 eprintln!("SBMS diagnostics write failed: {error}");
124 } else if result.is_none() && level == Level::Error {
125 eprintln!(
126 "SBMS error [{}:{} session={}]: {}",
127 record.module, record.stage, record.mapping_session_id, record.message
128 );
129 }
130}
131
132pub fn recent() -> Vec<Record> {
133 DIAGNOSTICS
134 .get()
135 .map(Diagnostics::recent)
136 .unwrap_or_default()
137}
138
139pub fn latest_error_matches(message: &str) -> bool {
140 DIAGNOSTICS
141 .get()
142 .and_then(|diagnostics| diagnostics.recent().pop())
143 .is_some_and(|record| record.level == Level::Error && record.message == message)
144}
145
146pub fn persisted_recent() -> io::Result<Vec<Record>> {
147 let path = default_log_path()?;
148 let _guard = CrossProcessLogGuard::acquire()?;
149 let mut records = VecDeque::with_capacity(DEFAULT_MEMORY_RECORDS);
150 for candidate in [rotated_path(&path, 2), rotated_path(&path, 1), path] {
151 let file = match fs::File::open(&candidate) {
152 Ok(file) => file,
153 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
154 Err(error) => return Err(error),
155 };
156 for line in BufReader::new(file).lines() {
157 let line = line?;
158 let Ok(record) = serde_json::from_str::<Record>(&line) else {
159 continue;
160 };
161 if records.len() == DEFAULT_MEMORY_RECORDS {
162 records.pop_front();
163 }
164 records.push_back(record);
165 }
166 }
167 Ok(records.into())
168}
169
170fn unix_time_millis() -> u64 {
171 SystemTime::now()
172 .duration_since(UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_millis()
175 .try_into()
176 .unwrap_or(u64::MAX)
177}
178
179pub fn local_time_string(unix_millis: u64) -> Option<String> {
180 let ticks = unix_millis
181 .checked_mul(TICKS_PER_MILLISECOND)?
182 .checked_add(WINDOWS_EPOCH_OFFSET_TICKS)?;
183 let file_time = FILETIME {
184 dwLowDateTime: ticks as u32,
185 dwHighDateTime: (ticks >> 32) as u32,
186 };
187 let mut utc = SYSTEMTIME::default();
188 unsafe { FileTimeToSystemTime(&file_time, &mut utc) }.ok()?;
189 let mut local = SYSTEMTIME::default();
190 unsafe { SystemTimeToTzSpecificLocalTimeEx(None, &utc, &mut local) }.ok()?;
191 Some(format!(
192 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
193 local.wYear,
194 local.wMonth,
195 local.wDay,
196 local.wHour,
197 local.wMinute,
198 local.wSecond,
199 local.wMilliseconds
200 ))
201}
202
203struct Diagnostics {
204 path: PathBuf,
205 max_file_bytes: u64,
206 memory_records: usize,
207 state: Mutex<State>,
208}
209
210struct State {
211 recent: VecDeque<Record>,
212}
213
214#[derive(Default)]
215struct CleanupReport {
216 removed: usize,
217 failures: Vec<String>,
218}
219
220struct CrossProcessLogGuard(HANDLE);
221
222impl CrossProcessLogGuard {
223 fn acquire() -> io::Result<Self> {
224 let handle = unsafe { CreateMutexW(None, false, w!("Local\\SBMSDiagnostics-v1")) }
225 .map_err(io::Error::other)?;
226 let wait = unsafe { WaitForSingleObject(handle, INFINITE) };
227 if wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED {
228 return Ok(Self(handle));
229 }
230
231 unsafe {
232 let _ = CloseHandle(handle);
233 }
234 if wait == WAIT_FAILED {
235 Err(io::Error::last_os_error())
236 } else {
237 Err(io::Error::other(format!(
238 "unexpected diagnostics mutex wait result: {}",
239 wait.0
240 )))
241 }
242 }
243}
244
245impl Drop for CrossProcessLogGuard {
246 fn drop(&mut self) {
247 unsafe {
248 let _ = ReleaseMutex(self.0);
249 let _ = CloseHandle(self.0);
250 }
251 }
252}
253
254impl Diagnostics {
255 fn open(path: PathBuf, max_file_bytes: u64, memory_records: usize) -> io::Result<Self> {
256 if max_file_bytes == 0 {
257 return Err(io::Error::new(
258 io::ErrorKind::InvalidInput,
259 "maximum log file size must be greater than zero",
260 ));
261 }
262 if memory_records == 0 {
263 return Err(io::Error::new(
264 io::ErrorKind::InvalidInput,
265 "memory record capacity must be greater than zero",
266 ));
267 }
268 if let Some(parent) = path.parent() {
269 fs::create_dir_all(parent)?;
270 }
271 let cleanup_report = match CrossProcessLogGuard::acquire() {
272 Ok(_guard) => cleanup_obsolete_logs(
273 path.parent().unwrap_or_else(|| Path::new(".")),
274 SystemTime::now(),
275 ),
276 Err(error) => CleanupReport {
277 failures: vec![format!("could not acquire the diagnostics lock: {error}")],
278 ..CleanupReport::default()
279 },
280 };
281 let diagnostics = Self {
282 path,
283 max_file_bytes,
284 memory_records,
285 state: Mutex::new(State {
286 recent: VecDeque::with_capacity(memory_records),
287 }),
288 };
289 diagnostics.report_cleanup(cleanup_report);
290 Ok(diagnostics)
291 }
292
293 fn write(&self, mut record: Record) -> io::Result<()> {
294 let line = encode_line(&mut record, self.max_file_bytes as usize)?;
295 let mut state = self
296 .state
297 .lock()
298 .unwrap_or_else(|poisoned| poisoned.into_inner());
299
300 if state.recent.len() == self.memory_records {
301 state.recent.pop_front();
302 }
303 state.recent.push_back(record.clone());
304
305 let _guard = CrossProcessLogGuard::acquire()?;
306 let current_bytes = OpenOptions::new()
307 .create(true)
308 .append(true)
309 .open(&self.path)?
310 .metadata()?
311 .len();
312 if current_bytes > 0
313 && current_bytes.saturating_add(line.len() as u64) > self.max_file_bytes
314 {
315 self.rotate()?;
316 }
317
318 // Open the active path for every record. A persistent handle can keep pointing at
319 // `sbms.log.1` after another SBMS process rotates the file.
320 let mut file = OpenOptions::new()
321 .create(true)
322 .append(true)
323 .open(&self.path)?;
324 file.write_all(&line)?;
325 file.flush()?;
326 if record.level == Level::Error {
327 file.sync_data()?;
328 }
329 Ok(())
330 }
331
332 fn report_cleanup(&self, report: CleanupReport) {
333 if report.removed > 0 {
334 let time = unix_time_millis();
335 let _ = self.write(Record {
336 time,
337 local_time: local_time_string(time).unwrap_or_default(),
338 level: Level::Info,
339 module: "diagnostics".into(),
340 stage: "retention".into(),
341 mapping_session_id: "-".into(),
342 message: format!(
343 "removed {} obsolete or expired legacy log file(s)",
344 report.removed
345 ),
346 });
347 }
348 if !report.failures.is_empty() {
349 let time = unix_time_millis();
350 let failure_count = report.failures.len();
351 let details = report
352 .failures
353 .iter()
354 .take(5)
355 .cloned()
356 .collect::<Vec<_>>()
357 .join("; ");
358 let _ = self.write(Record {
359 time,
360 local_time: local_time_string(time).unwrap_or_default(),
361 level: Level::Warn,
362 module: "diagnostics".into(),
363 stage: "retention".into(),
364 mapping_session_id: "-".into(),
365 message: format!("could not clean {failure_count} legacy log file(s): {details}"),
366 });
367 }
368 }
369
370 fn recent(&self) -> Vec<Record> {
371 self.state
372 .lock()
373 .unwrap_or_else(|poisoned| poisoned.into_inner())
374 .recent
375 .iter()
376 .cloned()
377 .collect()
378 }
379
380 fn rotate(&self) -> io::Result<()> {
381 let oldest = rotated_path(&self.path, ROLLING_FILE_COUNT - 1);
382 if oldest.exists() {
383 fs::remove_file(&oldest)?;
384 }
385 for index in (1..ROLLING_FILE_COUNT - 1).rev() {
386 let source = rotated_path(&self.path, index);
387 if source.exists() {
388 fs::rename(source, rotated_path(&self.path, index + 1))?;
389 }
390 }
391 if self.path.exists() {
392 fs::rename(&self.path, rotated_path(&self.path, 1))?;
393 }
394 Ok(())
395 }
396}
397
398fn cleanup_obsolete_logs(directory: &Path, now: SystemTime) -> CleanupReport {
399 let mut report = CleanupReport::default();
400 let entries = match fs::read_dir(directory) {
401 Ok(entries) => entries,
402 Err(error) => {
403 report
404 .failures
405 .push(format!("{}: {error}", directory.display()));
406 return report;
407 }
408 };
409
410 for entry in entries {
411 let entry = match entry {
412 Ok(entry) => entry,
413 Err(error) => {
414 report.failures.push(error.to_string());
415 continue;
416 }
417 };
418 let file_type = match entry.file_type() {
419 Ok(file_type) => file_type,
420 Err(error) => {
421 report
422 .failures
423 .push(format!("{}: {error}", entry.path().display()));
424 continue;
425 }
426 };
427 if !file_type.is_file() {
428 continue;
429 }
430 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
431 continue;
432 };
433
434 let remove = is_obsolete_legacy_log(&name)
435 || (is_known_auxiliary_log(&name)
436 && match entry.metadata().and_then(|metadata| metadata.modified()) {
437 Ok(modified) => now
438 .duration_since(modified)
439 .is_ok_and(|age| age >= AUXILIARY_LOG_RETENTION),
440 Err(error) => {
441 report
442 .failures
443 .push(format!("{}: {error}", entry.path().display()));
444 false
445 }
446 });
447 if !remove {
448 continue;
449 }
450
451 match fs::remove_file(entry.path()) {
452 Ok(()) => report.removed += 1,
453 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
454 Err(error) => report
455 .failures
456 .push(format!("{}: {error}", entry.path().display())),
457 }
458 }
459 report
460}
461
462fn is_obsolete_legacy_log(name: &str) -> bool {
463 name.eq_ignore_ascii_case("latest.log")
464 || name.eq_ignore_ascii_case("error.log")
465 || exact_timestamped_log(name, "SBMS-")
466}
467
468fn is_known_auxiliary_log(name: &str) -> bool {
469 if name.eq_ignore_ascii_case("sunshine-actions.log") {
470 return true;
471 }
472 exact_timestamped_log(name, "setup-")
473 || [
474 "elevated-",
475 "native-smoke-",
476 "programfiles-sync-",
477 "ab-driver-verify-",
478 ]
479 .iter()
480 .any(|prefix| timestamp_suffixed_log(name, prefix))
481}
482
483fn exact_timestamped_log(name: &str, prefix: &str) -> bool {
484 let Some(stem) = name
485 .strip_prefix(prefix)
486 .and_then(|value| value.strip_suffix(".log"))
487 else {
488 return false;
489 };
490 is_log_timestamp(stem)
491}
492
493fn timestamp_suffixed_log(name: &str, prefix: &str) -> bool {
494 let Some(stem) = name
495 .strip_prefix(prefix)
496 .and_then(|value| value.strip_suffix(".log"))
497 else {
498 return false;
499 };
500 stem.get(stem.len().saturating_sub(15)..)
501 .is_some_and(is_log_timestamp)
502}
503
504fn is_log_timestamp(value: &str) -> bool {
505 value.len() == 15
506 && value.as_bytes()[8] == b'-'
507 && value
508 .bytes()
509 .enumerate()
510 .all(|(index, byte)| index == 8 || byte.is_ascii_digit())
511}
512
513fn encode_line(record: &mut Record, max_bytes: usize) -> io::Result<Vec<u8>> {
514 let mut line = serde_json::to_vec(record).map_err(io::Error::other)?;
515 line.push(b'\n');
516 if line.len() <= max_bytes {
517 return Ok(line);
518 }
519
520 record.message.clear();
521 let fixed_bytes = serde_json::to_vec(record)
522 .map_err(io::Error::other)?
523 .len()
524 .saturating_add(1);
525 if fixed_bytes > max_bytes {
526 return Err(io::Error::new(
527 io::ErrorKind::InvalidInput,
528 "maximum log file size is too small for a diagnostic record",
529 ));
530 }
531 Ok(serde_json::to_vec(record)
532 .map_err(io::Error::other)?
533 .into_iter()
534 .chain(std::iter::once(b'\n'))
535 .collect())
536}
537
538fn rotated_path(path: &Path, index: usize) -> PathBuf {
539 let mut name = path
540 .file_name()
541 .map(OsString::from)
542 .unwrap_or_else(|| OsString::from("sbms.log"));
543 name.push(format!(".{index}"));
544 path.with_file_name(name)
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(1);
552
553 fn test_path(name: &str) -> PathBuf {
554 std::env::temp_dir()
555 .join(format!(
556 "sbms-diagnostics-{}-{}",
557 std::process::id(),
558 NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed)
559 ))
560 .join(name)
561 }
562
563 fn record(index: usize, level: Level) -> Record {
564 Record {
565 time: index as u64,
566 local_time: String::new(),
567 level,
568 module: "test".into(),
569 stage: "write".into(),
570 mapping_session_id: "1-1".into(),
571 message: format!("record-{index}-{}", "x".repeat(96)),
572 }
573 }
574
575 #[test]
576 fn ring_buffer_keeps_the_latest_records() {
577 let path = test_path("sbms.log");
578 let diagnostics = Diagnostics::open(path.clone(), 1024 * 1024, 200).unwrap();
579 for index in 0..225 {
580 diagnostics.write(record(index, Level::Info)).unwrap();
581 }
582 let recent = diagnostics.recent();
583 assert_eq!(recent.len(), 200);
584 assert_eq!(recent.first().unwrap().time, 25);
585 assert_eq!(recent.last().unwrap().time, 224);
586 drop(diagnostics);
587 fs::remove_dir_all(path.parent().unwrap()).unwrap();
588 }
589
590 #[test]
591 fn old_records_without_local_time_remain_compatible() {
592 let record: Record = serde_json::from_str(
593 r#"{"time":1,"level":"INFO","module":"test","stage":"read","mapping_session_id":"-","message":"legacy"}"#,
594 )
595 .unwrap();
596
597 assert_eq!(record.time, 1);
598 assert!(record.local_time.is_empty());
599 }
600
601 #[test]
602 fn local_time_uses_a_readable_system_time_shape() {
603 let time = local_time_string(unix_time_millis()).unwrap();
604
605 assert_eq!(time.len(), 23);
606 assert_eq!(&time[4..5], "-");
607 assert_eq!(&time[10..11], " ");
608 assert_eq!(&time[19..20], ".");
609 }
610
611 #[test]
612 fn rotation_keeps_three_bounded_files() {
613 let path = test_path("sbms.log");
614 let diagnostics = Diagnostics::open(path.clone(), 512, 200).unwrap();
615 for index in 0..30 {
616 diagnostics.write(record(index, Level::Error)).unwrap();
617 }
618 drop(diagnostics);
619
620 for candidate in [&path, &rotated_path(&path, 1), &rotated_path(&path, 2)] {
621 assert!(candidate.is_file());
622 assert!(candidate.metadata().unwrap().len() <= 512);
623 }
624 assert!(!rotated_path(&path, 3).exists());
625 fs::remove_dir_all(path.parent().unwrap()).unwrap();
626 }
627
628 #[test]
629 fn rotation_coordinates_multiple_diagnostics_instances() {
630 let path = test_path("sbms.log");
631 let first = Diagnostics::open(path.clone(), 512, 200).unwrap();
632 let second = Diagnostics::open(path.clone(), 512, 200).unwrap();
633 for index in 0..30 {
634 let diagnostics = if index % 2 == 0 { &first } else { &second };
635 diagnostics.write(record(index, Level::Info)).unwrap();
636 }
637 let mut latest = record(31, Level::Info);
638 latest.message = "latest-after-cross-process-rotation".into();
639 first.write(latest).unwrap();
640
641 assert!(
642 fs::read_to_string(&path)
643 .unwrap()
644 .contains("latest-after-cross-process-rotation")
645 );
646 for candidate in [&path, &rotated_path(&path, 1), &rotated_path(&path, 2)] {
647 assert!(candidate.metadata().unwrap().len() <= 512);
648 }
649 assert!(!rotated_path(&path, 3).exists());
650 drop((first, second));
651 fs::remove_dir_all(path.parent().unwrap()).unwrap();
652 }
653
654 #[test]
655 fn legacy_cleanup_removes_only_explicit_obsolete_names() {
656 let path = test_path("sbms.log");
657 let directory = path.parent().unwrap();
658 fs::create_dir_all(directory).unwrap();
659 for name in [
660 "SBMS-20260727-054400.log",
661 "latest.log",
662 "error.log",
663 "setup-20260813-120000.log",
664 "SBMS-not-a-timestamp.log",
665 "SBMS-user-20260727-054400.log",
666 "user-notes.log",
667 "sbms.log",
668 "sbms.log.1",
669 "sbms.log.2",
670 ] {
671 fs::write(directory.join(name), name).unwrap();
672 }
673
674 let report = cleanup_obsolete_logs(directory, SystemTime::now());
675
676 assert_eq!(report.removed, 3);
677 assert!(report.failures.is_empty());
678 for name in [
679 "setup-20260813-120000.log",
680 "SBMS-not-a-timestamp.log",
681 "SBMS-user-20260727-054400.log",
682 "user-notes.log",
683 "sbms.log",
684 "sbms.log.1",
685 "sbms.log.2",
686 ] {
687 assert!(directory.join(name).is_file(), "{name} should be preserved");
688 }
689 fs::remove_dir_all(directory).unwrap();
690 }
691
692 #[test]
693 fn auxiliary_cleanup_applies_thirty_day_retention() {
694 let path = test_path("sbms.log");
695 let directory = path.parent().unwrap();
696 fs::create_dir_all(directory).unwrap();
697 for name in [
698 "setup-20260629-220847.log",
699 "elevated-gui-start-smoke-063-20260629-122148.log",
700 "native-smoke-20260629-043731.log",
701 "programfiles-sync-20260629-043721.log",
702 "ab-driver-verify-20260629-043305.log",
703 "sunshine-actions.log",
704 "unknown-20260629-043305.log",
705 "sbms.log",
706 ] {
707 fs::write(directory.join(name), name).unwrap();
708 }
709 let after_retention = SystemTime::now() + AUXILIARY_LOG_RETENTION + Duration::from_secs(1);
710
711 let report = cleanup_obsolete_logs(directory, after_retention);
712
713 assert_eq!(report.removed, 6);
714 assert!(report.failures.is_empty());
715 assert!(directory.join("unknown-20260629-043305.log").is_file());
716 assert!(directory.join("sbms.log").is_file());
717 fs::remove_dir_all(directory).unwrap();
718 }
719
720 #[test]
721 fn error_is_visible_without_dropping_the_writer() {
722 let path = test_path("sbms.log");
723 let diagnostics = Diagnostics::open(path.clone(), 1024 * 1024, 200).unwrap();
724 diagnostics.write(record(1, Level::Info)).unwrap();
725 diagnostics.write(record(2, Level::Error)).unwrap();
726
727 let text = fs::read_to_string(&path).unwrap();
728 assert!(text.contains("\"level\":\"ERROR\""));
729 assert!(text.contains("record-2"));
730 drop(diagnostics);
731 fs::remove_dir_all(path.parent().unwrap()).unwrap();
732 }
733
734 #[test]
735 fn persisted_recent_skips_malformed_lines_and_keeps_the_latest_records() {
736 let path = test_path("sbms.log");
737 fs::create_dir_all(path.parent().unwrap()).unwrap();
738 let mut contents = String::new();
739 for index in 0..205 {
740 contents.push_str(&serde_json::to_string(&record(index, Level::Info)).unwrap());
741 contents.push('\n');
742 }
743 contents.push_str("not-json\n");
744 fs::write(&path, contents).unwrap();
745
746 let records = {
747 let _guard = CrossProcessLogGuard::acquire().unwrap();
748 let mut records = VecDeque::with_capacity(DEFAULT_MEMORY_RECORDS);
749 let file = fs::File::open(&path).unwrap();
750 for line in BufReader::new(file).lines() {
751 let line = line.unwrap();
752 let Ok(record) = serde_json::from_str::<Record>(&line) else {
753 continue;
754 };
755 if records.len() == DEFAULT_MEMORY_RECORDS {
756 records.pop_front();
757 }
758 records.push_back(record);
759 }
760 records
761 };
762
763 assert_eq!(records.len(), 200);
764 assert_eq!(records.front().unwrap().time, 5);
765 assert_eq!(records.back().unwrap().time, 204);
766 fs::remove_dir_all(path.parent().unwrap()).unwrap();
767 }
768
769 #[test]
770 fn mapping_session_ids_are_unique() {
771 assert_ne!(new_mapping_session_id(), new_mapping_session_id());
772 }
773}
774