Skip to main content

dial9_perf_self_profile/
cpu_source.rs

1//! CPU and scheduler profiling as a [`dial9_core::source::Source`].
2//!
3//! Provides two [`Source`] implementations that feed CPU stack samples
4//! into the dial9 trace stream without any tokio dependency:
5//!
6//! - [`CpuProfiler`] — process-wide frequency-based CPU sampling.
7//! - [`SchedProfiler`] — per-worker-thread context-switch capture.
8
9use crate::{EventSource, PerfSampler, SamplerConfig, SamplingMode, is_ctimer_active};
10use dial9_core::encoder::{Encodable, ThreadLocalEncoder};
11use dial9_core::source::{FlushContext, Source};
12use dial9_trace_format::types::{EventEncoder, FieldType};
13use dial9_trace_format::{InternedStackFrames, InternedString, TraceEvent, TraceField};
14use std::collections::HashMap;
15use std::io::{self, Write};
16use std::sync::Arc;
17
18// ── Wire sentinel ───────────────────────────────────────────────────────────
19
20/// Worker ID sentinel used when the sample's worker is not yet known.
21/// Attribution happens at analysis time via tid ↔ park/unpark mapping.
22const WORKER_ID_UNKNOWN: u64 = 255;
23
24// ── CpuSampleSource ─────────────────────────────────────────────────────────
25
26/// What triggered a CPU sample.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum CpuSampleSource {
30    /// Periodic CPU profiling sample (frequency-based).
31    CpuProfile = 0,
32    /// Context switch captured by per-thread sched event tracking.
33    SchedEvent = 1,
34}
35
36impl TraceField for CpuSampleSource {
37    fn field_type() -> FieldType {
38        FieldType::U8
39    }
40    fn encode<W: Write>(&self, enc: &mut EventEncoder<'_, W>) -> io::Result<()> {
41        enc.write_u8(*self as u8)
42    }
43}
44
45// ── CpuSampleEvent (wire format) ────────────────────────────────────────────
46
47#[derive(TraceEvent)]
48#[traceevent(wire_slot)]
49struct CpuSampleEvent {
50    #[traceevent(timestamp)]
51    timestamp_ns: u64,
52    /// Worker ID on the wire. Always `WORKER_ID_UNKNOWN`; analysis resolves
53    /// worker attribution via tid ↔ park/unpark mapping.
54    worker_id: u64,
55    tid: u32,
56    source: CpuSampleSource,
57    thread_name: Option<InternedString>,
58    callchain: InternedStackFrames,
59    /// CPU the sample was taken on, if the backend could determine it.
60    ///
61    /// Widened to `u64` on the wire so the field encodes as `OptionalVarint`:
62    /// 1 byte when absent, typically 2 bytes (tag + small-varint) when present.
63    cpu: Option<u64>,
64}
65
66// ── Internal types ──────────────────────────────────────────────────────────
67
68/// Interned thread name, shared across drain calls so short-lived threads
69/// are captured before `/proc/self/task/<tid>/comm` disappears.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub(crate) struct ThreadName(Arc<str>);
72
73impl ThreadName {
74    fn new(name: String) -> Self {
75        Self(name.into())
76    }
77
78    fn as_str(&self) -> &str {
79        self.0.as_ref()
80    }
81}
82
83/// A raw CPU sample before worker-id resolution.
84pub(crate) struct RawCpuSample {
85    pub tid: u32,
86    pub timestamp_nanos: u64,
87    pub callchain: Vec<u64>,
88    pub source: CpuSampleSource,
89    pub cpu: Option<u32>,
90}
91
92/// Encodable wrapper for a raw sample. Interning of `thread_name` and
93/// `callchain` happens in [`Encodable::encode`] against the thread-local
94/// encoder's pools.
95struct CpuSampleData {
96    timestamp_nanos: u64,
97    tid: u32,
98    thread_name: Option<ThreadName>,
99    source: CpuSampleSource,
100    callchain: Vec<u64>,
101    cpu: Option<u32>,
102}
103
104impl Encodable for CpuSampleData {
105    fn encode(&self, enc: &mut ThreadLocalEncoder<'_>) {
106        let thread_name = self
107            .thread_name
108            .as_ref()
109            .map(|n| enc.intern_string(n.as_str()));
110        let callchain = enc.intern_stack_frames(&self.callchain);
111        enc.encode(&CpuSampleEvent {
112            timestamp_ns: self.timestamp_nanos,
113            worker_id: WORKER_ID_UNKNOWN,
114            tid: self.tid,
115            source: self.source,
116            thread_name,
117            callchain,
118            cpu: self.cpu.map(u64::from),
119        });
120    }
121}
122
123// ── Platform helper ─────────────────────────────────────────────────────────
124
125/// Read the thread name from `/proc/self/task/<tid>/comm`.
126/// Returns `None` if the file can't be read.
127pub(crate) fn read_thread_name(tid: u32) -> Option<String> {
128    std::fs::read_to_string(format!("/proc/self/task/{tid}/comm"))
129        .ok()
130        .map(|s| s.trim().to_string())
131        .filter(|s| !s.is_empty())
132}
133
134// ── Config types ────────────────────────────────────────────────────────────
135
136/// Which CPU profiling backend to use.
137///
138/// The backend determines how stack samples are collected. Each variant only
139/// exposes configuration knobs that the backend actually supports, making
140/// invalid combinations (e.g. ctimer + kernel stacks) unrepresentable.
141///
142/// # `DIAL9_FORCE_CTIMER` interaction
143///
144/// The `DIAL9_FORCE_CTIMER` environment variable is only respected by
145/// [`Auto`](CpuBackend::Auto). When [`Perf`](CpuBackend::Perf) or
146/// [`Ctimer`](CpuBackend::Ctimer) is specified explicitly, the env var is
147/// ignored — the caller has already made a deterministic choice.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub(crate) enum CpuBackend {
150    /// Try perf first; falls back to ctimer if `perf_event_open` is blocked.
151    Auto,
152    /// Perf backend via `perf_event_open`. Fails if perf is blocked.
153    Perf,
154    /// Ctimer backend (userspace frame-pointer unwinding via `SIGPROF`).
155    Ctimer,
156}
157
158/// Configuration for CPU profiling integration.
159///
160/// # Examples
161///
162/// ```ignore
163/// use dial9_perf_self_profile::{CpuProfilingConfig, EventSource};
164///
165/// // Default: try perf, fall back to ctimer:
166/// let config = CpuProfilingConfig::default();
167///
168/// // Explicit perf with kernel stacks:
169/// let config = CpuProfilingConfig::with_perf_backend()
170///     .event_source(EventSource::SwCpuClock)
171///     .include_kernel(true);
172///
173/// // Explicit ctimer:
174/// let config = CpuProfilingConfig::with_ctimer_backend();
175/// ```
176#[derive(Debug, Clone)]
177pub struct CpuProfilingConfig {
178    frequency_hz: u64,
179    backend: CpuBackend,
180    event_source: EventSource,
181    include_kernel: bool,
182}
183
184impl Default for CpuProfilingConfig {
185    fn default() -> Self {
186        Self {
187            frequency_hz: 99,
188            backend: CpuBackend::Auto,
189            event_source: EventSource::SwCpuClock,
190            include_kernel: false,
191        }
192    }
193}
194
195impl CpuProfilingConfig {
196    // ── Constructors ────────────────────────────────────────────────────────
197
198    /// Use the **perf** backend exclusively — no ctimer fallback.
199    ///
200    /// Fails at start time if `perf_event_open` is blocked. Use this when you
201    /// need capabilities only perf can provide (kernel stacks, hardware
202    /// counters, event-based sources).
203    pub fn with_perf_backend() -> Self {
204        Self {
205            backend: CpuBackend::Perf,
206            ..Self::default()
207        }
208    }
209
210    /// Use the **ctimer** backend exclusively — no perf attempt.
211    ///
212    /// Avoids perf's per-CPU inherited context overhead entirely. Only supports
213    /// frequency-based userspace CPU-time sampling (frame-pointer unwinding via
214    /// `SIGPROF`). Cannot capture kernel stacks or hardware events.
215    pub fn with_ctimer_backend() -> Self {
216        Self {
217            backend: CpuBackend::Ctimer,
218            ..Self::default()
219        }
220    }
221
222    // ── Setters ─────────────────────────────────────────────────────────────
223
224    /// Sampling frequency in Hz. Default: 99 (low overhead).
225    pub fn frequency_hz(mut self, hz: u64) -> Self {
226        self.frequency_hz = hz;
227        self
228    }
229
230    /// Which perf event source to sample on. Default: `SwCpuClock`.
231    ///
232    /// Ignored by [`Ctimer`](CpuBackend::Ctimer).
233    pub fn event_source(mut self, source: EventSource) -> Self {
234        self.event_source = source;
235        self
236    }
237
238    /// Whether to include kernel stack frames. Default: `false`.
239    ///
240    /// Ignored by [`Ctimer`](CpuBackend::Ctimer).
241    pub fn include_kernel(mut self, yes: bool) -> Self {
242        self.include_kernel = yes;
243        self
244    }
245}
246
247/// Configuration for per-thread sched event capture (context switches).
248///
249/// Uses `perf_event_open` with `SwContextSwitches` in per-thread mode, so each
250/// tracked thread gets its own perf fd: Tokio workers on their first poll/park,
251/// other threads when they call `Dial9Handle::track_current_thread`.
252#[derive(Debug, Clone, Default)]
253pub struct SchedEventConfig {
254    sampling_interval: Option<u64>,
255    include_kernel: bool,
256}
257
258impl SchedEventConfig {
259    /// Record every Nth context switch. Default records every event.
260    pub fn sampling_interval(mut self, n: u64) -> Self {
261        self.sampling_interval = Some(n);
262        self
263    }
264
265    /// Include kernel stack frames in callchains.
266    pub fn include_kernel(mut self, yes: bool) -> Self {
267        self.include_kernel = yes;
268        self
269    }
270}
271
272// ── CpuProfiler ─────────────────────────────────────────────────────────────
273
274/// Process-wide CPU profiler. Registers a `perf_event_open` sampler and
275/// drains raw stack traces into the trace stream on each flush cycle.
276///
277/// Worker attribution is left to analysis; each sample carries only its OS
278/// `tid`, which the viewer maps to a worker via park/unpark events.
279pub struct CpuProfiler {
280    sampler: PerfSampler,
281    pid: u32,
282    /// OS tid → thread name, eagerly cached at drain time so short-lived
283    /// threads are captured before they exit and their `comm` file disappears.
284    tid_to_name: HashMap<u32, ThreadName>,
285    /// Original config retained for segment metadata emission.
286    config: CpuProfilingConfig,
287    /// The effective backend that was selected after Auto resolution.
288    /// "perf" or "ctimer" — never "auto".
289    effective_backend: &'static str,
290    /// Whether segment metadata has been emitted yet (emit-once).
291    metadata_emitted: bool,
292}
293
294impl std::fmt::Debug for CpuProfiler {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        f.debug_struct("CpuProfiler")
297            .field("pid", &self.pid)
298            .field("backend", &self.effective_backend)
299            .finish_non_exhaustive()
300    }
301}
302
303impl CpuProfiler {
304    /// [`Source::name`] of the CPU profiler. The runtime builder keys off this
305    /// to decide whether the pipeline symbolizes.
306    pub const SOURCE_NAME: &'static str = "cpu_profile";
307
308    /// Start the process-wide CPU profiler with the given config.
309    pub fn start(config: CpuProfilingConfig) -> io::Result<Self> {
310        let (sampler, effective_backend) = match config.backend {
311            CpuBackend::Auto => {
312                let s = PerfSampler::start(
313                    SamplerConfig::default()
314                        .event_source(config.event_source)
315                        .sampling(SamplingMode::FrequencyHz(config.frequency_hz))
316                        .include_kernel(config.include_kernel),
317                )?;
318                // After Auto resolution, check which backend was actually selected.
319                let backend = if is_ctimer_active() { "ctimer" } else { "perf" };
320                (s, backend)
321            }
322            CpuBackend::Perf => {
323                let s = PerfSampler::start_perf_only(
324                    SamplerConfig::default()
325                        .event_source(config.event_source)
326                        .sampling(SamplingMode::FrequencyHz(config.frequency_hz))
327                        .include_kernel(config.include_kernel),
328                )?;
329                (s, "perf")
330            }
331            CpuBackend::Ctimer => {
332                let s = PerfSampler::start_ctimer_only(
333                    SamplerConfig::default()
334                        .sampling(SamplingMode::FrequencyHz(config.frequency_hz))
335                        .include_kernel(false),
336                )?;
337                (s, "ctimer")
338            }
339        };
340        Ok(Self {
341            sampler,
342            pid: std::process::id(),
343            tid_to_name: HashMap::new(),
344            config,
345            effective_backend,
346            metadata_emitted: false,
347        })
348    }
349
350    /// Drain all pending perf samples as raw (tid, callchain) tuples.
351    ///
352    /// Filters out child-process samples (perf `inherit` leaks them).
353    /// Eagerly caches thread names for non-worker tids.
354    pub(crate) fn drain(&mut self, mut f: impl FnMut(RawCpuSample, Option<&ThreadName>)) {
355        let pid = self.pid;
356        self.sampler.for_each_sample(|sample| {
357            if sample.pid != pid {
358                return;
359            }
360            if !self.tid_to_name.contains_key(&sample.tid)
361                && let Some(name) = read_thread_name(sample.tid)
362            {
363                self.tid_to_name.insert(sample.tid, ThreadName::new(name));
364            }
365            let thread_name = self.tid_to_name.get(&sample.tid);
366            f(
367                RawCpuSample {
368                    tid: sample.tid,
369                    timestamp_nanos: sample.time,
370                    callchain: sample.callchain.clone(),
371                    source: CpuSampleSource::CpuProfile,
372                    cpu: sample.cpu,
373                },
374                thread_name,
375            );
376        });
377    }
378}
379
380impl Source for CpuProfiler {
381    fn flush(&mut self, ctx: &FlushContext<'_>) {
382        self.drain(|raw, thread_name| {
383            // worker_id is always UNKNOWN; analysis attributes via tid.
384            ctx.record_event(&CpuSampleData {
385                timestamp_nanos: raw.timestamp_nanos,
386                tid: raw.tid,
387                source: raw.source,
388                callchain: raw.callchain,
389                thread_name: thread_name.cloned(),
390                cpu: raw.cpu,
391            });
392        });
393    }
394
395    /// Perf samples the whole process, so this only matters for the ctimer
396    /// fallback, which arms a timer per thread.
397    fn on_thread_start(&mut self) -> io::Result<()> {
398        crate::register_current_thread()
399    }
400
401    fn on_thread_stop(&mut self) {
402        crate::unregister_current_thread();
403    }
404
405    fn name(&self) -> &'static str {
406        Self::SOURCE_NAME
407    }
408
409    #[cfg(feature = "symbolize-processor")]
410    fn segment_processor(&mut self) -> Option<Box<dyn dial9_core::pipeline::SegmentProcessor>> {
411        Some(Box::new(crate::SymbolizeProcessor::new()))
412    }
413
414    fn segment_metadata(&mut self, out: &mut Vec<(String, String)>) {
415        if self.metadata_emitted {
416            return;
417        }
418        self.metadata_emitted = true;
419        out.push(("cpu.profile.enabled".to_string(), "true".to_string()));
420        out.push((
421            "cpu.profile.frequency_hz".to_string(),
422            self.config.frequency_hz.to_string(),
423        ));
424        // Report the *effective* backend (perf or ctimer), never "auto".
425        // The Auto variant resolves at construction time; self.effective_backend
426        // captures the actual selection.
427        out.push((
428            "cpu.profile.backend".to_string(),
429            self.effective_backend.to_string(),
430        ));
431        // When ctimer is the effective backend, it always samples thread CPU time
432        // (CLOCK_THREAD_CPUTIME_ID), regardless of what EventSource was
433        // originally requested. Report the *effective* source honestly.
434        #[allow(unreachable_patterns)]
435        let event_source_name = if self.effective_backend == "ctimer" {
436            "sw_cpu_clock"
437        } else {
438            match self.config.event_source {
439                EventSource::SwCpuClock => "sw_cpu_clock",
440                EventSource::SwTaskClock => "sw_task_clock",
441                EventSource::HwCpuCycles => "hw_cpu_cycles",
442                EventSource::SwContextSwitches => "sw_context_switches",
443                EventSource::Tracepoint(id) => {
444                    out.push((
445                        "cpu.profile.event_source".to_string(),
446                        format!("tracepoint:{id}"),
447                    ));
448                    return;
449                }
450                _ => "unknown",
451            }
452        };
453        out.push((
454            "cpu.profile.event_source".to_string(),
455            event_source_name.to_string(),
456        ));
457    }
458}
459
460// ── SchedProfiler ────────────────────────────────────────────────────────────
461
462/// Per-thread sched event profiler. Captures context switches for each
463/// thread that calls [`on_thread_start`](Source::on_thread_start).
464pub struct SchedProfiler {
465    sampler: PerfSampler,
466    /// Original config retained for segment metadata emission.
467    config: SchedEventConfig,
468    /// Whether segment metadata has been emitted yet (emit-once).
469    metadata_emitted: bool,
470}
471
472impl std::fmt::Debug for SchedProfiler {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        f.debug_struct("SchedProfiler")
475            .field("config", &self.config)
476            .finish_non_exhaustive()
477    }
478}
479
480impl SchedProfiler {
481    /// Create a new sched profiler with the given config.
482    pub fn new(config: SchedEventConfig) -> io::Result<Self> {
483        let sampler = PerfSampler::new_per_thread(
484            SamplerConfig::default()
485                .event_source(EventSource::SwContextSwitches)
486                .sampling(SamplingMode::Period(config.sampling_interval.unwrap_or(1)))
487                .include_kernel(config.include_kernel),
488        )?;
489        Ok(Self {
490            sampler,
491            config,
492            metadata_emitted: false,
493        })
494    }
495
496    pub(crate) fn track_current_thread(&mut self) -> io::Result<()> {
497        self.sampler.track_current_thread()
498    }
499
500    pub(crate) fn stop_tracking_current_thread(&mut self) {
501        self.sampler.stop_tracking_current_thread()
502    }
503
504    pub(crate) fn drain(&mut self, mut f: impl FnMut(RawCpuSample)) {
505        self.sampler.for_each_sample(|sample| {
506            f(RawCpuSample {
507                tid: sample.tid,
508                timestamp_nanos: sample.time,
509                callchain: sample.callchain.clone(),
510                source: CpuSampleSource::SchedEvent,
511                cpu: sample.cpu,
512            });
513        });
514    }
515}
516
517impl Source for SchedProfiler {
518    fn flush(&mut self, ctx: &FlushContext<'_>) {
519        self.drain(|raw| {
520            // worker_id left UNKNOWN; attributed by tid at analysis.
521            ctx.record_event(&CpuSampleData {
522                timestamp_nanos: raw.timestamp_nanos,
523                tid: raw.tid,
524                source: raw.source,
525                callchain: raw.callchain,
526                thread_name: None,
527                cpu: raw.cpu,
528            });
529        });
530    }
531
532    fn on_thread_start(&mut self) -> io::Result<()> {
533        self.track_current_thread()
534    }
535
536    fn on_thread_stop(&mut self) {
537        self.stop_tracking_current_thread();
538    }
539
540    fn name(&self) -> &'static str {
541        "sched"
542    }
543
544    fn segment_metadata(&mut self, out: &mut Vec<(String, String)>) {
545        if self.metadata_emitted {
546            return;
547        }
548        self.metadata_emitted = true;
549        out.push(("sched.profile.enabled".to_string(), "true".to_string()));
550        // Always report the effective sample interval. The default is 1
551        // (every context switch), matching the Period(1) used in construction.
552        let effective_interval = self.config.sampling_interval.unwrap_or(1);
553        out.push((
554            "sched.profile.sample_interval".to_string(),
555            effective_interval.to_string(),
556        ));
557    }
558}
559
560#[cfg(test)]
561mod cpu_sample_round_trip_tests {
562    use super::{CpuSampleEvent, CpuSampleSource, WORKER_ID_UNKNOWN};
563    use dial9_trace_format::decoder::{DecodedFrame, Decoder};
564    use dial9_trace_format::encoder::Encoder;
565    use dial9_trace_format::types::FieldValue;
566
567    /// Encode a `CpuSampleEvent` with the given `cpu` and decode it back to the
568    /// event frame's `(timestamp, field values)`.
569    fn round_trip(cpu: Option<u64>) -> (u64, Vec<FieldValue>) {
570        let mut enc = Encoder::new();
571        let thread_name = enc.intern_string("tokio-runtime-worker").unwrap();
572        let callchain = enc
573            .intern_stack_frames(&[0xdead_beef, 0xcafe_babe])
574            .unwrap();
575        enc.write(&CpuSampleEvent {
576            timestamp_ns: 7_000_000,
577            worker_id: WORKER_ID_UNKNOWN,
578            tid: 9999,
579            source: CpuSampleSource::CpuProfile,
580            thread_name: Some(thread_name),
581            callchain,
582            cpu,
583        })
584        .unwrap();
585        let bytes = enc.finish();
586
587        Decoder::new(&bytes)
588            .unwrap()
589            .decode_all()
590            .into_iter()
591            .find_map(|frame| match frame {
592                DecodedFrame::Event {
593                    timestamp_ns,
594                    values,
595                    ..
596                } => Some((timestamp_ns, values)),
597                _ => None,
598            })
599            .expect("event frame")
600    }
601
602    #[test]
603    fn cpu_sample_event_round_trips_with_cpu() {
604        let (timestamp_ns, values) = round_trip(Some(3));
605        assert_eq!(timestamp_ns, 7_000_000);
606        assert_eq!(values[1], FieldValue::Varint(9999)); // tid
607        // `cpu` is the last field; `Some(3)` encodes as an OptionalVarint.
608        assert_eq!(*values.last().unwrap(), FieldValue::Varint(3));
609    }
610
611    #[test]
612    fn cpu_sample_event_round_trips_without_cpu() {
613        let (_timestamp_ns, values) = round_trip(None);
614        // An absent `cpu` decodes as `FieldValue::None`.
615        assert_eq!(*values.last().unwrap(), FieldValue::None);
616    }
617}
618
619#[cfg(test)]
620mod metadata_tests {
621    use super::*;
622    use dial9_core::source::Source;
623
624    /// CpuProfiler::segment_metadata must report the effective backend
625    /// ("perf" or "ctimer"), never "auto", even when CpuBackend::Auto was
626    /// configured. This test runs on Linux where `start` resolves Auto.
627    #[test]
628    fn cpu_profiler_metadata_reports_effective_backend_not_auto() {
629        // CpuProfiler::start may fail in CI without perf access and without
630        // ctimer (rare). If it succeeds, the metadata must not say "auto".
631        let config = CpuProfilingConfig::default(); // backend = Auto
632        let Ok(mut profiler) = CpuProfiler::start(config) else {
633            // Can't start the profiler (e.g. kernel restrictions) — skip test.
634            eprintln!("skipping: CpuProfiler::start failed (likely no perf access)");
635            return;
636        };
637        let mut meta = Vec::new();
638        profiler.segment_metadata(&mut meta);
639
640        let backend = meta
641            .iter()
642            .find(|(k, _)| k == "cpu.profile.backend")
643            .expect("metadata must contain cpu.profile.backend");
644        assert!(
645            backend.1 == "perf" || backend.1 == "ctimer",
646            "effective backend must be 'perf' or 'ctimer', got '{}'",
647            backend.1
648        );
649        assert_ne!(
650            backend.1, "auto",
651            "metadata must never report 'auto' as the backend"
652        );
653
654        // Verify frequency is reported
655        let freq = meta
656            .iter()
657            .find(|(k, _)| k == "cpu.profile.frequency_hz")
658            .expect("metadata must contain cpu.profile.frequency_hz");
659        assert_eq!(freq.1, "99"); // default frequency
660
661        // Verify emit-once semantics
662        let mut meta2 = Vec::new();
663        profiler.segment_metadata(&mut meta2);
664        assert!(meta2.is_empty(), "metadata should not re-emit");
665    }
666
667    /// SchedProfiler::segment_metadata must always report the effective
668    /// sample_interval (default 1), even when None is configured.
669    #[test]
670    fn sched_profiler_metadata_reports_default_interval() {
671        // SchedProfiler::new requires perf_event_open. If it fails, skip.
672        let config = SchedEventConfig::default(); // sampling_interval = None
673        let Ok(mut profiler) = SchedProfiler::new(config) else {
674            eprintln!("skipping: SchedProfiler::new failed (likely no perf access)");
675            return;
676        };
677        let mut meta = Vec::new();
678        profiler.segment_metadata(&mut meta);
679
680        let interval = meta
681            .iter()
682            .find(|(k, _)| k == "sched.profile.sample_interval")
683            .expect("metadata must contain sched.profile.sample_interval");
684        assert_eq!(
685            interval.1, "1",
686            "effective default interval must be 1, got '{}'",
687            interval.1
688        );
689
690        // Verify emit-once semantics
691        let mut meta2 = Vec::new();
692        profiler.segment_metadata(&mut meta2);
693        assert!(meta2.is_empty(), "metadata should not re-emit");
694    }
695
696    /// SchedProfiler reports the user-configured interval when explicitly set.
697    #[test]
698    fn sched_profiler_metadata_reports_explicit_interval() {
699        let config = SchedEventConfig::default().sampling_interval(5);
700        let Ok(mut profiler) = SchedProfiler::new(config) else {
701            eprintln!("skipping: SchedProfiler::new failed (likely no perf access)");
702            return;
703        };
704        let mut meta = Vec::new();
705        profiler.segment_metadata(&mut meta);
706
707        let interval = meta
708            .iter()
709            .find(|(k, _)| k == "sched.profile.sample_interval")
710            .expect("metadata must contain sched.profile.sample_interval");
711        assert_eq!(
712            interval.1, "5",
713            "explicit interval must be reported, got '{}'",
714            interval.1
715        );
716    }
717
718    /// When the ctimer backend is active, the effective event source must always
719    /// be reported as `sw_cpu_clock`, regardless of the originally requested
720    /// EventSource. ctimer uses CLOCK_THREAD_CPUTIME_ID which is a CPU clock.
721    #[test]
722    fn ctimer_always_reports_cpu_clock_event_source() {
723        // Use the explicit Ctimer backend with a non-default event source to
724        // verify the override works.
725        let config =
726            CpuProfilingConfig::with_ctimer_backend().event_source(EventSource::HwCpuCycles); // would be "hw_cpu_cycles" if not overridden
727        let Ok(mut profiler) = CpuProfiler::start(config) else {
728            eprintln!("skipping: CpuProfiler::start(Ctimer) failed");
729            return;
730        };
731        let mut meta = Vec::new();
732        profiler.segment_metadata(&mut meta);
733
734        let backend = meta
735            .iter()
736            .find(|(k, _)| k == "cpu.profile.backend")
737            .expect("metadata must contain cpu.profile.backend");
738        assert_eq!(
739            backend.1, "ctimer",
740            "explicit ctimer backend must report 'ctimer'"
741        );
742
743        let event_source = meta
744            .iter()
745            .find(|(k, _)| k == "cpu.profile.event_source")
746            .expect("metadata must contain cpu.profile.event_source");
747        assert_eq!(
748            event_source.1, "sw_cpu_clock",
749            "ctimer backend must always report 'sw_cpu_clock' as event source, got '{}'",
750            event_source.1
751        );
752    }
753}