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}