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