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