Skip to main content

running_process_probe/snapshot/
stream.rs

1//! Bounded sample sink: drop and count, never block (#635).
2//!
3//! # The app must not pay for a slow consumer
4//!
5//! Samples flow from the probe thread to the daemon. If the daemon reads
6//! slowly — or stops reading — the producer must keep running. Blocking would
7//! push the consumer's latency back into the profiled application, which
8//! inverts the entire point of a low-overhead probe: the observation would
9//! change the thing being observed, and a wedged daemon would become a wedged
10//! app.
11//!
12//! So the sink is bounded, and when it is full [`SampleSink::offer`] drops the
13//! sample and increments a counter rather than waiting. Dropping is a normal,
14//! *reported* outcome, not an error — a consumer that sees `dropped > 0` knows
15//! its view is incomplete and by how much.
16//!
17//! # Why counted rather than silent
18//!
19//! A profile missing samples looks exactly like a profile of a less busy
20//! program. Reporting the drop count is what lets a reader tell "the app was
21//! idle" from "we couldn't keep up".
22
23use std::collections::VecDeque;
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::{Arc, Mutex};
26
27/// Default queue depth.
28///
29/// Deep enough to absorb a brief consumer stall, shallow enough that a
30/// persistently slow consumer is reported quickly rather than hidden behind a
31/// large backlog — and that memory stays bounded regardless of producer rate.
32pub const DEFAULT_CAPACITY: usize = 256;
33
34/// What the sink has seen.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct SinkStats {
37    /// Samples accepted into the queue.
38    pub accepted: u64,
39    /// Samples dropped because the queue was full.
40    ///
41    /// Non-zero means the consumer fell behind and the stream is incomplete.
42    pub dropped: u64,
43}
44
45impl SinkStats {
46    /// Total samples offered, accepted or not.
47    pub fn offered(&self) -> u64 {
48        self.accepted + self.dropped
49    }
50
51    /// Whether every offered sample was accepted.
52    pub fn is_complete(&self) -> bool {
53        self.dropped == 0
54    }
55}
56
57/// A bounded, non-blocking sink shared between producer and consumer.
58///
59/// Cloning shares the same underlying queue.
60#[derive(Clone, Debug)]
61pub struct SampleSink<T> {
62    inner: Arc<Inner<T>>,
63}
64
65#[derive(Debug)]
66struct Inner<T> {
67    queue: Mutex<VecDeque<T>>,
68    capacity: usize,
69    accepted: AtomicU64,
70    dropped: AtomicU64,
71}
72
73impl<T> SampleSink<T> {
74    /// Create a sink holding at most `capacity` samples.
75    ///
76    /// A zero capacity is treated as one: a sink that can never accept
77    /// anything would report 100% drops and hide whether the consumer was ever
78    /// working.
79    pub fn with_capacity(capacity: usize) -> Self {
80        Self {
81            inner: Arc::new(Inner {
82                queue: Mutex::new(VecDeque::with_capacity(capacity.max(1))),
83                capacity: capacity.max(1),
84                accepted: AtomicU64::new(0),
85                dropped: AtomicU64::new(0),
86            }),
87        }
88    }
89
90    /// Offer a sample.
91    ///
92    /// Returns `true` if it was queued, `false` if it was dropped. Never
93    /// blocks waiting for the consumer — the whole contract of this type.
94    pub fn offer(&self, sample: T) -> bool {
95        let mut queue = match self.inner.queue.lock() {
96            Ok(q) => q,
97            // A poisoned lock means a consumer panicked. Count the sample as
98            // dropped rather than propagating a panic into the probe thread,
99            // which would take down the application being profiled.
100            Err(poisoned) => poisoned.into_inner(),
101        };
102
103        if queue.len() >= self.inner.capacity {
104            self.inner.dropped.fetch_add(1, Ordering::Relaxed);
105            return false;
106        }
107        queue.push_back(sample);
108        self.inner.accepted.fetch_add(1, Ordering::Relaxed);
109        true
110    }
111
112    /// Take everything queued so far.
113    pub fn drain(&self) -> Vec<T> {
114        let mut queue = match self.inner.queue.lock() {
115            Ok(q) => q,
116            Err(poisoned) => poisoned.into_inner(),
117        };
118        queue.drain(..).collect()
119    }
120
121    /// Samples currently queued.
122    pub fn len(&self) -> usize {
123        match self.inner.queue.lock() {
124            Ok(q) => q.len(),
125            Err(poisoned) => poisoned.into_inner().len(),
126        }
127    }
128
129    /// Whether nothing is queued.
130    pub fn is_empty(&self) -> bool {
131        self.len() == 0
132    }
133
134    /// Accepted and dropped counts.
135    pub fn stats(&self) -> SinkStats {
136        SinkStats {
137            accepted: self.inner.accepted.load(Ordering::Relaxed),
138            dropped: self.inner.dropped.load(Ordering::Relaxed),
139        }
140    }
141}
142
143impl<T> Default for SampleSink<T> {
144    fn default() -> Self {
145        Self::with_capacity(DEFAULT_CAPACITY)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::sync::atomic::AtomicBool;
153    use std::time::{Duration, Instant};
154
155    #[test]
156    fn accepts_up_to_capacity_then_drops() {
157        let sink = SampleSink::with_capacity(4);
158        for i in 0..4 {
159            assert!(sink.offer(i), "sample {i} should fit");
160        }
161        assert!(!sink.offer(99), "the 5th sample must be dropped");
162
163        let stats = sink.stats();
164        assert_eq!(stats.accepted, 4);
165        assert_eq!(stats.dropped, 1);
166        assert_eq!(stats.offered(), 5);
167        assert!(!stats.is_complete());
168    }
169
170    #[test]
171    fn draining_makes_room_again() {
172        let sink = SampleSink::with_capacity(2);
173        sink.offer(1);
174        sink.offer(2);
175        assert!(!sink.offer(3));
176
177        assert_eq!(sink.drain(), vec![1, 2]);
178        assert!(
179            sink.offer(4),
180            "a drained sink must accept again — drops are transient, not terminal"
181        );
182    }
183
184    #[test]
185    fn drain_preserves_order() {
186        let sink = SampleSink::with_capacity(8);
187        for i in 0..5 {
188            sink.offer(i);
189        }
190        assert_eq!(sink.drain(), vec![0, 1, 2, 3, 4]);
191    }
192
193    #[test]
194    fn zero_capacity_is_treated_as_one() {
195        let sink = SampleSink::with_capacity(0);
196        assert!(
197            sink.offer(1),
198            "a sink that can never accept would report 100% drops and hide \
199             whether the consumer ever worked"
200        );
201    }
202
203    /// The contract: a stalled consumer must not slow the producer.
204    #[test]
205    fn producer_never_blocks_on_a_stalled_consumer() {
206        let sink: SampleSink<u64> = SampleSink::with_capacity(8);
207
208        // Consumer never drains. Producer must still finish promptly.
209        let start = Instant::now();
210        for i in 0..10_000 {
211            sink.offer(i);
212        }
213        let elapsed = start.elapsed();
214
215        assert!(
216            elapsed < Duration::from_secs(2),
217            "producing 10k samples into a full sink took {elapsed:?}; offer() must not wait"
218        );
219
220        let stats = sink.stats();
221        assert_eq!(stats.accepted, 8, "only capacity should be retained");
222        assert_eq!(stats.dropped, 9_992);
223        assert_eq!(stats.offered(), 10_000, "every offer must be accounted for");
224    }
225
226    /// Throttled reader: drops are counted, nothing is lost silently.
227    #[test]
228    fn slow_consumer_causes_counted_drops_not_blocking() {
229        let sink: SampleSink<u64> = SampleSink::with_capacity(16);
230        let stop = Arc::new(AtomicBool::new(false));
231
232        let consumer = {
233            let sink = sink.clone();
234            let stop = Arc::clone(&stop);
235            std::thread::spawn(move || {
236                let mut seen = 0u64;
237                while !stop.load(Ordering::Relaxed) {
238                    seen += sink.drain().len() as u64;
239                    // Deliberately slower than the producer.
240                    std::thread::sleep(Duration::from_millis(5));
241                }
242                seen + sink.drain().len() as u64
243            })
244        };
245
246        for i in 0..5_000 {
247            sink.offer(i);
248        }
249        stop.store(true, Ordering::Relaxed);
250        let consumed = consumer.join().unwrap();
251
252        let stats = sink.stats();
253        assert_eq!(
254            stats.offered(),
255            5_000,
256            "accepted + dropped must equal what was offered"
257        );
258        assert!(
259            stats.dropped > 0,
260            "a consumer sleeping 5ms per batch cannot keep up with 5k offers"
261        );
262        assert!(
263            consumed <= stats.accepted,
264            "consumed {consumed} exceeds accepted {}",
265            stats.accepted
266        );
267    }
268
269    /// Cloned handles share one queue and one set of counters.
270    #[test]
271    fn clones_share_the_same_queue_and_counters() {
272        let a = SampleSink::with_capacity(4);
273        let b = a.clone();
274        a.offer(1);
275        b.offer(2);
276        assert_eq!(b.len(), 2);
277        assert_eq!(a.stats().accepted, 2);
278        assert_eq!(a.drain(), vec![1, 2]);
279        assert!(b.is_empty());
280    }
281
282    /// A panicking consumer must not take the producer with it.
283    #[test]
284    fn a_poisoned_lock_does_not_panic_the_producer() {
285        let sink: SampleSink<u64> = SampleSink::with_capacity(4);
286        let poisoner = {
287            let sink = sink.clone();
288            std::thread::spawn(move || {
289                let _guard = sink.inner.queue.lock().unwrap();
290                panic!("consumer died holding the lock");
291            })
292        };
293        assert!(poisoner.join().is_err(), "the helper thread should panic");
294
295        // The probe thread must keep working; taking down the profiled
296        // application because a consumer panicked would be unacceptable.
297        assert!(sink.offer(1));
298        assert_eq!(sink.stats().accepted, 1);
299    }
300}