1use std::sync::atomic::{AtomicBool, Ordering};
21
22use crate::buffer::{BufferMode, Disk, SegmentWriter};
23use crate::clock;
24use crate::handle::Dial9Handle;
25use crate::primitives::sync::Arc;
26use crate::recording::{Recorder, RecordingStartHook};
27use crate::shared_state::SharedState;
28use crate::source::Source;
29
30type RecordingThreadHook = Arc<dyn Fn() -> Box<dyn FnOnce() + Send> + Send + Sync>;
34
35fn noop_thread_hook() -> RecordingThreadHook {
36 Arc::new(|| Box::new(|| {}) as Box<dyn FnOnce() + Send>)
37}
38
39pub(crate) fn merge_segment_metadata(
43 existing: &mut Vec<(String, String)>,
44 entries: impl IntoIterator<Item = (String, String)>,
45) {
46 let incoming: Vec<(String, String)> = entries.into_iter().collect();
47 existing.retain(|(k, _)| !incoming.iter().any(|(ik, _)| ik == k));
48 existing.extend(incoming);
49}
50
51pub fn recorder<M: BufferMode>(writer: SegmentWriter<M>) -> RecorderBuilder<M> {
56 builder_with(Some(writer))
57}
58
59pub fn recorder_or_disabled<M: BufferMode>(
67 writer: std::io::Result<SegmentWriter<M>>,
68) -> RecorderBuilder<M> {
69 match writer {
70 Ok(writer) => builder_with(Some(writer)),
71 Err(e) => {
72 tracing::error!(
73 target: "dial9_telemetry",
74 "dial9: trace writer setup failed; running without telemetry: {e}"
75 );
76 builder_with(None)
77 }
78 }
79}
80
81fn builder_with<M: BufferMode>(writer: Option<SegmentWriter<M>>) -> RecorderBuilder<M> {
82 RecorderBuilder {
83 writer,
84 sources: Vec::new(),
85 recording_start_hooks: Vec::new(),
86 segment_metadata: Vec::new(),
87 metrics_sink: None,
88 thread_init: noop_thread_hook(),
89 #[cfg(feature = "pipeline")]
90 pipeline: None,
91 #[cfg(feature = "pipeline")]
92 terminal_processor: None,
93 #[cfg(feature = "pipeline")]
94 worker_poll_interval: None,
95 #[cfg(feature = "pipeline")]
96 trigger: None,
97 #[cfg(feature = "pipeline")]
98 pending_dump_trigger: None,
99 enabled: true,
100 }
101}
102
103pub fn recorder_disabled() -> crate::recording::Recorder {
110 crate::recording::Recorder::new(crate::handle::Dial9Handle::disabled(), None)
111}
112
113#[derive(Debug)]
115pub(crate) struct SoleRecorderGuard;
116
117impl SoleRecorderGuard {
118 pub(crate) fn claim() -> Option<Self> {
123 match cfg!(feature = "test-util") || !RECORDER_TAKEN.swap(true, Ordering::SeqCst) {
124 true => Some(Self),
125 false => None,
126 }
127 }
128}
129
130impl Drop for SoleRecorderGuard {
131 fn drop(&mut self) {
132 RECORDER_TAKEN.store(false, Ordering::SeqCst);
133 }
134}
135
136static RECORDER_TAKEN: AtomicBool = AtomicBool::new(false);
137
138#[cfg(feature = "pipeline")]
145fn default_pipeline(
146 source_stages: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
147 terminal: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
148 is_disk: bool,
149) -> Vec<Box<dyn crate::pipeline::SegmentProcessor>> {
150 if source_stages.is_empty() && terminal.is_none() {
151 return Vec::new();
152 }
153 let mut processors = source_stages;
154 processors.push(Box::new(crate::worker::processors::GzipCompressor));
155 match terminal {
156 Some(terminal) => processors.push(terminal),
157 None if is_disk => processors.push(Box::new(
158 crate::worker::processors::WriteBackProcessor::default(),
159 )),
160 None => {}
161 }
162 processors
163}
164
165#[must_use = "call `.build()` to start recording"]
167pub struct RecorderBuilder<M: BufferMode = Disk> {
168 writer: Option<SegmentWriter<M>>,
171 sources: Vec<Box<dyn Source>>,
172 recording_start_hooks: Vec<RecordingStartHook>,
173 segment_metadata: Vec<(String, String)>,
174 metrics_sink: Option<metrique::writer::BoxEntrySink>,
175 thread_init: RecordingThreadHook,
176 #[cfg(feature = "pipeline")]
179 pipeline: Option<Vec<Box<dyn crate::pipeline::SegmentProcessor>>>,
180 #[cfg(feature = "pipeline")]
184 terminal_processor: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
185 #[cfg(feature = "pipeline")]
186 worker_poll_interval: Option<std::time::Duration>,
187 #[cfg(feature = "pipeline")]
188 trigger: Option<crate::dump::DumpRx>,
189 #[cfg(feature = "pipeline")]
192 pending_dump_trigger: Option<crate::dump::DumpTrigger>,
193 enabled: bool,
196}
197
198impl<M: BufferMode> std::fmt::Debug for RecorderBuilder<M> {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct("RecorderBuilder")
201 .field("sources", &self.sources.len())
202 .finish_non_exhaustive()
203 }
204}
205
206impl<M: BufferMode> RecorderBuilder<M> {
207 pub fn source(mut self, source: impl Source + 'static) -> Self {
209 self.sources.push(Box::new(source));
210 self
211 }
212
213 pub fn source_names(&self) -> impl Iterator<Item = &str> + '_ {
215 self.sources.iter().map(|s| s.name())
216 }
217
218 pub fn writer_boot_id(&self) -> Option<&str> {
221 self.writer.as_ref()?.boot_id()
222 }
223
224 pub fn segment_metadata(mut self, entries: impl IntoIterator<Item = (String, String)>) -> Self {
245 merge_segment_metadata(&mut self.segment_metadata, entries);
246 self
247 }
248
249 pub fn metrics_sink(mut self, sink: metrique::writer::BoxEntrySink) -> Self {
252 self.metrics_sink = Some(sink);
253 self
254 }
255
256 pub fn build(self) -> Recorder {
265 #[allow(unused_mut)]
266 let Some(mut writer) = self.writer else {
267 return recorder_disabled();
268 };
269
270 let Some(sole_recorder) = SoleRecorderGuard::claim() else {
271 tracing::error!(
272 target: "dial9",
273 "dial9: this process already has a recorder, this one will run without telemetry."
274 );
275 return recorder_disabled();
276 };
277
278 let shared = Arc::new(SharedState::new(clock::clock_monotonic_ns()));
279
280 #[cfg(feature = "pipeline")]
283 if let Some(trigger) = self.pending_dump_trigger {
284 shared.set_dump_trigger(trigger);
285 }
286
287 let mut segment_metadata = self.segment_metadata;
291 if let Some(boot_id) = writer.boot_id().map(str::to_owned) {
292 for (key, value) in &mut segment_metadata {
293 if key == "boot_id" {
294 *value = boot_id.clone();
295 }
296 }
297 }
298 if !segment_metadata.is_empty() {
299 writer.update_segment_metadata(segment_metadata);
300 }
301
302 #[allow(unused_mut)]
303 let mut sources = self.sources;
304
305 #[cfg(feature = "pipeline")]
308 let processors = match self.pipeline {
309 Some(processors) => processors,
310 None => {
311 let source_stages = sources
312 .iter_mut()
313 .filter_map(|source| source.segment_processor())
314 .collect();
315 default_pipeline(source_stages, self.terminal_processor, M::IS_DISK)
316 }
317 };
318
319 for source in sources {
320 shared.push_source(source);
321 }
322
323 #[cfg(feature = "pipeline")]
326 let worker = if processors.is_empty() {
327 None
328 } else {
329 let poll = self
330 .worker_poll_interval
331 .unwrap_or(crate::worker::DEFAULT_POLL_INTERVAL);
332 let metrics = self
333 .metrics_sink
334 .clone()
335 .unwrap_or_else(metrique::writer::sink::DevNullSink::boxed);
336 let config = crate::worker::BackgroundTaskConfig::builder()
337 .maybe_trace_dir(M::IS_DISK.then(|| writer.trace_dir().to_path_buf()))
338 .maybe_trace_stem(M::IS_DISK.then(|| writer.trace_stem().to_string()))
339 .poll_interval(poll)
340 .processors(processors)
341 .metrics_sink(metrics)
342 .maybe_trigger(self.trigger)
343 .build();
344 let (tx, rx) = tokio::sync::oneshot::channel();
345 let hook = self.thread_init.clone();
346 crate::worker::spawn(&writer, config, rx, move || hook())
347 .map(|wt| crate::recording::WorkerHandle::new(tx, wt))
348 };
349
350 let hook = self.thread_init.clone();
351 #[allow(unused_mut)]
352 let mut recorder = Recorder::start(shared, writer, self.metrics_sink, move || hook());
353 recorder.hold_process(sole_recorder);
354
355 #[cfg(feature = "pipeline")]
356 if let Some(worker) = worker {
357 recorder.attach_worker(worker);
358 }
359
360 recorder.set_recording_start_hooks(self.recording_start_hooks);
361
362 if self.enabled {
363 recorder.enable();
364 }
365 recorder
366 }
367
368 pub fn paused(mut self) -> Self {
374 self.enabled = false;
375 self
376 }
377}
378
379pub trait RecorderSourceExt: Sized {
387 fn source(self, source: impl Source + 'static) -> Self;
389
390 fn on_recording_start(self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self;
393
394 fn on_recording_thread_start<F, T>(self, hook: F) -> Self
398 where
399 F: Fn() -> T + Send + Sync + 'static,
400 T: FnOnce() + Send + 'static;
401
402 fn with_custom_events<F>(
407 self,
408 config: crate::custom_events::CustomEventsConfig,
409 callback: F,
410 ) -> Self
411 where
412 F: for<'a> FnMut(&mut crate::custom_events::CustomEventsContext<'a>) + Send + 'static,
413 {
414 self.source(crate::custom_events::CustomEventsSource::new(
415 config, callback,
416 ))
417 }
418}
419
420impl<M: BufferMode> RecorderSourceExt for RecorderBuilder<M> {
421 fn source(mut self, source: impl Source + 'static) -> Self {
422 self.sources.push(Box::new(source));
423 self
424 }
425
426 fn on_recording_start(mut self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self {
427 self.recording_start_hooks.push(Box::new(hook));
428 self
429 }
430
431 fn on_recording_thread_start<F, T>(mut self, hook: F) -> Self
432 where
433 F: Fn() -> T + Send + Sync + 'static,
434 T: FnOnce() + Send + 'static,
435 {
436 self.thread_init = Arc::new(move || Box::new(hook()) as Box<dyn FnOnce() + Send>);
437 self
438 }
439}
440
441#[cfg(feature = "pipeline")]
442impl<M: BufferMode> RecorderBuilder<M> {
443 pub fn pipe(mut self, processor: impl crate::pipeline::SegmentProcessor + 'static) -> Self {
446 self.pipeline
447 .get_or_insert_default()
448 .push(Box::new(processor));
449 self
450 }
451
452 pub fn processors(
456 mut self,
457 processors: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
458 ) -> Self {
459 self.pipeline = Some(processors);
460 self
461 }
462
463 pub fn terminal_processor(
471 mut self,
472 processor: impl crate::pipeline::SegmentProcessor + 'static,
473 ) -> Self {
474 self.terminal_processor = Some(Box::new(processor));
475 self
476 }
477
478 pub fn worker_poll_interval(mut self, interval: std::time::Duration) -> Self {
480 self.worker_poll_interval = Some(interval);
481 self
482 }
483
484 pub fn trigger(mut self, trigger: crate::dump::DumpRx) -> Self {
487 self.trigger = Some(trigger);
488 self
489 }
490
491 pub fn with_dump_trigger<F>(mut self, configure: F) -> Self
498 where
499 F: FnOnce(&mut crate::dump::DumpTriggerConfig),
500 {
501 let mut config = crate::dump::DumpTriggerConfig::new();
502 configure(&mut config);
503 let (mut trigger, rx) = crate::dump::channel();
504 if let Some(window) = config.debounce_window() {
505 trigger = trigger.with_debounce(window);
506 }
507 self.trigger = Some(rx);
508 self.pending_dump_trigger = Some(trigger);
509 self
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::buffer::{DiskBuffer, MemoryBuffer};
517 use crate::source::FlushContext;
518 use dial9_trace_format::TraceEvent;
519 use dial9_trace_format::decoder::Decoder;
520 use std::path::{Path, PathBuf};
521 use std::time::Duration;
522
523 #[derive(Debug, serde::Deserialize, TraceEvent)]
524 struct TestEvent {
525 #[traceevent(timestamp)]
526 timestamp_ns: u64,
527 value: u64,
528 }
529
530 struct OnceSource {
532 emitted: bool,
533 value: u64,
534 }
535
536 impl Source for OnceSource {
537 fn flush(&mut self, ctx: &FlushContext<'_>) {
538 if !self.emitted {
539 self.emitted = true;
540 ctx.record_event(&TestEvent {
541 timestamp_ns: clock::clock_monotonic_ns(),
542 value: self.value,
543 });
544 }
545 }
546 fn name(&self) -> &'static str {
547 "once"
548 }
549 }
550
551 fn sealed_segment(dir: &Path) -> PathBuf {
552 std::fs::read_dir(dir)
553 .expect("trace dir readable")
554 .filter_map(|e| e.ok().map(|e| e.path()))
555 .find(|p| {
556 let name = p.file_name().unwrap().to_string_lossy();
557 name.ends_with(".bin") && !name.ends_with(".active")
558 })
559 .expect("a sealed .bin segment")
560 }
561
562 fn decoded_test_values(bytes: &[u8]) -> Vec<u64> {
563 let mut decoder = Decoder::new(bytes).expect("valid trace header");
564 let mut values = Vec::new();
565 decoder
566 .for_each_event(|raw| {
567 if raw.name == "TestEvent" {
568 let event: TestEvent = raw.deserialize().expect("TestEvent decodes");
569 values.push(event.value);
570 }
571 })
572 .expect("decode events");
573 values
574 }
575
576 #[test]
579 fn records_source_events_to_disk() {
580 let dir = tempfile::tempdir().expect("tempdir");
581 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
582
583 let recorder = recorder(writer)
584 .segment_metadata([("service".to_string(), "recorder-test".to_string())])
585 .source(OnceSource {
586 emitted: false,
587 value: 7,
588 })
589 .build();
590 recorder.graceful_shutdown(Duration::ZERO);
591
592 let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
593 assert!(
594 decoded_test_values(&bytes).contains(&7),
595 "the source's event should round-trip through the trace file"
596 );
597 }
598
599 #[test]
601 fn build_starts_recording() {
602 let writer = MemoryBuffer::new(1 << 20).expect("writer");
603 let recorder = recorder(writer).build();
604 assert!(
605 recorder.shared().expect("live recorder").is_enabled(),
606 "build() must start recording"
607 );
608 }
609
610 #[test]
612 fn paused_build_waits_for_enable() {
613 let writer = MemoryBuffer::new(1 << 20).expect("writer");
614 let recorder = recorder(writer).paused().build();
615 assert!(
616 !recorder.shared().expect("live recorder").is_enabled(),
617 "paused() must leave recording off"
618 );
619 assert!(
621 !recorder.handle().is_enabled(),
622 "a paused handle must report disabled"
623 );
624 assert!(
625 recorder.handle().shared().is_some(),
626 "a paused handle is still connected"
627 );
628 recorder.enable();
629 assert!(
630 recorder.shared().expect("live recorder").is_enabled(),
631 "recording on after enable()"
632 );
633 assert!(
634 recorder.handle().is_enabled(),
635 "handle enabled after enable()"
636 );
637 }
638
639 #[test]
642 fn on_recording_start_runs_once_when_recording_starts() {
643 use std::sync::Arc as StdArc;
644 use std::sync::atomic::{AtomicUsize, Ordering};
645
646 let runs = StdArc::new(AtomicUsize::new(0));
647 let runs_hook = StdArc::clone(&runs);
648 let live = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
649 .on_recording_start(move |_handle| {
650 runs_hook.fetch_add(1, Ordering::SeqCst);
651 })
652 .build();
653 assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at build");
654 live.enable();
655 assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at most once");
656 drop(live);
657
658 let paused_runs = StdArc::new(AtomicUsize::new(0));
659 let paused_hook = StdArc::clone(&paused_runs);
660 let paused = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
661 .on_recording_start(move |_handle| {
662 paused_hook.fetch_add(1, Ordering::SeqCst);
663 })
664 .paused()
665 .build();
666 assert_eq!(
667 paused_runs.load(Ordering::SeqCst),
668 0,
669 "paused build must not run the hook yet"
670 );
671 paused.enable();
672 assert_eq!(paused_runs.load(Ordering::SeqCst), 1, "hook runs on enable");
673 }
674
675 #[cfg(feature = "pipeline")]
678 #[test]
679 fn pipe_runs_the_background_worker() {
680 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
681 use std::future::Future;
682 use std::pin::Pin;
683 use std::sync::Arc as StdArc;
684 use std::sync::atomic::{AtomicUsize, Ordering};
685
686 #[derive(Debug)]
687 struct CountingProcessor(StdArc<AtomicUsize>);
688 impl SegmentProcessor for CountingProcessor {
689 fn name(&self) -> &'static str {
690 "Counting"
691 }
692 fn process(
693 &mut self,
694 data: SegmentData,
695 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
696 {
697 self.0.fetch_add(1, Ordering::SeqCst);
698 Box::pin(async move { Ok(data) })
699 }
700 }
701
702 let dir = tempfile::tempdir().expect("tempdir");
703 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
704 let processed = StdArc::new(AtomicUsize::new(0));
705
706 let recorder = recorder(writer)
707 .source(OnceSource {
708 emitted: false,
709 value: 11,
710 })
711 .pipe(CountingProcessor(StdArc::clone(&processed)))
712 .build();
713 recorder.graceful_shutdown(Duration::from_secs(5));
714
715 assert!(
716 processed.load(Ordering::SeqCst) >= 1,
717 "the background worker should process the sealed segment"
718 );
719 }
720
721 #[test]
722 fn segment_metadata_merges_last_key_wins() {
723 let mut md = vec![
724 ("service".to_string(), "checkout".to_string()),
725 ("region".to_string(), "us-east-1".to_string()),
726 ];
727 super::merge_segment_metadata(
728 &mut md,
729 [
730 ("region".to_string(), "eu-west-1".to_string()),
731 ("bucket".to_string(), "traces".to_string()),
732 ],
733 );
734
735 assert!(md.contains(&("service".to_string(), "checkout".to_string())));
736 assert!(md.contains(&("region".to_string(), "eu-west-1".to_string())));
737 assert!(md.contains(&("bucket".to_string(), "traces".to_string())));
738 assert!(
739 !md.iter().any(|(k, v)| k == "region" && v == "us-east-1"),
740 "the colliding key's old value must be gone"
741 );
742 }
743 #[cfg(feature = "pipeline")]
746 #[test]
747 fn source_stage_joins_the_default_pipeline() {
748 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
749 use std::future::Future;
750 use std::pin::Pin;
751 use std::sync::Arc as StdArc;
752 use std::sync::atomic::{AtomicUsize, Ordering};
753
754 struct MarkerStage(StdArc<AtomicUsize>);
755 impl SegmentProcessor for MarkerStage {
756 fn name(&self) -> &'static str {
757 "Marker"
758 }
759 fn process(
760 &mut self,
761 data: SegmentData,
762 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
763 {
764 self.0.fetch_add(1, Ordering::SeqCst);
765 Box::pin(async move { Ok(data) })
766 }
767 }
768
769 struct StagedSource(StdArc<AtomicUsize>);
771 impl Source for StagedSource {
772 fn flush(&mut self, _ctx: &FlushContext<'_>) {}
773 fn name(&self) -> &'static str {
774 "staged"
775 }
776 fn segment_processor(&mut self) -> Option<Box<dyn SegmentProcessor>> {
777 Some(Box::new(MarkerStage(StdArc::clone(&self.0))))
778 }
779 }
780
781 let dir = tempfile::tempdir().expect("tempdir");
782 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
783 let ran = StdArc::new(AtomicUsize::new(0));
784
785 let recorder = recorder(writer)
786 .source(StagedSource(StdArc::clone(&ran)))
787 .source(OnceSource {
788 emitted: false,
789 value: 3,
790 })
791 .build();
792 recorder.graceful_shutdown(Duration::from_secs(5));
793
794 assert!(
795 ran.load(Ordering::SeqCst) >= 1,
796 "the source's stage should run in the default pipeline"
797 );
798 let gzipped = std::fs::read_dir(dir.path())
799 .expect("trace dir readable")
800 .filter_map(|e| e.ok().map(|e| e.path()))
801 .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
802 assert!(gzipped, "the default pipeline compresses and writes back");
803 }
804
805 #[cfg(feature = "pipeline")]
807 #[test]
808 fn default_pipeline_is_empty_without_stages() {
809 let dir = tempfile::tempdir().expect("tempdir");
810 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
811
812 let recorder = recorder(writer)
813 .source(OnceSource {
814 emitted: false,
815 value: 5,
816 })
817 .build();
818 recorder.graceful_shutdown(Duration::from_secs(5));
819
820 let compressed = std::fs::read_dir(dir.path())
821 .expect("trace dir readable")
822 .filter_map(|e| e.ok().map(|e| e.path()))
823 .any(|p| p.to_string_lossy().ends_with(".gz"));
824 assert!(
825 !compressed,
826 "no stages means no worker, so nothing is gzipped"
827 );
828 let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
830 assert_eq!(decoded_test_values(&bytes), vec![5]);
831 }
832
833 #[cfg(feature = "pipeline")]
836 #[test]
837 fn terminal_processor_replaces_write_back() {
838 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
839 use std::future::Future;
840 use std::pin::Pin;
841 use std::sync::Arc as StdArc;
842 use std::sync::atomic::{AtomicUsize, Ordering};
843
844 struct Uploader(StdArc<AtomicUsize>);
845 impl SegmentProcessor for Uploader {
846 fn name(&self) -> &'static str {
847 "Uploader"
848 }
849 fn process(
850 &mut self,
851 data: SegmentData,
852 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
853 {
854 self.0.fetch_add(1, Ordering::SeqCst);
855 Box::pin(async move { Ok(data) })
856 }
857 }
858
859 let dir = tempfile::tempdir().expect("tempdir");
860 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
861 let uploaded = StdArc::new(AtomicUsize::new(0));
862
863 let recorder = recorder(writer)
864 .source(OnceSource {
865 emitted: false,
866 value: 9,
867 })
868 .terminal_processor(Uploader(StdArc::clone(&uploaded)))
869 .build();
870 recorder.graceful_shutdown(Duration::from_secs(5));
871
872 assert!(
873 uploaded.load(Ordering::SeqCst) >= 1,
874 "the terminal stage runs on its own"
875 );
876 let written_back = std::fs::read_dir(dir.path())
877 .expect("trace dir readable")
878 .filter_map(|e| e.ok().map(|e| e.path()))
879 .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
880 assert!(!written_back, "the terminal stage takes write-back's place");
881 }
882
883 #[test]
884 fn recording_thread_hook_runs_on_dial9_threads() {
885 use std::sync::Arc as StdArc;
886 use std::sync::atomic::{AtomicUsize, Ordering};
887
888 fn build_and_shut_down<M: BufferMode + Send + 'static>(
889 writer: crate::buffer::SegmentWriter<M>,
890 ) -> (usize, usize) {
891 let started = StdArc::new(AtomicUsize::new(0));
892 let stopped = StdArc::new(AtomicUsize::new(0));
893 let (s, t) = (StdArc::clone(&started), StdArc::clone(&stopped));
894
895 let recorder =
896 RecorderSourceExt::on_recording_thread_start(recorder(writer), move || {
897 s.fetch_add(1, Ordering::SeqCst);
898 let t = StdArc::clone(&t);
899 move || {
900 t.fetch_add(1, Ordering::SeqCst);
901 }
902 })
903 .build();
904 recorder.graceful_shutdown(Duration::from_secs(5));
905
906 (
907 started.load(Ordering::SeqCst),
908 stopped.load(Ordering::SeqCst),
909 )
910 }
911
912 let (started, stopped) = build_and_shut_down(MemoryBuffer::new(64 * 1024).unwrap());
913 assert!(
914 started >= 1,
915 "the hook should run on the flush thread, ran {started} times"
916 );
917 assert_eq!(
918 started, stopped,
919 "every thread that ran the hook should run its teardown"
920 );
921 }
922}
923
924#[cfg(all(test, not(feature = "test-util")))]
925mod single_recorder_tests {
926 use super::*;
927 use crate::buffer::MemoryBuffer;
928
929 #[test]
930 fn a_second_recorder_in_the_process_is_refused() {
931 let first = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
932 assert!(first.handle().is_connected(), "the first one records");
933
934 let second = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
935 assert!(
936 !second.handle().is_connected(),
937 "the process already has a recorder, so this one is inert"
938 );
939
940 drop(second);
941 drop(first);
942
943 let after = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
944 assert!(
945 after.handle().is_connected(),
946 "the slot frees up once the holder stops"
947 );
948 }
949}