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
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#[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 Fail,
129
130 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 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 #[derive(Debug)]
388 enum ExpectedBenchTimeout {
389 Exact(SlowTimeout),
391 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 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 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 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}