Skip to main content

stackpulse/spool/
tail.rs

1use std::fs::File;
2use std::io;
3use std::ops::Range;
4use std::path::Path;
5use std::sync::Arc;
6
7use memmap2::Mmap;
8use nix::fcntl::{fallocate, FallocateFlags};
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use crate::native_module::ExactImageStore;
12
13use super::{
14    decode_spool_record, invalid_data, next_source_id, DecodedSpoolRecord, MmapSpoolCursor,
15    SampleRecord, SampleStack, SpoolDefinitions, ThreadRecord,
16};
17
18const MAX_BATCH_SAMPLES: usize = 16 * 1024;
19
20struct SpoolDiscarder {
21    file: File,
22    position: usize,
23}
24
25/// Incremental reader for an append-only StackPulse spool.
26///
27/// Each complete record is decoded once. A record cut off at the visible end
28/// of the file is retried after the writer appends the rest of it.
29/// Definitions are retained for the life of the reader, while sample storage
30/// is bounded and reused between polls.
31pub struct Tail {
32    file: File,
33    discarder: Option<SpoolDiscarder>,
34    mmap: Arc<Mmap>,
35    position: usize,
36    definitions: SpoolDefinitions,
37    threads: Vec<ThreadRecord>,
38    last_timestamp_ns: u64,
39    first_sample_timestamp_ns: Option<u64>,
40    samples: Vec<SampleRecord>,
41    initial_samples_pending: bool,
42    observed_processes: Vec<crate::Pid>,
43    observed_process_set: FxHashSet<crate::Pid>,
44    retired_processes: Vec<crate::Pid>,
45    retired_modules: Vec<u32>,
46    active_modules_by_process: FxHashMap<crate::Pid, Vec<usize>>,
47    kernel_mappings_changed: bool,
48    more_available: bool,
49    exact_images: Option<ExactImageStore>,
50}
51
52impl std::fmt::Debug for Tail {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        formatter
55            .debug_struct("Tail")
56            .field("position", &self.position)
57            .field("modules", &self.definitions.modules.len())
58            .field("frames", &self.definitions.frames.len())
59            .field("pending_samples", &self.samples.len())
60            .finish_non_exhaustive()
61    }
62}
63
64/// Samples and definition changes decoded by one [`Tail::poll`].
65///
66/// The batch borrows reusable storage from its reader. Dropping it permits the
67/// next poll to clear that storage while retaining its capacity.
68pub struct TailBatch<'a> {
69    tail: &'a Tail,
70    modules: Range<usize>,
71    frames: Range<usize>,
72}
73
74impl std::fmt::Debug for TailBatch<'_> {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter
77            .debug_struct("TailBatch")
78            .field("samples", &self.tail.samples.len())
79            .field("modules", &self.modules.len())
80            .field("frames", &self.frames.len())
81            .field("kernel_mappings_changed", &self.kernel_mappings_changed())
82            .finish()
83    }
84}
85
86impl Tail {
87    /// Open a growing spool and decode its current complete prefix.
88    ///
89    /// The writer must flush the spool header before this call.
90    ///
91    /// # Errors
92    ///
93    /// Returns a corrupt-spool error for malformed input, or an I/O category
94    /// when the file cannot be opened or mapped.
95    pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
96        Self::open_inner(path).map_err(crate::Error::spool)
97    }
98
99    fn open_inner(path: impl AsRef<Path>) -> io::Result<Self> {
100        Self::from_file_inner(File::open(path)?, None, None)
101    }
102
103    pub(crate) fn from_recorder(
104        file: File,
105        discarder: File,
106        exact_images: ExactImageStore,
107    ) -> crate::Result<Self> {
108        Self::from_file_inner(file, Some(discarder), Some(exact_images))
109            .map_err(crate::Error::spool)
110    }
111
112    fn from_file_inner(
113        file: File,
114        discarder: Option<File>,
115        exact_images: Option<ExactImageStore>,
116    ) -> io::Result<Self> {
117        // SAFETY: writers do not modify the unread prefix represented by a
118        // mapping. Decoded module paths are copied, cursors do not outlive a
119        // parse, and `discard_consumed` requires exclusive access to Tail
120        // before it changes bytes that no later parse will read.
121        let mmap = Arc::new(unsafe { Mmap::map(&file)? });
122        let mut cursor = MmapSpoolCursor::new(Arc::clone(&mmap));
123        cursor.check_magic()?;
124        let start_timestamp_us = cursor.read_varint::<u64>()?;
125        let sample_interval_us = cursor.read_varint::<u64>()?;
126        let position = cursor.position;
127        let mut tail = Self {
128            file,
129            discarder: discarder.map(|file| SpoolDiscarder { file, position: 0 }),
130            mmap,
131            position,
132            definitions: SpoolDefinitions {
133                source_id: next_source_id(),
134                start_timestamp_us,
135                sample_interval_us,
136                modules: Vec::new(),
137                frames: Vec::new(),
138                frame_contexts: super::SpoolFrameModuleContexts::default(),
139                stack_nodes: Vec::new(),
140                python_runtime_records: Vec::new(),
141                truncated_tail: false,
142            },
143            threads: Vec::new(),
144            last_timestamp_ns: 0,
145            first_sample_timestamp_ns: None,
146            samples: Vec::new(),
147            initial_samples_pending: true,
148            observed_processes: Vec::new(),
149            observed_process_set: FxHashSet::default(),
150            retired_processes: Vec::new(),
151            retired_modules: Vec::new(),
152            active_modules_by_process: FxHashMap::default(),
153            kernel_mappings_changed: false,
154            more_available: false,
155            exact_images,
156        };
157        tail.more_available = tail.parse_available()?;
158
159        // A symbolizer built before the first poll starts from all definitions
160        // decoded above. Mapping additions therefore need no update, while
161        // retirements remain in the first batch so their resources can be
162        // released after its historical samples are resolved.
163        tail.kernel_mappings_changed = false;
164        Ok(tail)
165    }
166
167    /// Configure a symbolizer bound to this growing spool.
168    ///
169    /// Call [`crate::Symbolizer::update`] with every batch before resolving
170    /// that batch's stacks.
171    #[must_use]
172    pub fn symbolizer(&self) -> crate::SymbolizerBuilder<'_> {
173        crate::SymbolizerBuilder::for_tail(self)
174    }
175
176    /// Decode records appended since the previous poll.
177    ///
178    /// The first poll returns samples that were already complete when the tail
179    /// was opened. Later polls return only newly appended samples.
180    ///
181    /// The returned batch borrows storage owned by this reader. A poll with no
182    /// appended records performs no allocation once the reader is warm.
183    /// Call [`TailBatch::has_more`] to determine whether another complete
184    /// batch may already be visible. It does not indicate whether the writer
185    /// is still active.
186    ///
187    /// # Errors
188    ///
189    /// Returns a corrupt-spool error for malformed appended data. Shrinking
190    /// the opened file is rejected because tailing supports append only.
191    pub fn poll(&mut self) -> crate::Result<TailBatch<'_>> {
192        self.poll_inner().map_err(crate::Error::spool)
193    }
194
195    /// Release filesystem blocks occupied by complete records already decoded.
196    ///
197    /// This makes the spool disposable: it remains usable by this tail and its
198    /// writer, but can no longer be reopened or replayed from the beginning.
199    /// This is supported by tails created with [`crate::Recorder::tail`]; a
200    /// tail opened with [`Tail::open`] holds a read-only file descriptor.
201    ///
202    /// # Errors
203    ///
204    /// Returns an I/O error when the filesystem does not support hole punching
205    /// or the tail does not hold a writable file descriptor.
206    pub fn discard_consumed(&mut self) -> crate::Result<()> {
207        self.discard_consumed_inner().map_err(crate::Error::spool)
208    }
209
210    fn discard_consumed_inner(&mut self) -> io::Result<()> {
211        let discarder = self.discarder.as_mut().ok_or_else(|| {
212            io::Error::new(
213                io::ErrorKind::PermissionDenied,
214                "tail was not created by a recorder",
215            )
216        })?;
217        let page_size = usize::try_from(crate::elf::system_page_size())
218            .map_err(|_| invalid_data("system page size does not fit usize"))?;
219        let discard_end = self.position - self.position % page_size;
220        if discard_end <= discarder.position {
221            return Ok(());
222        }
223        let offset = libc::off_t::try_from(discarder.position)
224            .map_err(|_| invalid_data("spool discard offset is too large"))?;
225        let length = libc::off_t::try_from(discard_end - discarder.position)
226            .map_err(|_| invalid_data("spool discard length is too large"))?;
227        fallocate(
228            &discarder.file,
229            FallocateFlags::FALLOC_FL_PUNCH_HOLE | FallocateFlags::FALLOC_FL_KEEP_SIZE,
230            offset,
231            length,
232        )?;
233        discarder.position = discard_end;
234        Ok(())
235    }
236
237    fn poll_inner(&mut self) -> io::Result<TailBatch<'_>> {
238        let initial = std::mem::take(&mut self.initial_samples_pending);
239        let module_start = self.definitions.modules.len();
240        let frame_start = self.definitions.frames.len();
241
242        if initial {
243            let mapped_len = self.mmap.len();
244            self.remap_if_grown()?;
245            self.more_available |= self.mmap.len() > mapped_len;
246        } else {
247            self.samples.clear();
248            self.observed_processes.clear();
249            self.observed_process_set.clear();
250            self.retired_processes.clear();
251            self.retired_modules.clear();
252            self.kernel_mappings_changed = false;
253            self.remap_if_grown()?;
254            self.more_available = self.parse_available()?;
255        }
256
257        Ok(TailBatch {
258            tail: self,
259            modules: module_start..self.definitions.modules.len(),
260            frames: frame_start..self.definitions.frames.len(),
261        })
262    }
263
264    /// Return the profile timeline anchor in microseconds.
265    #[must_use]
266    pub fn start_timestamp_us(&self) -> Option<u64> {
267        (self.definitions.start_timestamp_us != 0).then_some(self.definitions.start_timestamp_us)
268    }
269
270    /// Return the optional sample interval metadata in microseconds.
271    #[must_use]
272    pub fn sample_interval_us(&self) -> Option<u64> {
273        (self.definitions.sample_interval_us != 0).then_some(self.definitions.sample_interval_us)
274    }
275
276    pub(crate) fn modules(&self) -> &[super::ModuleRecord] {
277        &self.definitions.modules
278    }
279
280    pub(crate) fn frames(&self) -> &[super::FrameRecord] {
281        &self.definitions.frames
282    }
283
284    fn timestamp_us(&self, sample: &SampleRecord) -> Option<u64> {
285        let anchor = self.start_timestamp_us()?;
286        let first = self
287            .first_sample_timestamp_ns
288            .unwrap_or(sample.timestamp_ns);
289        Some(anchor.saturating_add(sample.timestamp_ns.saturating_sub(first) / 1_000))
290    }
291
292    pub(crate) fn source_id(&self) -> u64 {
293        self.definitions.source_id
294    }
295
296    pub(crate) fn exact_images(&self) -> Option<ExactImageStore> {
297        self.exact_images.clone()
298    }
299
300    pub(crate) fn frame_module_contexts(&self) -> super::SpoolFrameModuleContexts {
301        self.definitions.frame_contexts.clone()
302    }
303
304    fn observe_process(&mut self, process: crate::Pid) {
305        if self.observed_processes.last() != Some(&process)
306            && self.observed_process_set.insert(process)
307        {
308            self.observed_processes.push(process);
309        }
310    }
311
312    fn remap_if_grown(&mut self) -> io::Result<()> {
313        let file_len = usize::try_from(self.file.metadata()?.len())
314            .map_err(|_| invalid_data("spool file is too large to map"))?;
315        if file_len < self.mmap.len() {
316            return Err(invalid_data("spool file shrank while being tailed"));
317        }
318        if file_len > self.mmap.len() {
319            // SAFETY: see `from_file_inner`; only the unread suffix of an old
320            // mapping is accessed, and that suffix is never modified.
321            self.mmap = Arc::new(unsafe { Mmap::map(&self.file)? });
322        }
323        Ok(())
324    }
325
326    fn parse_available(&mut self) -> io::Result<bool> {
327        let mut cursor = MmapSpoolCursor::at_position(Arc::clone(&self.mmap), self.position);
328        loop {
329            let record = match decode_spool_record(
330                &mut cursor,
331                &self.definitions.modules,
332                self.definitions.frames.len(),
333                &self.definitions.stack_nodes,
334                &self.threads,
335                &mut self.last_timestamp_ns,
336            ) {
337                Ok(Some(record)) => record,
338                Ok(None) => break,
339                Err(error) if error.kind() == io::ErrorKind::UnexpectedEof && cursor.at_eof() => {
340                    break;
341                }
342                Err(error) => return Err(error),
343            };
344            let ends_batch = matches!(
345                &record,
346                DecodedSpoolRecord::DeactivateProcess(_) | DecodedSpoolRecord::DeactivateModule(_)
347            );
348            match record {
349                DecodedSpoolRecord::Module(module) => {
350                    let process = module.pid();
351                    let module_index = self.definitions.modules.len();
352                    self.kernel_mappings_changed |= module.is_kernel();
353                    self.definitions.modules.push(module);
354                    self.definitions.frame_contexts.push_module();
355                    if let Some(process) = process {
356                        self.active_modules_by_process
357                            .entry(process)
358                            .or_default()
359                            .push(module_index);
360                        self.observe_process(process);
361                    }
362                }
363                DecodedSpoolRecord::Frame(frame) => {
364                    let module_limit = self.definitions.modules.len();
365                    self.definitions.frames.push(frame);
366                    self.definitions.frame_contexts.push_frame(module_limit);
367                }
368                DecodedSpoolRecord::Stack(stack) => self.definitions.stack_nodes.push(stack),
369                DecodedSpoolRecord::Thread(thread) => self.threads.push(thread),
370                DecodedSpoolRecord::Sample(sample) => {
371                    self.first_sample_timestamp_ns
372                        .get_or_insert(sample.timestamp_ns);
373                    self.observe_process(sample.process_id);
374                    self.samples.push(sample);
375                }
376                DecodedSpoolRecord::PythonRuntime(record) => {
377                    self.observe_process(record.process_id);
378                }
379                DecodedSpoolRecord::DeactivateProcess(process_id) => {
380                    for module_index in self
381                        .active_modules_by_process
382                        .remove(&process_id)
383                        .unwrap_or_default()
384                    {
385                        self.retired_modules
386                            .push(self.definitions.modules[module_index].id);
387                        self.definitions
388                            .frame_contexts
389                            .deactivate_module(module_index, self.definitions.frames.len());
390                    }
391                    self.observe_process(process_id);
392                    self.retired_processes.push(process_id);
393                }
394                DecodedSpoolRecord::DeactivateModule(module_id) => {
395                    let module = &self.definitions.modules[module_id];
396                    let process = module.pid();
397                    self.retired_modules.push(module.id);
398                    self.kernel_mappings_changed |= module.is_kernel();
399                    self.definitions
400                        .frame_contexts
401                        .deactivate_module(module_id, self.definitions.frames.len());
402                    if let Some(process) = process {
403                        if let Some(modules) = self.active_modules_by_process.get_mut(&process) {
404                            modules.retain(|&active| active != module_id);
405                            if modules.is_empty() {
406                                self.active_modules_by_process.remove(&process);
407                            }
408                        }
409                        self.observe_process(process);
410                    }
411                }
412            }
413            self.position = cursor.position;
414            if self.samples.len() == MAX_BATCH_SAMPLES || ends_batch {
415                return Ok(self.position < self.mmap.len());
416            }
417        }
418        Ok(false)
419    }
420}
421
422impl<'a> TailBatch<'a> {
423    #[cfg(test)]
424    pub(crate) fn samples(&self) -> &[SampleRecord] {
425        &self.tail.samples
426    }
427
428    /// Iterate over this poll's samples with borrowed raw frames.
429    pub fn stacks(&self) -> impl ExactSizeIterator<Item = SampleStack<'a>> + '_ {
430        self.tail
431            .samples
432            .iter()
433            .copied()
434            .map(|sample| self.tail.definitions.sample_stack(sample))
435    }
436
437    /// Return the process IDs observed in this batch.
438    ///
439    /// IDs are deduplicated in first-seen order and include sample, module,
440    /// Python-runtime, and mapping-retirement records.
441    #[must_use]
442    pub fn processes(&self) -> &[crate::Pid] {
443        &self.tail.observed_processes
444    }
445
446    /// Return whether another poll can decode an already-visible batch.
447    ///
448    /// Consumers should poll again without waiting when this is true. This
449    /// keeps each sample batch bounded when the writer gets ahead.
450    #[must_use]
451    pub fn has_more(&self) -> bool {
452        self.tail.more_available
453    }
454
455    pub(crate) fn modules(&self) -> &[super::ModuleRecord] {
456        &self.tail.definitions.modules[self.modules.clone()]
457    }
458
459    pub(crate) fn frames(&self) -> &[super::FrameRecord] {
460        &self.tail.definitions.frames[self.frames.clone()]
461    }
462
463    /// Convert a sample timestamp to the profile timeline in microseconds.
464    #[must_use]
465    pub fn timestamp_us(&self, sample: &SampleRecord) -> Option<u64> {
466        self.tail.timestamp_us(sample)
467    }
468
469    pub(crate) fn source_id(&self) -> u64 {
470        self.tail.definitions.source_id
471    }
472
473    pub(crate) fn all_modules(&self) -> &[super::ModuleRecord] {
474        &self.tail.definitions.modules
475    }
476
477    pub(crate) fn all_frames(&self) -> &[super::FrameRecord] {
478        &self.tail.definitions.frames
479    }
480
481    pub(crate) fn frame_module_contexts(&self) -> super::SpoolFrameModuleContexts {
482        self.tail.definitions.frame_contexts.clone()
483    }
484
485    pub(crate) fn retired_processes(&self) -> &[crate::Pid] {
486        &self.tail.retired_processes
487    }
488
489    pub(crate) fn retired_modules(&self) -> &[u32] {
490        &self.tail.retired_modules
491    }
492
493    pub(crate) fn frame_contexts_changed(&self) -> bool {
494        !self.modules.is_empty() || !self.frames.is_empty() || !self.tail.retired_modules.is_empty()
495    }
496
497    pub(crate) fn kernel_mappings_changed(&self) -> bool {
498        self.tail.kernel_mappings_changed
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use std::io::Write;
505
506    use super::*;
507    use crate::spool::{
508        FrameMode, FrameRecord, ModulePath, ModuleRecord, PerfSpoolWriter, Snapshot,
509    };
510    use crate::test_support::TempDir;
511
512    fn module() -> ModuleRecord {
513        ModuleRecord::new(
514            0,
515            crate::Pid::new(7).unwrap(),
516            0x1000..0x2000,
517            0,
518            ModulePath::from("/tmp/test.so"),
519        )
520        .unwrap()
521        .file_identity(0, 0, 1, 0)
522    }
523
524    fn frame() -> FrameRecord {
525        FrameRecord {
526            module_id: Some(0),
527            file_relative_ip: 0x10,
528            abs_ip: 0x1010,
529            mode: FrameMode::User,
530        }
531    }
532
533    fn assert_no_invalidation(invalidation: crate::symbolize::Invalidation<'_>) {
534        assert!(!invalidation.all());
535        assert!(invalidation.processes().next().is_none());
536    }
537
538    #[test]
539    fn polls_flushed_prefixes_and_matches_finished_reader() {
540        let dir = TempDir::new("tail-prefixes");
541        let path = dir.path().join("recording.spool");
542        let mut writer = PerfSpoolWriter::create(&path, 123, 10).unwrap();
543        writer.flush().unwrap();
544        let mut tail = Tail::open(&path).unwrap();
545        assert!(format!("{tail:?}").contains("pending_samples: 0"));
546        assert_eq!(tail.start_timestamp_us(), Some(123));
547        assert_eq!(tail.sample_interval_us(), Some(10));
548        let mut symbolizer = tail
549            .symbolizer()
550            .disable_perf_maps()
551            .stack_cache(crate::StackCache::External)
552            .build()
553            .unwrap();
554        let initial = tail.poll().unwrap();
555        assert!(initial.samples().is_empty());
556        assert!(!initial.has_more());
557        assert!(initial.processes().is_empty());
558        let other_tail = Tail::open(&path).unwrap();
559        let mut other_symbolizer = other_tail.symbolizer().build().unwrap();
560        let error = other_symbolizer
561            .update(&initial)
562            .err()
563            .expect("a symbolizer must reject batches from another tail");
564        assert_eq!(error.kind(), crate::ErrorKind::InvalidInput);
565        assert_no_invalidation(symbolizer.update(&initial).unwrap());
566
567        writer.write_module(&module()).unwrap();
568        writer.write_sample_frames(1_000, 7, 8, [frame()]).unwrap();
569        writer.write_python_runtime(1_500, 9, true).unwrap();
570        writer.flush().unwrap();
571        let first = tail.poll().unwrap();
572        assert!(format!("{first:?}").contains("samples: 1"));
573        assert_eq!(first.samples().len(), 1);
574        assert_eq!(
575            first.processes(),
576            &[crate::Pid::new(7).unwrap(), crate::Pid::new(9).unwrap()]
577        );
578        assert_eq!(first.timestamp_us(&first.samples()[0]), Some(123));
579        assert!(!symbolizer.update(&first).unwrap().all());
580        let native_backend = symbolizer.has_native_backend();
581        let mut stack = symbolizer.resolve(first.stacks().next().unwrap()).unwrap();
582        assert_eq!(stack.is_cacheable(), !native_backend);
583        assert!(stack.next_with_id().is_some());
584        assert!(stack.next().is_none());
585
586        writer.write_sample_frames(3_000, 7, 8, [frame()]).unwrap();
587        writer.write_module_deactivation(7).unwrap();
588        writer.flush().unwrap();
589        let second = tail.poll().unwrap();
590        assert_eq!(second.timestamp_us(&second.samples()[0]), Some(125));
591        symbolizer.update(&second).unwrap();
592        assert_eq!(
593            symbolizer
594                .resolve(second.stacks().next().unwrap())
595                .unwrap()
596                .len(),
597            1
598        );
599
600        let after_retirement = tail.poll().unwrap();
601        let invalidation = symbolizer.update(&after_retirement).unwrap();
602        let pid = crate::Pid::new(7).unwrap();
603        assert_eq!(invalidation.processes().collect::<Vec<_>>(), vec![pid]);
604        assert!(invalidation.affects_process(pid));
605        drop(writer);
606
607        let finished = Snapshot::open(&path).unwrap();
608        assert_eq!(tail.modules(), finished.modules());
609        assert_eq!(tail.frames(), finished.frames());
610        assert_eq!(finished.samples().len(), 2);
611        assert_eq!(finished.python_runtime_records().len(), 1);
612
613        let mut append = std::fs::OpenOptions::new()
614            .append(true)
615            .open(&path)
616            .unwrap();
617        append.write_all(&[u8::MAX]).unwrap();
618        append.flush().unwrap();
619        let error = tail.poll().unwrap_err();
620        assert_eq!(error.kind(), crate::ErrorKind::CorruptSpool);
621    }
622
623    #[test]
624    fn retries_a_sample_cut_off_mid_record_without_double_delta() {
625        let dir = TempDir::new("tail-partial-sample");
626        let path = dir.path().join("recording.spool");
627        let mut writer = PerfSpoolWriter::from_writer(Vec::new(), 0, 10).unwrap();
628        writer
629            .write_sample_frames(
630                1_000,
631                7,
632                8,
633                [FrameRecord {
634                    module_id: None,
635                    file_relative_ip: 0,
636                    abs_ip: 0x1010,
637                    mode: FrameMode::User,
638                }],
639            )
640            .unwrap();
641        let bytes = writer.into_inner();
642        let split = bytes.len() - 1;
643        std::fs::write(&path, &bytes[..split]).unwrap();
644
645        let mut tail = Tail::open(&path).unwrap();
646        assert!(tail.poll().unwrap().samples().is_empty());
647        let mut append = std::fs::OpenOptions::new()
648            .append(true)
649            .open(&path)
650            .unwrap();
651        append.write_all(&bytes[split..]).unwrap();
652        append.flush().unwrap();
653        {
654            let batch = tail.poll().unwrap();
655            let mut stacks = batch.stacks();
656            let stack = stacks.next().expect("expected one sample");
657            assert!(stacks.next().is_none());
658            assert_eq!(stack.sample().timestamp_ns, 1_000);
659        }
660
661        append.set_len(8).unwrap();
662        let error = tail.poll().unwrap_err();
663        assert_eq!(error.kind(), crate::ErrorKind::CorruptSpool);
664    }
665
666    #[test]
667    fn discards_consumed_prefix_and_continues_tailing() {
668        let dir = TempDir::new("tail-discard");
669        let path = dir.path().join("recording.spool");
670        let mut writer = PerfSpoolWriter::create(&path, 0, 1).unwrap();
671        let frame = FrameRecord {
672            module_id: None,
673            file_relative_ip: 0,
674            abs_ip: 0x1000,
675            mode: FrameMode::User,
676        };
677        for index in 0..20_000_u64 {
678            writer
679                .write_sample_frames(index + 1, 7, 8, [frame])
680                .unwrap();
681        }
682        writer.flush().unwrap();
683        let file = writer.open_reader().unwrap();
684        let discarder = writer.open_discarder().unwrap();
685        file.sync_all().unwrap();
686        let mut tail = Tail::from_file_inner(file, Some(discarder), None).unwrap();
687        {
688            let first = tail.poll().unwrap();
689            assert_eq!(first.samples().len(), MAX_BATCH_SAMPLES);
690        }
691
692        if let Err(error) = tail.discard_consumed() {
693            assert_eq!(error.kind(), crate::ErrorKind::Unsupported);
694            return;
695        }
696        tail.discard_consumed().unwrap();
697
698        {
699            let second = tail.poll().unwrap();
700            assert_eq!(second.samples().len(), 20_000 - MAX_BATCH_SAMPLES);
701        }
702
703        writer.write_sample_frames(20_001, 7, 8, [frame]).unwrap();
704        writer.flush().unwrap();
705        {
706            let appended = tail.poll().unwrap();
707            assert_eq!(appended.samples().len(), 1);
708            assert_eq!(appended.samples()[0].timestamp_ns, 20_001);
709        }
710
711        assert!(super::super::Replay::open(&path).is_err());
712    }
713}