1use crate::time::far_future_duration;
5use serde::{Deserialize, Serialize, de::IntoDeserializer};
6use std::{fmt, num::NonZeroUsize, time::Duration};
7
8#[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 pub(crate) const VERY_LARGE: Self = Self {
25 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#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
92#[serde(rename_all = "kebab-case")]
93#[cfg_attr(test, derive(test_strategy::Arbitrary))]
94pub enum SlowTimeoutResult {
95 #[default]
96 Fail,
98
99 Pass,
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::{
107 config::{core::NextestConfig, utils::test_helpers::*},
108 run_mode::NextestRunMode,
109 };
110 use camino_tempfile::tempdir;
111 use indoc::indoc;
112 use nextest_filtering::ParseContext;
113 use test_case::test_case;
114
115 #[test_case(
116 "",
117 Ok(SlowTimeout {
118 period: Duration::from_secs(60),
119 terminate_after: None,
120 grace_period: Duration::from_secs(10),
121 on_timeout: SlowTimeoutResult::Fail,
122 }),
123 None
124 ; "empty config is expected to use the hardcoded values"
125 )]
126 #[test_case(
127 indoc! {r#"
128 [profile.default]
129 slow-timeout = "30s"
130 "#},
131 Ok(SlowTimeout {
132 period: Duration::from_secs(30),
133 terminate_after: None,
134 grace_period: Duration::from_secs(10),
135 on_timeout: SlowTimeoutResult::Fail,
136 }),
137 None
138 ; "overrides the default profile"
139 )]
140 #[test_case(
141 indoc! {r#"
142 [profile.default]
143 slow-timeout = "30s"
144
145 [profile.ci]
146 slow-timeout = { period = "60s", terminate-after = 3 }
147 "#},
148 Ok(SlowTimeout {
149 period: Duration::from_secs(30),
150 terminate_after: None,
151 grace_period: Duration::from_secs(10),
152 on_timeout: SlowTimeoutResult::Fail,
153 }),
154 Some(SlowTimeout {
155 period: Duration::from_secs(60),
156 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
157 grace_period: Duration::from_secs(10),
158 on_timeout: SlowTimeoutResult::Fail,
159 })
160 ; "adds a custom profile 'ci'"
161 )]
162 #[test_case(
163 indoc! {r#"
164 [profile.default]
165 slow-timeout = { period = "60s", terminate-after = 3 }
166
167 [profile.ci]
168 slow-timeout = "30s"
169 "#},
170 Ok(SlowTimeout {
171 period: Duration::from_secs(60),
172 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
173 grace_period: Duration::from_secs(10),
174 on_timeout: SlowTimeoutResult::Fail,
175 }),
176 Some(SlowTimeout {
177 period: Duration::from_secs(30),
178 terminate_after: None,
179 grace_period: Duration::from_secs(10),
180 on_timeout: SlowTimeoutResult::Fail,
181 })
182 ; "ci profile uses string notation"
183 )]
184 #[test_case(
185 indoc! {r#"
186 [profile.default]
187 slow-timeout = { period = "60s", terminate-after = 3, grace-period = "1s" }
188
189 [profile.ci]
190 slow-timeout = "30s"
191 "#},
192 Ok(SlowTimeout {
193 period: Duration::from_secs(60),
194 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
195 grace_period: Duration::from_secs(1),
196 on_timeout: SlowTimeoutResult::Fail,
197 }),
198 Some(SlowTimeout {
199 period: Duration::from_secs(30),
200 terminate_after: None,
201 grace_period: Duration::from_secs(10),
202 on_timeout: SlowTimeoutResult::Fail,
203 })
204 ; "timeout grace period"
205 )]
206 #[test_case(
207 indoc! {r#"
208 [profile.default]
209 slow-timeout = { period = "60s" }
210 "#},
211 Ok(SlowTimeout {
212 period: Duration::from_secs(60),
213 terminate_after: None,
214 grace_period: Duration::from_secs(10),
215 on_timeout: SlowTimeoutResult::Fail,
216 }),
217 None
218 ; "partial table"
219 )]
220 #[test_case(
221 indoc! {r#"
222 [profile.default]
223 slow-timeout = { period = "60s", terminate-after = 0 }
224 "#},
225 Err("original: invalid value: integer `0`, expected a nonzero usize"),
226 None
227 ; "zero terminate-after should fail"
228 )]
229 #[test_case(
230 indoc! {r#"
231 [profile.default]
232 slow-timeout = { period = "60s", on-timeout = "pass" }
233 "#},
234 Ok(SlowTimeout {
235 period: Duration::from_secs(60),
236 terminate_after: None,
237 grace_period: Duration::from_secs(10),
238 on_timeout: SlowTimeoutResult::Pass,
239 }),
240 None
241 ; "timeout result success"
242 )]
243 #[test_case(
244 indoc! {r#"
245 [profile.default]
246 slow-timeout = { period = "60s", on-timeout = "fail" }
247 "#},
248 Ok(SlowTimeout {
249 period: Duration::from_secs(60),
250 terminate_after: None,
251 grace_period: Duration::from_secs(10),
252 on_timeout: SlowTimeoutResult::Fail,
253 }),
254 None
255 ; "timeout result failure"
256 )]
257 #[test_case(
258 indoc! {r#"
259 [profile.default]
260 slow-timeout = { period = "60s", on-timeout = "pass" }
261
262 [profile.ci]
263 slow-timeout = { period = "30s", on-timeout = "fail" }
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 Some(SlowTimeout {
272 period: Duration::from_secs(30),
273 terminate_after: None,
274 grace_period: Duration::from_secs(10),
275 on_timeout: SlowTimeoutResult::Fail,
276 })
277 ; "override on-timeout option"
278 )]
279 #[test_case(
280 indoc! {r#"
281 [profile.default]
282 slow-timeout = "60s"
283
284 [profile.ci]
285 slow-timeout = { terminate-after = 3 }
286 "#},
287 Err("original: missing configuration field \"profile.ci.slow-timeout.period\""),
288 None
289
290 ; "partial slow-timeout table should error"
291 )]
292 fn slowtimeout_adheres_to_hierarchy(
293 config_contents: &str,
294 expected_default: Result<SlowTimeout, &str>,
295 maybe_expected_ci: Option<SlowTimeout>,
296 ) {
297 let workspace_dir = tempdir().unwrap();
298
299 let graph = temp_workspace(&workspace_dir, config_contents);
300
301 let pcx = ParseContext::new(&graph);
302
303 let nextest_config_result = NextestConfig::from_sources(
304 graph.workspace().root(),
305 &pcx,
306 None,
307 &[][..],
308 &Default::default(),
309 );
310
311 match expected_default {
312 Ok(expected_default) => {
313 let nextest_config = nextest_config_result.expect("config file should parse");
314
315 assert_eq!(
316 nextest_config
317 .profile("default")
318 .expect("default profile should exist")
319 .apply_build_platforms(&build_platforms())
320 .slow_timeout(NextestRunMode::Test),
321 expected_default,
322 );
323
324 if let Some(expected_ci) = maybe_expected_ci {
325 assert_eq!(
326 nextest_config
327 .profile("ci")
328 .expect("ci profile should exist")
329 .apply_build_platforms(&build_platforms())
330 .slow_timeout(NextestRunMode::Test),
331 expected_ci,
332 );
333 }
334 }
335
336 Err(expected_err_str) => {
337 let err_str = format!("{:?}", nextest_config_result.unwrap_err());
338
339 assert!(
340 err_str.contains(expected_err_str),
341 "expected error string not found: {err_str}",
342 )
343 }
344 }
345 }
346
347 const DEFAULT_TEST_SLOW_TIMEOUT: SlowTimeout = SlowTimeout {
349 period: Duration::from_secs(60),
350 terminate_after: None,
351 grace_period: Duration::from_secs(10),
352 on_timeout: SlowTimeoutResult::Fail,
353 };
354
355 #[derive(Debug)]
357 enum ExpectedBenchTimeout {
358 Exact(SlowTimeout),
360 VeryLarge,
363 }
364
365 #[test_case(
366 "",
367 DEFAULT_TEST_SLOW_TIMEOUT,
368 ExpectedBenchTimeout::VeryLarge
369 ; "empty config uses defaults for both modes"
370 )]
371 #[test_case(
372 indoc! {r#"
373 [profile.default]
374 slow-timeout = { period = "10s", terminate-after = 2 }
375 "#},
376 SlowTimeout {
377 period: Duration::from_secs(10),
378 terminate_after: Some(NonZeroUsize::new(2).unwrap()),
379 grace_period: Duration::from_secs(10),
380 on_timeout: SlowTimeoutResult::Fail,
381 },
382 ExpectedBenchTimeout::VeryLarge
384 ; "slow-timeout does not affect bench.slow-timeout"
385 )]
386 #[test_case(
387 indoc! {r#"
388 [profile.default]
389 bench.slow-timeout = { period = "20s", terminate-after = 3 }
390 "#},
391 DEFAULT_TEST_SLOW_TIMEOUT,
393 ExpectedBenchTimeout::Exact(SlowTimeout {
394 period: Duration::from_secs(20),
395 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
396 grace_period: Duration::from_secs(10),
397 on_timeout: SlowTimeoutResult::Fail,
398 })
399 ; "bench.slow-timeout does not affect slow-timeout"
400 )]
401 #[test_case(
402 indoc! {r#"
403 [profile.default]
404 slow-timeout = { period = "10s", terminate-after = 2 }
405 bench.slow-timeout = { period = "20s", terminate-after = 3 }
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 ExpectedBenchTimeout::Exact(SlowTimeout {
414 period: Duration::from_secs(20),
415 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
416 grace_period: Duration::from_secs(10),
417 on_timeout: SlowTimeoutResult::Fail,
418 })
419 ; "both slow-timeout and bench.slow-timeout can be set independently"
420 )]
421 #[test_case(
422 indoc! {r#"
423 [profile.default]
424 bench.slow-timeout = "30s"
425 "#},
426 DEFAULT_TEST_SLOW_TIMEOUT,
427 ExpectedBenchTimeout::Exact(SlowTimeout {
428 period: Duration::from_secs(30),
429 terminate_after: None,
430 grace_period: Duration::from_secs(10),
431 on_timeout: SlowTimeoutResult::Fail,
432 })
433 ; "bench.slow-timeout string notation"
434 )]
435 fn bench_slowtimeout_is_independent(
436 config_contents: &str,
437 expected_test_timeout: SlowTimeout,
438 expected_bench_timeout: ExpectedBenchTimeout,
439 ) {
440 let workspace_dir = tempdir().unwrap();
441
442 let graph = temp_workspace(&workspace_dir, config_contents);
443
444 let pcx = ParseContext::new(&graph);
445
446 let nextest_config = NextestConfig::from_sources(
447 graph.workspace().root(),
448 &pcx,
449 None,
450 &[][..],
451 &Default::default(),
452 )
453 .expect("config file should parse");
454
455 let profile = nextest_config
456 .profile("default")
457 .expect("default profile should exist")
458 .apply_build_platforms(&build_platforms());
459
460 assert_eq!(
461 profile.slow_timeout(NextestRunMode::Test),
462 expected_test_timeout,
463 "Test mode slow-timeout mismatch"
464 );
465
466 let actual_bench_timeout = profile.slow_timeout(NextestRunMode::Benchmark);
467 match expected_bench_timeout {
468 ExpectedBenchTimeout::Exact(expected) => {
469 assert_eq!(
470 actual_bench_timeout, expected,
471 "Benchmark mode slow-timeout mismatch"
472 );
473 }
474 ExpectedBenchTimeout::VeryLarge => {
475 assert!(
478 actual_bench_timeout.period >= SlowTimeout::VERY_LARGE.period,
479 "Benchmark mode slow-timeout should be >= VERY_LARGE, got {:?}",
480 actual_bench_timeout.period
481 );
482 assert_eq!(
483 actual_bench_timeout.terminate_after, None,
484 "Benchmark mode terminate_after should be None"
485 );
486 }
487 }
488 }
489}