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