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, 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, Default, PartialEq, Eq)]
92#[serde(rename_all = "kebab-case")]
93pub enum SlowTimeoutResult {
94    #[default]
95    /// The test is marked as failed.
96    Fail,
97
98    /// The test is marked as passed.
99    Pass,
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::config::{core::NextestConfig, utils::test_helpers::*};
106    use camino_tempfile::tempdir;
107    use indoc::indoc;
108    use nextest_filtering::ParseContext;
109    use test_case::test_case;
110
111    #[test_case(
112        "",
113        Ok(SlowTimeout {
114            period: Duration::from_secs(60),
115            terminate_after: None,
116            grace_period: Duration::from_secs(10),
117            on_timeout: SlowTimeoutResult::Fail,
118        }),
119        None
120        ; "empty config is expected to use the hardcoded values"
121    )]
122    #[test_case(
123        indoc! {r#"
124            [profile.default]
125            slow-timeout = "30s"
126        "#},
127        Ok(SlowTimeout {
128            period: Duration::from_secs(30),
129            terminate_after: None,
130            grace_period: Duration::from_secs(10),
131            on_timeout: SlowTimeoutResult::Fail,
132        }),
133        None
134        ; "overrides the default profile"
135    )]
136    #[test_case(
137        indoc! {r#"
138            [profile.default]
139            slow-timeout = "30s"
140
141            [profile.ci]
142            slow-timeout = { period = "60s", terminate-after = 3 }
143        "#},
144        Ok(SlowTimeout {
145            period: Duration::from_secs(30),
146            terminate_after: None,
147            grace_period: Duration::from_secs(10),
148            on_timeout: SlowTimeoutResult::Fail,
149        }),
150        Some(SlowTimeout {
151            period: Duration::from_secs(60),
152            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
153            grace_period: Duration::from_secs(10),
154            on_timeout: SlowTimeoutResult::Fail,
155        })
156        ; "adds a custom profile 'ci'"
157    )]
158    #[test_case(
159        indoc! {r#"
160            [profile.default]
161            slow-timeout = { period = "60s", terminate-after = 3 }
162
163            [profile.ci]
164            slow-timeout = "30s"
165        "#},
166        Ok(SlowTimeout {
167            period: Duration::from_secs(60),
168            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
169            grace_period: Duration::from_secs(10),
170            on_timeout: SlowTimeoutResult::Fail,
171        }),
172        Some(SlowTimeout {
173            period: Duration::from_secs(30),
174            terminate_after: None,
175            grace_period: Duration::from_secs(10),
176            on_timeout: SlowTimeoutResult::Fail,
177        })
178        ; "ci profile uses string notation"
179    )]
180    #[test_case(
181        indoc! {r#"
182            [profile.default]
183            slow-timeout = { period = "60s", terminate-after = 3, grace-period = "1s" }
184
185            [profile.ci]
186            slow-timeout = "30s"
187        "#},
188        Ok(SlowTimeout {
189            period: Duration::from_secs(60),
190            terminate_after: Some(NonZeroUsize::new(3).unwrap()),
191            grace_period: Duration::from_secs(1),
192            on_timeout: SlowTimeoutResult::Fail,
193        }),
194        Some(SlowTimeout {
195            period: Duration::from_secs(30),
196            terminate_after: None,
197            grace_period: Duration::from_secs(10),
198            on_timeout: SlowTimeoutResult::Fail,
199        })
200        ; "timeout grace period"
201    )]
202    #[test_case(
203        indoc! {r#"
204            [profile.default]
205            slow-timeout = { period = "60s" }
206        "#},
207        Ok(SlowTimeout {
208            period: Duration::from_secs(60),
209            terminate_after: None,
210            grace_period: Duration::from_secs(10),
211            on_timeout: SlowTimeoutResult::Fail,
212        }),
213        None
214        ; "partial table"
215    )]
216    #[test_case(
217        indoc! {r#"
218            [profile.default]
219            slow-timeout = { period = "60s", terminate-after = 0 }
220        "#},
221        Err("original: invalid value: integer `0`, expected a nonzero usize"),
222        None
223        ; "zero terminate-after should fail"
224    )]
225    #[test_case(
226        indoc! {r#"
227            [profile.default]
228            slow-timeout = { period = "60s", on-timeout = "pass" }
229        "#},
230        Ok(SlowTimeout {
231            period: Duration::from_secs(60),
232            terminate_after: None,
233            grace_period: Duration::from_secs(10),
234            on_timeout: SlowTimeoutResult::Pass,
235        }),
236        None
237        ; "timeout result success"
238    )]
239    #[test_case(
240        indoc! {r#"
241            [profile.default]
242            slow-timeout = { period = "60s", on-timeout = "fail" }
243        "#},
244        Ok(SlowTimeout {
245            period: Duration::from_secs(60),
246            terminate_after: None,
247            grace_period: Duration::from_secs(10),
248            on_timeout: SlowTimeoutResult::Fail,
249        }),
250        None
251        ; "timeout result failure"
252    )]
253    #[test_case(
254        indoc! {r#"
255            [profile.default]
256            slow-timeout = { period = "60s", on-timeout = "pass" }
257
258            [profile.ci]
259            slow-timeout = { period = "30s", on-timeout = "fail" }
260        "#},
261        Ok(SlowTimeout {
262            period: Duration::from_secs(60),
263            terminate_after: None,
264            grace_period: Duration::from_secs(10),
265            on_timeout: SlowTimeoutResult::Pass,
266        }),
267        Some(SlowTimeout {
268            period: Duration::from_secs(30),
269            terminate_after: None,
270            grace_period: Duration::from_secs(10),
271            on_timeout: SlowTimeoutResult::Fail,
272        })
273        ; "override on-timeout option"
274    )]
275    #[test_case(
276        indoc! {r#"
277            [profile.default]
278            slow-timeout = "60s"
279
280            [profile.ci]
281            slow-timeout = { terminate-after = 3 }
282        "#},
283        Err("original: missing configuration field \"profile.ci.slow-timeout.period\""),
284        None
285
286        ; "partial slow-timeout table should error"
287    )]
288    fn slowtimeout_adheres_to_hierarchy(
289        config_contents: &str,
290        expected_default: Result<SlowTimeout, &str>,
291        maybe_expected_ci: Option<SlowTimeout>,
292    ) {
293        let workspace_dir = tempdir().unwrap();
294
295        let graph = temp_workspace(&workspace_dir, config_contents);
296
297        let pcx = ParseContext::new(&graph);
298
299        let nextest_config_result = NextestConfig::from_sources(
300            graph.workspace().root(),
301            &pcx,
302            None,
303            &[][..],
304            &Default::default(),
305        );
306
307        match expected_default {
308            Ok(expected_default) => {
309                let nextest_config = nextest_config_result.expect("config file should parse");
310
311                assert_eq!(
312                    nextest_config
313                        .profile("default")
314                        .expect("default profile should exist")
315                        .apply_build_platforms(&build_platforms())
316                        .slow_timeout(),
317                    expected_default,
318                );
319
320                if let Some(expected_ci) = maybe_expected_ci {
321                    assert_eq!(
322                        nextest_config
323                            .profile("ci")
324                            .expect("ci profile should exist")
325                            .apply_build_platforms(&build_platforms())
326                            .slow_timeout(),
327                        expected_ci,
328                    );
329                }
330            }
331
332            Err(expected_err_str) => {
333                let err_str = format!("{:?}", nextest_config_result.unwrap_err());
334
335                assert!(
336                    err_str.contains(expected_err_str),
337                    "expected error string not found: {err_str}",
338                )
339            }
340        }
341    }
342}