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