Skip to main content

sipx_testkit/
soak.rs

1//! Proving nothing grows without bound.
2//!
3//! This is the failure that only appears in production, because it is the only one that needs
4//! hours to become visible. A stack that leaks a task per call is indistinguishable from a
5//! correct one for the length of any test somebody runs impatiently.
6//!
7//! **Flat, not merely bounded.** A leak that fills a pool is still a leak; the pool only hides
8//! it until it becomes a problem, and then hides the cause too. So the assertion is that the
9//! reading at the end matches the reading at the start, within a tolerance for the noise a
10//! runtime genuinely has — not that it stayed under some ceiling.
11//!
12//! **Measured after settling, and the settling period is longer than it looks.** Ending a call
13//! is not instantaneous, and the slow part is not teardown — it is the protocol. RFC 3261 §17
14//! keeps a completed server transaction alive for Timer J, 64·T1, **thirty-two seconds**, so it
15//! can absorb a retransmitted request. For that whole time there is a task per call that has
16//! ended, and it is doing exactly what the RFC requires.
17//!
18//! So a settle shorter than the longest transaction timer reports the specification as a leak.
19//! The first version of sipx's own soak used five seconds and duly failed with "tasks grew from
20//! 5 to 305" after 300 calls — a number that looks exactly like a one-task-per-call leak and was
21//! not one. [`SETTLE_PAST_TIMERS`] is the floor.
22
23use std::time::Duration;
24
25/// The shortest settling period that does not accuse the protocol of leaking.
26///
27/// RFC 3261's Timer J and Timer K are both 64·T1 — thirty-two seconds with the default T1 — and
28/// a completed transaction is *supposed* to sit there for that long. Forty seconds leaves room
29/// for a run whose last call ended a little after the load did.
30///
31/// A soak measured over a shorter period is measuring the RFC, and the result of measuring the
32/// RFC is a failing test that somebody eventually deletes.
33///
34/// This is a **definition of silence** in the sense `docs/designs/media.md` gives the term: how
35/// long a hole has to be before "everything that was going to end has ended" is true, so that what
36/// is still resident afterwards is a leak rather than the specification. It is not a bound on
37/// failure and it is not a measurement — a run that settles for longer is more trustworthy, not
38/// less, which is why the constant is a floor (`X-44`).
39pub const SETTLE_PAST_TIMERS: Duration = Duration::from_secs(40);
40
41/// A reading of the things that must not grow.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub struct Reading {
44    /// Tasks alive in the runtime.
45    pub tasks: usize,
46    /// File descriptors held by this process, which is where sockets show up.
47    pub descriptors: usize,
48    /// Whatever the stack under test counts as outstanding — transactions, dialogs, connections.
49    pub outstanding: usize,
50    /// Resident memory, in kilobytes.
51    ///
52    /// The dimension the other three cannot see. A session that grows a `Vec` for every packet
53    /// leaks steadily while its task count and transaction count stay perfectly flat, and that
54    /// is an ordinary shape for a leak — a recording buffer, a statistics history, a queue with
55    /// no bound.
56    pub resident_kb: usize,
57}
58
59/// How much drift is noise rather than a leak.
60///
61/// Not zero, and the reason is worth stating: a runtime keeps blocking-pool threads alive after
62/// use, an allocator does not return every page, and a descriptor may still be in `TIME_WAIT`.
63/// A tolerance of zero produces a test that fails at random, and a test that fails at random is
64/// a test that gets deleted.
65#[derive(Debug, Clone, Copy)]
66pub struct Tolerance {
67    /// Extra tasks allowed at the end.
68    pub tasks: usize,
69    /// Extra descriptors allowed.
70    pub descriptors: usize,
71    /// Extra outstanding items allowed.
72    pub outstanding: usize,
73    /// Extra resident kilobytes allowed.
74    ///
75    /// Much the largest tolerance here, and it has to be. An allocator does not return every
76    /// freed page to the kernel, a runtime grows its per-thread caches on first use, and the
77    /// first few hundred calls touch code paths that are still being faulted in. Sixteen
78    /// megabytes is loose enough not to fail on any of that and tight enough that a leak of a
79    /// kilobyte per call shows up within a few thousand calls.
80    pub resident_kb: usize,
81}
82
83impl Default for Tolerance {
84    fn default() -> Self {
85        // Small absolute numbers rather than percentages. A percentage of a small reading is
86        // less than one, and a percentage of a large one hides exactly the leak that matters.
87        Self {
88            tasks: 4,
89            descriptors: 8,
90            outstanding: 0,
91            resident_kb: 16 * 1024,
92        }
93    }
94}
95
96/// What a soak run found.
97#[derive(Debug, Clone)]
98pub struct Soak {
99    /// Before the load.
100    pub before: Reading,
101    /// After it, once things have settled.
102    pub after: Reading,
103    /// How long the run was given to settle before the second reading.
104    pub settled_for: Duration,
105}
106
107impl Soak {
108    /// Everything that grew beyond its tolerance, described.
109    ///
110    /// A list rather than a boolean, because "something leaked" is not an actionable report and
111    /// the whole point of separating the readings is to say *what*.
112    #[must_use]
113    pub fn leaks(&self, tolerance: Tolerance) -> Vec<String> {
114        let mut found = Vec::new();
115        for (what, before, after, allowed) in [
116            (
117                "tasks",
118                self.before.tasks,
119                self.after.tasks,
120                tolerance.tasks,
121            ),
122            (
123                "descriptors",
124                self.before.descriptors,
125                self.after.descriptors,
126                tolerance.descriptors,
127            ),
128            (
129                "outstanding",
130                self.before.outstanding,
131                self.after.outstanding,
132                tolerance.outstanding,
133            ),
134            (
135                "resident_kb",
136                self.before.resident_kb,
137                self.after.resident_kb,
138                tolerance.resident_kb,
139            ),
140        ] {
141            let grew = after.saturating_sub(before);
142            if grew > allowed {
143                found.push(format!(
144                    "{what} grew from {before} to {after} (+{grew}, tolerance {allowed})"
145                ));
146            }
147        }
148        found
149    }
150
151    /// Whether it is flat within tolerance.
152    #[must_use]
153    pub fn is_flat(&self, tolerance: Tolerance) -> bool {
154        self.leaks(tolerance).is_empty()
155    }
156
157    /// A report a person can read.
158    #[must_use]
159    pub fn report(&self, tolerance: Tolerance) -> String {
160        let leaks = self.leaks(tolerance);
161        if leaks.is_empty() {
162            return format!(
163                "flat after {:.0}s: tasks {}→{}, descriptors {}→{}, outstanding {}→{}, \
164                 resident {} kB→{} kB",
165                self.settled_for.as_secs_f64(),
166                self.before.tasks,
167                self.after.tasks,
168                self.before.descriptors,
169                self.after.descriptors,
170                self.before.outstanding,
171                self.after.outstanding,
172                self.before.resident_kb,
173                self.after.resident_kb
174            );
175        }
176        format!("leaked:\n  {}", leaks.join("\n  "))
177    }
178}
179
180/// How many file descriptors this process holds.
181///
182/// Linux only, through `/proc`. Elsewhere it reports zero, which the tolerance then trivially
183/// accepts — a soak run on another platform still checks tasks and outstanding items, and
184/// pretending to a descriptor count that was never taken would be worse than admitting to none.
185#[must_use]
186pub fn open_descriptors() -> usize {
187    std::fs::read_dir("/proc/self/fd").map_or(0, std::iter::Iterator::count)
188}
189
190/// Resident memory in kilobytes.
191///
192/// Linux only, from `/proc/self/statm`, whose second field is the resident set in pages.
193/// Elsewhere it reports zero and the tolerance trivially accepts it — a soak on another
194/// platform still checks the other three, and inventing a figure that was never measured would
195/// be worse than admitting to none.
196#[must_use]
197pub fn resident_kb() -> usize {
198    let Ok(statm) = std::fs::read_to_string("/proc/self/statm") else {
199        return 0;
200    };
201    let Some(pages) = statm.split_whitespace().nth(1) else {
202        return 0;
203    };
204    let Ok(pages) = pages.parse::<usize>() else {
205        return 0;
206    };
207    // 4 kB pages on every platform this runs on. Reading the real page size would mean a libc
208    // call for a number that has not changed on x86-64 or aarch64 Linux.
209    pages.saturating_mul(4)
210}
211
212/// The number of tasks alive in the current runtime.
213///
214/// `alive_tasks` is the runtime's own count and needs no bookkeeping from the caller, which
215/// matters: a count the test maintains itself would be counting the test's model of the
216/// system rather than the system.
217#[must_use]
218pub fn alive_tasks() -> usize {
219    tokio::runtime::Handle::try_current().map_or(0, |handle| handle.metrics().num_alive_tasks())
220}
221
222/// Take a reading of the process now.
223#[must_use]
224pub fn sample(outstanding: usize) -> Reading {
225    Reading {
226        tasks: alive_tasks(),
227        descriptors: open_descriptors(),
228        outstanding,
229        resident_kb: resident_kb(),
230    }
231}
232
233/// Run `load`, then wait for things to settle, and report what grew.
234///
235/// `outstanding` is asked twice — before and after — for whatever the stack under test counts
236/// as work in progress.
237/// `settle` should be at least [`SETTLE_PAST_TIMERS`] for anything driving SIP — see the module
238/// documentation for why a shorter one reports RFC-mandated state as a leak.
239pub async fn soak<L, Load, O, Count>(settle: Duration, outstanding: O, load: L) -> Soak
240where
241    L: FnOnce() -> Load,
242    Load: std::future::Future<Output = ()>,
243    // Async, because what is being counted usually lives behind an event loop. A synchronous
244    // bound forces the caller into `block_in_place` and a nested `block_on`, which panics
245    // outright on a current-thread runtime — an undocumented runtime-flavour requirement
246    // inherited by everyone who samples an async quantity.
247    O: Fn() -> Count,
248    Count: std::future::Future<Output = usize>,
249{
250    let before = sample(outstanding().await);
251    load().await;
252    // Settling is not optional, and it is not about teardown. A completed SIP transaction sits
253    // in `Completed` for Timer J — 64·T1, thirty-two seconds — absorbing retransmissions, which
254    // is what the RFC asks of it. Sampling before that has elapsed counts every one of those as
255    // a leaked task.
256    tokio::time::sleep(settle).await;
257    let after = sample(outstanding().await);
258    Soak {
259        before,
260        after,
261        settled_for: settle,
262    }
263}
264
265#[cfg(test)]
266#[allow(
267    clippy::unwrap_used,
268    clippy::expect_used,
269    clippy::panic,
270    clippy::indexing_slicing
271)]
272mod tests {
273    use super::*;
274
275    fn reading(tasks: usize, descriptors: usize, outstanding: usize) -> Reading {
276        Reading {
277            tasks,
278            descriptors,
279            outstanding,
280            resident_kb: 0,
281        }
282    }
283
284    fn soak_of(before: Reading, after: Reading) -> Soak {
285        Soak {
286            before,
287            after,
288            settled_for: Duration::from_secs(1),
289        }
290    }
291
292    /// X-5's exit criterion. The assertion has to have teeth, and the only way to know it does
293    /// is to give it a leak and watch it fail.
294    #[tokio::test]
295    async fn an_injected_leak_fails_the_soak() {
296        // Tasks that outlive the load, which is exactly what a leaked call looks like.
297        let held: std::sync::Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>> =
298            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
299
300        let leaking = std::sync::Arc::clone(&held);
301        let result = soak(
302            Duration::from_millis(200),
303            || async { 0 },
304            || async move {
305                for _ in 0..40 {
306                    let handle = tokio::spawn(async {
307                        // Never finishes: the leak.
308                        std::future::pending::<()>().await;
309                    });
310                    leaking.lock().expect("not poisoned").push(handle);
311                }
312                tokio::time::sleep(Duration::from_millis(50)).await;
313            },
314        )
315        .await;
316
317        assert!(
318            !result.is_flat(Tolerance::default()),
319            "forty leaked tasks must fail the run: {}",
320            result.report(Tolerance::default())
321        );
322        assert!(
323            result.report(Tolerance::default()).contains("tasks grew"),
324            "and must say what leaked: {}",
325            result.report(Tolerance::default())
326        );
327
328        for handle in held.lock().expect("not poisoned").drain(..) {
329            handle.abort();
330        }
331    }
332
333    /// The floor is longer than the longest transaction timer, or the soak accuses the
334    /// specification of leaking.
335    #[test]
336    fn the_settling_floor_outlasts_a_sip_transaction() {
337        // 64·T1 with the default T1 of 500 ms.
338        assert!(
339            SETTLE_PAST_TIMERS > Duration::from_secs(32),
340            "Timer J is 32 s; anything shorter counts a completed transaction as a leak"
341        );
342    }
343
344    /// And the other half: a run that leaks nothing passes. Without this the test above holds
345    /// against an assertion that fails everything.
346    #[tokio::test]
347    async fn a_clean_run_is_flat() {
348        let result = soak(
349            Duration::from_millis(200),
350            || async { 0 },
351            || async {
352                for _ in 0..40 {
353                    tokio::spawn(async {
354                        tokio::time::sleep(Duration::from_millis(10)).await;
355                    });
356                }
357                tokio::time::sleep(Duration::from_millis(100)).await;
358            },
359        )
360        .await;
361
362        assert!(
363            result.is_flat(Tolerance::default()),
364            "{}",
365            result.report(Tolerance::default())
366        );
367    }
368
369    /// Flat, not merely bounded. A pool that filled up would satisfy "under the ceiling" and is
370    /// still a leak — the pool hides it until it becomes a problem, and then hides the cause.
371    #[test]
372    fn growth_within_a_ceiling_is_still_a_leak() {
373        let run = soak_of(reading(10, 20, 0), reading(10, 20, 40));
374        assert!(!run.is_flat(Tolerance::default()));
375        assert!(
376            run.report(Tolerance::default())
377                .contains("outstanding grew")
378        );
379    }
380
381    /// Each dimension is reported on its own. "Something leaked" is not actionable.
382    #[test]
383    fn every_dimension_that_grew_is_named() {
384        let run = soak_of(reading(10, 20, 0), reading(100, 200, 40));
385        let leaks = run.leaks(Tolerance::default());
386        assert_eq!(leaks.len(), 3, "{leaks:?}");
387        assert!(leaks.iter().any(|l| l.starts_with("tasks")));
388        assert!(leaks.iter().any(|l| l.starts_with("descriptors")));
389        assert!(leaks.iter().any(|l| l.starts_with("outstanding")));
390    }
391
392    /// A tolerance of zero produces a test that fails at random, and a test that fails at
393    /// random is a test that gets deleted. Small drift must pass.
394    #[test]
395    fn ordinary_runtime_drift_is_not_a_leak() {
396        let run = soak_of(reading(10, 20, 0), reading(12, 24, 0));
397        assert!(
398            run.is_flat(Tolerance::default()),
399            "{}",
400            run.report(Tolerance::default())
401        );
402    }
403
404    /// Shrinking is never a leak.
405    #[test]
406    fn a_reading_that_fell_is_not_growth() {
407        let run = soak_of(reading(100, 200, 40), reading(10, 20, 0));
408        assert!(run.is_flat(Tolerance::default()));
409    }
410
411    /// Outstanding work has no tolerance at all. A transaction store that ends a run holding
412    /// anything is holding a transaction whose call is over.
413    #[test]
414    fn one_leftover_transaction_is_one_too_many() {
415        let run = soak_of(reading(10, 20, 0), reading(10, 20, 1));
416        assert!(
417            !run.is_flat(Tolerance::default()),
418            "a single leftover transaction is a leak: {}",
419            run.report(Tolerance::default())
420        );
421    }
422}
423
424#[cfg(test)]
425#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
426mod memory_tests {
427    use super::*;
428
429    /// The dimension the other three cannot see: a leak that grows buffers while task and
430    /// transaction counts stay perfectly flat. `X-5` names memory in its acceptance, and a
431    /// `Reading` without it would have that criterion ticked and unmet.
432    #[test]
433    fn memory_growth_is_a_leak_the_other_dimensions_would_miss() {
434        let run = Soak {
435            before: Reading {
436                tasks: 10,
437                descriptors: 20,
438                outstanding: 0,
439                resident_kb: 50_000,
440            },
441            after: Reading {
442                tasks: 10,
443                descriptors: 20,
444                outstanding: 0,
445                // A hundred megabytes more, with everything else identical.
446                resident_kb: 150_000,
447            },
448            settled_for: Duration::from_secs(1),
449        };
450        assert!(!run.is_flat(Tolerance::default()));
451        assert!(
452            run.leaks(Tolerance::default())
453                .iter()
454                .any(|leak| leak.starts_with("resident_kb")),
455            "{:?}",
456            run.leaks(Tolerance::default())
457        );
458    }
459
460    /// And the tolerance is loose enough for the ordinary case. An allocator that has not
461    /// returned a few megabytes of freed pages is not a leak, and a soak that said it was would
462    /// fail at random.
463    #[test]
464    fn a_few_megabytes_of_allocator_drift_is_not_a_leak() {
465        let run = Soak {
466            before: Reading {
467                tasks: 10,
468                descriptors: 20,
469                outstanding: 0,
470                resident_kb: 50_000,
471            },
472            after: Reading {
473                tasks: 10,
474                descriptors: 20,
475                outstanding: 0,
476                resident_kb: 54_000,
477            },
478            settled_for: Duration::from_secs(1),
479        };
480        assert!(
481            run.is_flat(Tolerance::default()),
482            "{}",
483            run.report(Tolerance::default())
484        );
485    }
486
487    /// It reports something real on Linux, which is where it claims to work.
488    #[test]
489    fn resident_memory_is_readable_here() {
490        if std::path::Path::new("/proc/self/statm").exists() {
491            assert!(resident_kb() > 0, "a running process has a resident set");
492        }
493    }
494}