Skip to main content

nextest_runner/config/elements/
slow_timeout.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::time::far_future_duration;
5use serde::{Deserialize, Serialize, de::IntoDeserializer};
6use std::{fmt, num::NonZeroUsize, time::Duration};
7
8/// Time after which a test is considered slow, plus optional termination
9/// policy.
10#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "kebab-case")]
12pub struct SlowTimeout {
13    #[serde(with = "humantime_serde")]
14    pub(crate) period: Duration,
15    #[serde(default)]
16    pub(crate) terminate_after: Option<NonZeroUsize>,
17    #[serde(with = "humantime_serde", default = "default_grace_period")]
18    pub(crate) grace_period: Duration,
19    #[serde(default)]
20    pub(crate) on_timeout: SlowTimeoutResult,
21}
22
23impl SlowTimeout {
24    /// A reasonable value for "maximum slow timeout".
25    pub(crate) const VERY_LARGE: Self = Self {
26        // See far_future() in pausable_sleep.rs for why this is roughly 30 years.
27        period: far_future_duration(),
28        terminate_after: None,
29        grace_period: Duration::from_secs(10),
30        on_timeout: SlowTimeoutResult::Fail,
31    };
32}
33
34#[cfg(feature = "config-schema")]
35impl schemars::JsonSchema for SlowTimeout {
36    fn schema_name() -> std::borrow::Cow<'static, str> {
37        "SlowTimeout".into()
38    }
39
40    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
41        let on_timeout = generator.subschema_for::<SlowTimeoutResult>();
42        slow_timeout_schema(generator, "SlowTimeout", on_timeout)
43    }
44}
45
46#[cfg(feature = "config-schema")]
47pub(in crate::config) fn slow_timeout_schema(
48    generator: &mut schemars::SchemaGenerator,
49    title: &str,
50    on_timeout: schemars::Schema,
51) -> schemars::Schema {
52    schemars::json_schema!({
53        "title": title,
54        "oneOf": [
55            generator.subschema_for::<String>(),
56            {
57                "type": "object",
58                "properties": {
59                    "period": generator.subschema_for::<String>(),
60                    "terminate-after": {
61                        "type": ["integer", "null"],
62                        "minimum": 1,
63                    },
64                    "grace-period": generator.subschema_for::<String>(),
65                    "on-timeout": on_timeout,
66                },
67                "required": ["period"],
68                "additionalProperties": false,
69            }
70        ]
71    })
72}
73
74fn default_grace_period() -> Duration {
75    Duration::from_secs(10)
76}
77
78pub(in crate::config) fn deserialize_slow_timeout<'de, D>(
79    deserializer: D,
80) -> Result<Option<SlowTimeout>, D::Error>
81where
82    D: serde::Deserializer<'de>,
83{
84    struct V;
85
86    impl<'de2> serde::de::Visitor<'de2> for V {
87        type Value = Option<SlowTimeout>;
88
89        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
90            write!(
91                formatter,
92                "a table ({{ period = \"60s\", terminate-after = 2 }}) or a string (\"60s\")"
93            )
94        }
95
96        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
97        where
98            E: serde::de::Error,
99        {
100            if v.is_empty() {
101                Ok(None)
102            } else {
103                let period = humantime_serde::deserialize(v.into_deserializer())?;
104                Ok(Some(SlowTimeout {
105                    period,
106                    terminate_after: None,
107                    grace_period: default_grace_period(),
108                    on_timeout: SlowTimeoutResult::Fail,
109                }))
110            }
111        }
112
113        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
114        where
115            A: serde::de::MapAccess<'de2>,
116        {
117            SlowTimeout::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Some)
118        }
119    }
120
121    deserializer.deserialize_any(V)
122}
123
124/// The result of controlling slow timeout behavior.
125///
126/// In most situations a timed out test should be marked failing. However, there are certain
127/// classes of tests which are expected to run indefinitely long, like fuzzing, which explores a
128/// huge state space. For these tests it's nice to be able to treat a timeout as a success since
129/// they generally check for invariants and other properties of the code under test during their
130/// execution. A timeout in this context doesn't mean that there are no failing inputs, it just
131/// means that they weren't found up until that moment, which is still valuable information.
132#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
133#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
134#[serde(rename_all = "kebab-case")]
135#[cfg_attr(test, derive(test_strategy::Arbitrary))]
136pub enum SlowTimeoutResult {
137    #[default]
138    /// The test is marked as failed.
139    Fail,
140
141    /// The test is marked as passed.
142    Pass,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::{
149        config::{core::NextestConfig, utils::test_helpers::*},
150        run_mode::NextestRunMode,
151    };
152    use camino_tempfile::tempdir;
153    use indoc::indoc;
154    use nextest_filtering::ParseContext;
155    use test_case::test_case;
156
157    #[test_case(
158        "",
159        Ok(SlowTimeout {
160            period: Duration::from_secs(60),
161            terminate_after: None,
162            grace_period: Duration::from_secs(10),
163            on_timeout: SlowTimeoutResult::Fail,
164        }),
165        None
166        ; "empty config is expected to use the hardcoded values"
167    )]
168    #[test_case(
169        indoc! {r#"
170            [profile.default]
171            slow-timeout = "30s"
172        "#},
173        Ok(SlowTimeout {
174            period: Duration::from_secs(30),
175            terminate_after: None,
176            grace_period: Duration::from_secs(10),
177            on_timeout: SlowTimeoutResult::Fail,
178        }),
179        None
180        ; "overrides the default profile"
181    )]
182    #[test_case(
183        indoc! {r#"
184            [profile.default]
185            slow-timeout = "30s"
186
187            [profile.ci]
188            slow-timeout = { period = "60s", terminate-after = 3 }
189        "#},
190        Ok(SlowTimeout {
191            period: Duration::from_secs(30),
192            terminate_after: None,
193            grace_period: Duration::from_secs(10),
194            on_timeout: SlowTimeoutResult::Fail,
195        }),
196        Some(SlowTimeout {
197            period: Duration::from_secs(60),
198            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
199            grace_period: Duration::from_secs(10),
200            on_timeout: SlowTimeoutResult::Fail,
201        })
202        ; "adds a custom profile 'ci'"
203    )]
204    #[test_case(
205        indoc! {r#"
206            [profile.default]
207            slow-timeout = { period = "60s", terminate-after = 3 }
208
209            [profile.ci]
210            slow-timeout = "30s"
211        "#},
212        Ok(SlowTimeout {
213            period: Duration::from_secs(60),
214            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
215            grace_period: Duration::from_secs(10),
216            on_timeout: SlowTimeoutResult::Fail,
217        }),
218        Some(SlowTimeout {
219            period: Duration::from_secs(30),
220            terminate_after: None,
221            grace_period: Duration::from_secs(10),
222            on_timeout: SlowTimeoutResult::Fail,
223        })
224        ; "ci profile uses string notation"
225    )]
226    #[test_case(
227        indoc! {r#"
228            [profile.default]
229            slow-timeout = { period = "60s", terminate-after = 3, grace-period = "1s" }
230
231            [profile.ci]
232            slow-timeout = "30s"
233        "#},
234        Ok(SlowTimeout {
235            period: Duration::from_secs(60),
236            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
237            grace_period: Duration::from_secs(1),
238            on_timeout: SlowTimeoutResult::Fail,
239        }),
240        Some(SlowTimeout {
241            period: Duration::from_secs(30),
242            terminate_after: None,
243            grace_period: Duration::from_secs(10),
244            on_timeout: SlowTimeoutResult::Fail,
245        })
246        ; "timeout grace period"
247    )]
248    #[test_case(
249        indoc! {r#"
250            [profile.default]
251            slow-timeout = { period = "60s" }
252        "#},
253        Ok(SlowTimeout {
254            period: Duration::from_secs(60),
255            terminate_after: None,
256            grace_period: Duration::from_secs(10),
257            on_timeout: SlowTimeoutResult::Fail,
258        }),
259        None
260        ; "partial table"
261    )]
262    #[test_case(
263        indoc! {r#"
264            [profile.default]
265            slow-timeout = { period = "60s", terminate-after = 0 }
266        "#},
267        Err("original: invalid value: integer `0`, expected a nonzero usize"),
268        None
269        ; "zero terminate-after should fail"
270    )]
271    #[test_case(
272        indoc! {r#"
273            [profile.default]
274            slow-timeout = { period = "60s", on-timeout = "pass" }
275        "#},
276        Ok(SlowTimeout {
277            period: Duration::from_secs(60),
278            terminate_after: None,
279            grace_period: Duration::from_secs(10),
280            on_timeout: SlowTimeoutResult::Pass,
281        }),
282        None
283        ; "timeout result success"
284    )]
285    #[test_case(
286        indoc! {r#"
287            [profile.default]
288            slow-timeout = { period = "60s", on-timeout = "fail" }
289        "#},
290        Ok(SlowTimeout {
291            period: Duration::from_secs(60),
292            terminate_after: None,
293            grace_period: Duration::from_secs(10),
294            on_timeout: SlowTimeoutResult::Fail,
295        }),
296        None
297        ; "timeout result failure"
298    )]
299    #[test_case(
300        indoc! {r#"
301            [profile.default]
302            slow-timeout = { period = "60s", on-timeout = "pass" }
303
304            [profile.ci]
305            slow-timeout = { period = "30s", on-timeout = "fail" }
306        "#},
307        Ok(SlowTimeout {
308            period: Duration::from_secs(60),
309            terminate_after: None,
310            grace_period: Duration::from_secs(10),
311            on_timeout: SlowTimeoutResult::Pass,
312        }),
313        Some(SlowTimeout {
314            period: Duration::from_secs(30),
315            terminate_after: None,
316            grace_period: Duration::from_secs(10),
317            on_timeout: SlowTimeoutResult::Fail,
318        })
319        ; "override on-timeout option"
320    )]
321    #[test_case(
322        indoc! {r#"
323            [profile.default]
324            slow-timeout = "60s"
325
326            [profile.ci]
327            slow-timeout = { terminate-after = 3 }
328        "#},
329        Err("original: missing configuration field \"profile.ci.slow-timeout.period\""),
330        None
331
332        ; "partial slow-timeout table should error"
333    )]
334    fn slowtimeout_adheres_to_hierarchy(
335        config_contents: &str,
336        expected_default: Result<SlowTimeout, &str>,
337        maybe_expected_ci: Option<SlowTimeout>,
338    ) {
339        let workspace_dir = tempdir().unwrap();
340
341        let graph = temp_workspace(&workspace_dir, config_contents);
342
343        let pcx = ParseContext::new(&graph);
344
345        let nextest_config_result = NextestConfig::from_sources(
346            graph.workspace().root(),
347            &pcx,
348            None,
349            &[][..],
350            &Default::default(),
351        );
352
353        match expected_default {
354            Ok(expected_default) => {
355                let nextest_config = nextest_config_result.expect("config file should parse");
356
357                assert_eq!(
358                    nextest_config
359                        .profile("default")
360                        .expect("default profile should exist")
361                        .apply_build_platforms(&build_platforms())
362                        .slow_timeout(NextestRunMode::Test),
363                    expected_default,
364                );
365
366                if let Some(expected_ci) = maybe_expected_ci {
367                    assert_eq!(
368                        nextest_config
369                            .profile("ci")
370                            .expect("ci profile should exist")
371                            .apply_build_platforms(&build_platforms())
372                            .slow_timeout(NextestRunMode::Test),
373                        expected_ci,
374                    );
375                }
376            }
377
378            Err(expected_err_str) => {
379                let err_str = format!("{:?}", nextest_config_result.unwrap_err());
380
381                assert!(
382                    err_str.contains(expected_err_str),
383                    "expected error string not found: {err_str}",
384                )
385            }
386        }
387    }
388
389    // Default test slow-timeout is 60 seconds.
390    const DEFAULT_TEST_SLOW_TIMEOUT: SlowTimeout = SlowTimeout {
391        period: Duration::from_secs(60),
392        terminate_after: None,
393        grace_period: Duration::from_secs(10),
394        on_timeout: SlowTimeoutResult::Fail,
395    };
396
397    /// Expected bench timeout: either a specific value or "very large" (default).
398    #[derive(Debug)]
399    enum ExpectedBenchTimeout {
400        /// Expect a specific timeout value.
401        Exact(SlowTimeout),
402        /// Expect the default very large timeout (>= VERY_LARGE, accounting for
403        /// leap years in humantime parsing).
404        VeryLarge,
405    }
406
407    #[test_case(
408        "",
409        DEFAULT_TEST_SLOW_TIMEOUT,
410        ExpectedBenchTimeout::VeryLarge
411        ; "empty config uses defaults for both modes"
412    )]
413    #[test_case(
414        indoc! {r#"
415            [profile.default]
416            slow-timeout = { period = "10s", terminate-after = 2 }
417        "#},
418        SlowTimeout {
419            period: Duration::from_secs(10),
420            terminate_after: Some(NonZeroUsize::new(2).unwrap()),
421            grace_period: Duration::from_secs(10),
422            on_timeout: SlowTimeoutResult::Fail,
423        },
424        // bench.slow-timeout should still be 30 years (default).
425        ExpectedBenchTimeout::VeryLarge
426        ; "slow-timeout does not affect bench.slow-timeout"
427    )]
428    #[test_case(
429        indoc! {r#"
430            [profile.default]
431            bench.slow-timeout = { period = "20s", terminate-after = 3 }
432        "#},
433        // slow-timeout should still be 60s (default).
434        DEFAULT_TEST_SLOW_TIMEOUT,
435        ExpectedBenchTimeout::Exact(SlowTimeout {
436            period: Duration::from_secs(20),
437            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
438            grace_period: Duration::from_secs(10),
439            on_timeout: SlowTimeoutResult::Fail,
440        })
441        ; "bench.slow-timeout does not affect slow-timeout"
442    )]
443    #[test_case(
444        indoc! {r#"
445            [profile.default]
446            slow-timeout = { period = "10s", terminate-after = 2 }
447            bench.slow-timeout = { period = "20s", terminate-after = 3 }
448        "#},
449        SlowTimeout {
450            period: Duration::from_secs(10),
451            terminate_after: Some(NonZeroUsize::new(2).unwrap()),
452            grace_period: Duration::from_secs(10),
453            on_timeout: SlowTimeoutResult::Fail,
454        },
455        ExpectedBenchTimeout::Exact(SlowTimeout {
456            period: Duration::from_secs(20),
457            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
458            grace_period: Duration::from_secs(10),
459            on_timeout: SlowTimeoutResult::Fail,
460        })
461        ; "both slow-timeout and bench.slow-timeout can be set independently"
462    )]
463    #[test_case(
464        indoc! {r#"
465            [profile.default]
466            bench.slow-timeout = "30s"
467        "#},
468        DEFAULT_TEST_SLOW_TIMEOUT,
469        ExpectedBenchTimeout::Exact(SlowTimeout {
470            period: Duration::from_secs(30),
471            terminate_after: None,
472            grace_period: Duration::from_secs(10),
473            on_timeout: SlowTimeoutResult::Fail,
474        })
475        ; "bench.slow-timeout string notation"
476    )]
477    fn bench_slowtimeout_is_independent(
478        config_contents: &str,
479        expected_test_timeout: SlowTimeout,
480        expected_bench_timeout: ExpectedBenchTimeout,
481    ) {
482        let workspace_dir = tempdir().unwrap();
483
484        let graph = temp_workspace(&workspace_dir, config_contents);
485
486        let pcx = ParseContext::new(&graph);
487
488        let nextest_config = NextestConfig::from_sources(
489            graph.workspace().root(),
490            &pcx,
491            None,
492            &[][..],
493            &Default::default(),
494        )
495        .expect("config file should parse");
496
497        let profile = nextest_config
498            .profile("default")
499            .expect("default profile should exist")
500            .apply_build_platforms(&build_platforms());
501
502        assert_eq!(
503            profile.slow_timeout(NextestRunMode::Test),
504            expected_test_timeout,
505            "Test mode slow-timeout mismatch"
506        );
507
508        let actual_bench_timeout = profile.slow_timeout(NextestRunMode::Benchmark);
509        match expected_bench_timeout {
510            ExpectedBenchTimeout::Exact(expected) => {
511                assert_eq!(
512                    actual_bench_timeout, expected,
513                    "Benchmark mode slow-timeout mismatch"
514                );
515            }
516            ExpectedBenchTimeout::VeryLarge => {
517                // The default is "30y" which humantime parses accounting for
518                // leap years, so it is slightly larger than VERY_LARGE.
519                assert!(
520                    actual_bench_timeout.period >= SlowTimeout::VERY_LARGE.period,
521                    "Benchmark mode slow-timeout should be >= VERY_LARGE, got {:?}",
522                    actual_bench_timeout.period
523                );
524                assert_eq!(
525                    actual_bench_timeout.terminate_after, None,
526                    "Benchmark mode terminate_after should be None"
527                );
528            }
529        }
530    }
531}