nextest_runner/config/
slow_timeout.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, de::IntoDeserializer};
5use std::{fmt, num::NonZeroUsize, time::Duration};
6
7/// Type for the slow-timeout config key.
8#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "kebab-case")]
10pub struct SlowTimeout {
11    #[serde(with = "humantime_serde")]
12    pub(crate) period: Duration,
13    #[serde(default)]
14    pub(crate) terminate_after: Option<NonZeroUsize>,
15    #[serde(with = "humantime_serde", default = "default_grace_period")]
16    pub(crate) grace_period: Duration,
17}
18
19impl SlowTimeout {
20    /// A reasonable value for "maximum slow timeout".
21    pub(crate) const VERY_LARGE: Self = Self {
22        // See far_future() in pausable_sleep.rs for why this is roughly 30 years.
23        period: Duration::from_secs(86400 * 365 * 30),
24        terminate_after: None,
25        grace_period: Duration::from_secs(10),
26    };
27}
28
29fn default_grace_period() -> Duration {
30    Duration::from_secs(10)
31}
32
33pub(super) fn deserialize_slow_timeout<'de, D>(
34    deserializer: D,
35) -> Result<Option<SlowTimeout>, D::Error>
36where
37    D: serde::Deserializer<'de>,
38{
39    struct V;
40
41    impl<'de2> serde::de::Visitor<'de2> for V {
42        type Value = Option<SlowTimeout>;
43
44        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
45            write!(
46                formatter,
47                "a table ({{ period = \"60s\", terminate-after = 2 }}) or a string (\"60s\")"
48            )
49        }
50
51        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
52        where
53            E: serde::de::Error,
54        {
55            if v.is_empty() {
56                Ok(None)
57            } else {
58                let period = humantime_serde::deserialize(v.into_deserializer())?;
59                Ok(Some(SlowTimeout {
60                    period,
61                    terminate_after: None,
62                    grace_period: default_grace_period(),
63                }))
64            }
65        }
66
67        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
68        where
69            A: serde::de::MapAccess<'de2>,
70        {
71            SlowTimeout::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Some)
72        }
73    }
74
75    deserializer.deserialize_any(V)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::config::{
82        NextestConfig,
83        test_helpers::{build_platforms, temp_workspace},
84    };
85    use camino_tempfile::tempdir;
86    use indoc::indoc;
87    use nextest_filtering::ParseContext;
88    use test_case::test_case;
89
90    #[test_case(
91        "",
92        Ok(SlowTimeout { period: Duration::from_secs(60), terminate_after: None, grace_period: Duration::from_secs(10) }),
93        None
94
95        ; "empty config is expected to use the hardcoded values"
96    )]
97    #[test_case(
98        indoc! {r#"
99            [profile.default]
100            slow-timeout = "30s"
101        "#},
102        Ok(SlowTimeout { period: Duration::from_secs(30), terminate_after: None, grace_period: Duration::from_secs(10) }),
103        None
104
105        ; "overrides the default profile"
106    )]
107    #[test_case(
108        indoc! {r#"
109            [profile.default]
110            slow-timeout = "30s"
111
112            [profile.ci]
113            slow-timeout = { period = "60s", terminate-after = 3 }
114        "#},
115        Ok(SlowTimeout { period: Duration::from_secs(30), terminate_after: None, grace_period: Duration::from_secs(10) }),
116        Some(SlowTimeout { period: Duration::from_secs(60), terminate_after: Some(NonZeroUsize::new(3).unwrap()), grace_period: Duration::from_secs(10) })
117
118        ; "adds a custom profile 'ci'"
119    )]
120    #[test_case(
121        indoc! {r#"
122            [profile.default]
123            slow-timeout = { period = "60s", terminate-after = 3 }
124
125            [profile.ci]
126            slow-timeout = "30s"
127        "#},
128        Ok(SlowTimeout { period: Duration::from_secs(60), terminate_after: Some(NonZeroUsize::new(3).unwrap()), grace_period: Duration::from_secs(10) }),
129        Some(SlowTimeout { period: Duration::from_secs(30), terminate_after: None, grace_period: Duration::from_secs(10) })
130
131        ; "ci profile uses string notation"
132    )]
133    #[test_case(
134        indoc! {r#"
135            [profile.default]
136            slow-timeout = { period = "60s", terminate-after = 3, grace-period = "1s" }
137
138            [profile.ci]
139            slow-timeout = "30s"
140        "#},
141        Ok(SlowTimeout { period: Duration::from_secs(60), terminate_after: Some(NonZeroUsize::new(3).unwrap()), grace_period: Duration::from_secs(1) }),
142        Some(SlowTimeout { period: Duration::from_secs(30), terminate_after: None, grace_period: Duration::from_secs(10) })
143
144        ; "timeout grace period"
145    )]
146    #[test_case(
147        indoc! {r#"
148            [profile.default]
149            slow-timeout = { period = "60s" }
150        "#},
151        Ok(SlowTimeout { period: Duration::from_secs(60), terminate_after: None, grace_period: Duration::from_secs(10) }),
152        None
153
154        ; "partial table"
155    )]
156    #[test_case(
157        indoc! {r#"
158            [profile.default]
159            slow-timeout = { period = "60s", terminate-after = 0 }
160        "#},
161        Err("original: invalid value: integer `0`, expected a nonzero usize"),
162        None
163
164        ; "zero terminate-after should fail"
165    )]
166    #[test_case(
167        indoc! {r#"
168            [profile.default]
169            slow-timeout = "60s"
170
171            [profile.ci]
172            slow-timeout = { terminate-after = 3 }
173        "#},
174        Err("original: missing field `period`"),
175        None
176
177        ; "partial slow-timeout table should error"
178    )]
179    fn slowtimeout_adheres_to_hierarchy(
180        config_contents: &str,
181        expected_default: Result<SlowTimeout, &str>,
182        maybe_expected_ci: Option<SlowTimeout>,
183    ) {
184        let workspace_dir = tempdir().unwrap();
185
186        let graph = temp_workspace(&workspace_dir, config_contents);
187
188        let pcx = ParseContext::new(&graph);
189
190        let nextest_config_result = NextestConfig::from_sources(
191            graph.workspace().root(),
192            &pcx,
193            None,
194            &[][..],
195            &Default::default(),
196        );
197
198        match expected_default {
199            Ok(expected_default) => {
200                let nextest_config = nextest_config_result.expect("config file should parse");
201
202                assert_eq!(
203                    nextest_config
204                        .profile("default")
205                        .expect("default profile should exist")
206                        .apply_build_platforms(&build_platforms())
207                        .slow_timeout(),
208                    expected_default,
209                );
210
211                if let Some(expected_ci) = maybe_expected_ci {
212                    assert_eq!(
213                        nextest_config
214                            .profile("ci")
215                            .expect("ci profile should exist")
216                            .apply_build_platforms(&build_platforms())
217                            .slow_timeout(),
218                        expected_ci,
219                    );
220                }
221            }
222
223            Err(expected_err_str) => {
224                let err_str = format!("{:?}", nextest_config_result.unwrap_err());
225
226                assert!(
227                    err_str.contains(expected_err_str),
228                    "expected error string not found: {err_str}",
229                )
230            }
231        }
232    }
233}