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)]
11#[serde(rename_all = "kebab-case")]
12pub struct SlowTimeout {
13 #[serde(with = "humantime_serde")]
14 pub(crate) period: Duration,
15 #[serde(default)]
16 pub(crate) terminate_after: Option<NonZeroUsize>,
17 #[serde(with = "humantime_serde", default = "default_grace_period")]
18 pub(crate) grace_period: Duration,
19 #[serde(default)]
20 pub(crate) on_timeout: SlowTimeoutResult,
21}
22
23impl SlowTimeout {
24 pub(crate) const VERY_LARGE: Self = Self {
26 period: far_future_duration(),
28 terminate_after: None,
29 grace_period: Duration::from_secs(10),
30 on_timeout: SlowTimeoutResult::Fail,
31 };
32}
33
34#[cfg(feature = "config-schema")]
35impl schemars::JsonSchema for SlowTimeout {
36 fn schema_name() -> std::borrow::Cow<'static, str> {
37 "SlowTimeout".into()
38 }
39
40 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
41 let on_timeout = generator.subschema_for::<SlowTimeoutResult>();
42 slow_timeout_schema(generator, "SlowTimeout", on_timeout)
43 }
44}
45
46#[cfg(feature = "config-schema")]
47pub(in crate::config) fn slow_timeout_schema(
48 generator: &mut schemars::SchemaGenerator,
49 title: &str,
50 on_timeout: schemars::Schema,
51) -> schemars::Schema {
52 schemars::json_schema!({
53 "title": title,
54 "oneOf": [
55 generator.subschema_for::<String>(),
56 {
57 "type": "object",
58 "properties": {
59 "period": generator.subschema_for::<String>(),
60 "terminate-after": {
61 "type": ["integer", "null"],
62 "minimum": 1,
63 },
64 "grace-period": generator.subschema_for::<String>(),
65 "on-timeout": on_timeout,
66 },
67 "required": ["period"],
68 "additionalProperties": false,
69 }
70 ]
71 })
72}
73
74fn default_grace_period() -> Duration {
75 Duration::from_secs(10)
76}
77
78pub(in crate::config) fn deserialize_slow_timeout<'de, D>(
79 deserializer: D,
80) -> Result<Option<SlowTimeout>, D::Error>
81where
82 D: serde::Deserializer<'de>,
83{
84 struct V;
85
86 impl<'de2> serde::de::Visitor<'de2> for V {
87 type Value = Option<SlowTimeout>;
88
89 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
90 write!(
91 formatter,
92 "a table ({{ period = \"60s\", terminate-after = 2 }}) or a string (\"60s\")"
93 )
94 }
95
96 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
97 where
98 E: serde::de::Error,
99 {
100 if v.is_empty() {
101 Ok(None)
102 } else {
103 let period = humantime_serde::deserialize(v.into_deserializer())?;
104 Ok(Some(SlowTimeout {
105 period,
106 terminate_after: None,
107 grace_period: default_grace_period(),
108 on_timeout: SlowTimeoutResult::Fail,
109 }))
110 }
111 }
112
113 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
114 where
115 A: serde::de::MapAccess<'de2>,
116 {
117 SlowTimeout::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Some)
118 }
119 }
120
121 deserializer.deserialize_any(V)
122}
123
124#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
133#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
134#[serde(rename_all = "kebab-case")]
135#[cfg_attr(test, derive(test_strategy::Arbitrary))]
136pub enum SlowTimeoutResult {
137 #[default]
138 Fail,
140
141 Pass,
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::{
149 config::{core::NextestConfig, utils::test_helpers::*},
150 run_mode::NextestRunMode,
151 };
152 use camino_tempfile::tempdir;
153 use indoc::indoc;
154 use nextest_filtering::ParseContext;
155 use test_case::test_case;
156
157 #[test_case(
158 "",
159 Ok(SlowTimeout {
160 period: Duration::from_secs(60),
161 terminate_after: None,
162 grace_period: Duration::from_secs(10),
163 on_timeout: SlowTimeoutResult::Fail,
164 }),
165 None
166 ; "empty config is expected to use the hardcoded values"
167 )]
168 #[test_case(
169 indoc! {r#"
170 [profile.default]
171 slow-timeout = "30s"
172 "#},
173 Ok(SlowTimeout {
174 period: Duration::from_secs(30),
175 terminate_after: None,
176 grace_period: Duration::from_secs(10),
177 on_timeout: SlowTimeoutResult::Fail,
178 }),
179 None
180 ; "overrides the default profile"
181 )]
182 #[test_case(
183 indoc! {r#"
184 [profile.default]
185 slow-timeout = "30s"
186
187 [profile.ci]
188 slow-timeout = { period = "60s", terminate-after = 3 }
189 "#},
190 Ok(SlowTimeout {
191 period: Duration::from_secs(30),
192 terminate_after: None,
193 grace_period: Duration::from_secs(10),
194 on_timeout: SlowTimeoutResult::Fail,
195 }),
196 Some(SlowTimeout {
197 period: Duration::from_secs(60),
198 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
199 grace_period: Duration::from_secs(10),
200 on_timeout: SlowTimeoutResult::Fail,
201 })
202 ; "adds a custom profile 'ci'"
203 )]
204 #[test_case(
205 indoc! {r#"
206 [profile.default]
207 slow-timeout = { period = "60s", terminate-after = 3 }
208
209 [profile.ci]
210 slow-timeout = "30s"
211 "#},
212 Ok(SlowTimeout {
213 period: Duration::from_secs(60),
214 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
215 grace_period: Duration::from_secs(10),
216 on_timeout: SlowTimeoutResult::Fail,
217 }),
218 Some(SlowTimeout {
219 period: Duration::from_secs(30),
220 terminate_after: None,
221 grace_period: Duration::from_secs(10),
222 on_timeout: SlowTimeoutResult::Fail,
223 })
224 ; "ci profile uses string notation"
225 )]
226 #[test_case(
227 indoc! {r#"
228 [profile.default]
229 slow-timeout = { period = "60s", terminate-after = 3, grace-period = "1s" }
230
231 [profile.ci]
232 slow-timeout = "30s"
233 "#},
234 Ok(SlowTimeout {
235 period: Duration::from_secs(60),
236 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
237 grace_period: Duration::from_secs(1),
238 on_timeout: SlowTimeoutResult::Fail,
239 }),
240 Some(SlowTimeout {
241 period: Duration::from_secs(30),
242 terminate_after: None,
243 grace_period: Duration::from_secs(10),
244 on_timeout: SlowTimeoutResult::Fail,
245 })
246 ; "timeout grace period"
247 )]
248 #[test_case(
249 indoc! {r#"
250 [profile.default]
251 slow-timeout = { period = "60s" }
252 "#},
253 Ok(SlowTimeout {
254 period: Duration::from_secs(60),
255 terminate_after: None,
256 grace_period: Duration::from_secs(10),
257 on_timeout: SlowTimeoutResult::Fail,
258 }),
259 None
260 ; "partial table"
261 )]
262 #[test_case(
263 indoc! {r#"
264 [profile.default]
265 slow-timeout = { period = "60s", terminate-after = 0 }
266 "#},
267 Err("original: invalid value: integer `0`, expected a nonzero usize"),
268 None
269 ; "zero terminate-after should fail"
270 )]
271 #[test_case(
272 indoc! {r#"
273 [profile.default]
274 slow-timeout = { period = "60s", on-timeout = "pass" }
275 "#},
276 Ok(SlowTimeout {
277 period: Duration::from_secs(60),
278 terminate_after: None,
279 grace_period: Duration::from_secs(10),
280 on_timeout: SlowTimeoutResult::Pass,
281 }),
282 None
283 ; "timeout result success"
284 )]
285 #[test_case(
286 indoc! {r#"
287 [profile.default]
288 slow-timeout = { period = "60s", on-timeout = "fail" }
289 "#},
290 Ok(SlowTimeout {
291 period: Duration::from_secs(60),
292 terminate_after: None,
293 grace_period: Duration::from_secs(10),
294 on_timeout: SlowTimeoutResult::Fail,
295 }),
296 None
297 ; "timeout result failure"
298 )]
299 #[test_case(
300 indoc! {r#"
301 [profile.default]
302 slow-timeout = { period = "60s", on-timeout = "pass" }
303
304 [profile.ci]
305 slow-timeout = { period = "30s", on-timeout = "fail" }
306 "#},
307 Ok(SlowTimeout {
308 period: Duration::from_secs(60),
309 terminate_after: None,
310 grace_period: Duration::from_secs(10),
311 on_timeout: SlowTimeoutResult::Pass,
312 }),
313 Some(SlowTimeout {
314 period: Duration::from_secs(30),
315 terminate_after: None,
316 grace_period: Duration::from_secs(10),
317 on_timeout: SlowTimeoutResult::Fail,
318 })
319 ; "override on-timeout option"
320 )]
321 #[test_case(
322 indoc! {r#"
323 [profile.default]
324 slow-timeout = "60s"
325
326 [profile.ci]
327 slow-timeout = { terminate-after = 3 }
328 "#},
329 Err("original: missing configuration field \"profile.ci.slow-timeout.period\""),
330 None
331
332 ; "partial slow-timeout table should error"
333 )]
334 fn slowtimeout_adheres_to_hierarchy(
335 config_contents: &str,
336 expected_default: Result<SlowTimeout, &str>,
337 maybe_expected_ci: Option<SlowTimeout>,
338 ) {
339 let workspace_dir = tempdir().unwrap();
340
341 let graph = temp_workspace(&workspace_dir, config_contents);
342
343 let pcx = ParseContext::new(&graph);
344
345 let nextest_config_result = NextestConfig::from_sources(
346 graph.workspace().root(),
347 &pcx,
348 None,
349 &[][..],
350 &Default::default(),
351 );
352
353 match expected_default {
354 Ok(expected_default) => {
355 let nextest_config = nextest_config_result.expect("config file should parse");
356
357 assert_eq!(
358 nextest_config
359 .profile("default")
360 .expect("default profile should exist")
361 .apply_build_platforms(&build_platforms())
362 .slow_timeout(NextestRunMode::Test),
363 expected_default,
364 );
365
366 if let Some(expected_ci) = maybe_expected_ci {
367 assert_eq!(
368 nextest_config
369 .profile("ci")
370 .expect("ci profile should exist")
371 .apply_build_platforms(&build_platforms())
372 .slow_timeout(NextestRunMode::Test),
373 expected_ci,
374 );
375 }
376 }
377
378 Err(expected_err_str) => {
379 let err_str = format!("{:?}", nextest_config_result.unwrap_err());
380
381 assert!(
382 err_str.contains(expected_err_str),
383 "expected error string not found: {err_str}",
384 )
385 }
386 }
387 }
388
389 const DEFAULT_TEST_SLOW_TIMEOUT: SlowTimeout = SlowTimeout {
391 period: Duration::from_secs(60),
392 terminate_after: None,
393 grace_period: Duration::from_secs(10),
394 on_timeout: SlowTimeoutResult::Fail,
395 };
396
397 #[derive(Debug)]
399 enum ExpectedBenchTimeout {
400 Exact(SlowTimeout),
402 VeryLarge,
405 }
406
407 #[test_case(
408 "",
409 DEFAULT_TEST_SLOW_TIMEOUT,
410 ExpectedBenchTimeout::VeryLarge
411 ; "empty config uses defaults for both modes"
412 )]
413 #[test_case(
414 indoc! {r#"
415 [profile.default]
416 slow-timeout = { period = "10s", terminate-after = 2 }
417 "#},
418 SlowTimeout {
419 period: Duration::from_secs(10),
420 terminate_after: Some(NonZeroUsize::new(2).unwrap()),
421 grace_period: Duration::from_secs(10),
422 on_timeout: SlowTimeoutResult::Fail,
423 },
424 ExpectedBenchTimeout::VeryLarge
426 ; "slow-timeout does not affect bench.slow-timeout"
427 )]
428 #[test_case(
429 indoc! {r#"
430 [profile.default]
431 bench.slow-timeout = { period = "20s", terminate-after = 3 }
432 "#},
433 DEFAULT_TEST_SLOW_TIMEOUT,
435 ExpectedBenchTimeout::Exact(SlowTimeout {
436 period: Duration::from_secs(20),
437 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
438 grace_period: Duration::from_secs(10),
439 on_timeout: SlowTimeoutResult::Fail,
440 })
441 ; "bench.slow-timeout does not affect slow-timeout"
442 )]
443 #[test_case(
444 indoc! {r#"
445 [profile.default]
446 slow-timeout = { period = "10s", terminate-after = 2 }
447 bench.slow-timeout = { period = "20s", terminate-after = 3 }
448 "#},
449 SlowTimeout {
450 period: Duration::from_secs(10),
451 terminate_after: Some(NonZeroUsize::new(2).unwrap()),
452 grace_period: Duration::from_secs(10),
453 on_timeout: SlowTimeoutResult::Fail,
454 },
455 ExpectedBenchTimeout::Exact(SlowTimeout {
456 period: Duration::from_secs(20),
457 terminate_after: Some(NonZeroUsize::new(3).unwrap()),
458 grace_period: Duration::from_secs(10),
459 on_timeout: SlowTimeoutResult::Fail,
460 })
461 ; "both slow-timeout and bench.slow-timeout can be set independently"
462 )]
463 #[test_case(
464 indoc! {r#"
465 [profile.default]
466 bench.slow-timeout = "30s"
467 "#},
468 DEFAULT_TEST_SLOW_TIMEOUT,
469 ExpectedBenchTimeout::Exact(SlowTimeout {
470 period: Duration::from_secs(30),
471 terminate_after: None,
472 grace_period: Duration::from_secs(10),
473 on_timeout: SlowTimeoutResult::Fail,
474 })
475 ; "bench.slow-timeout string notation"
476 )]
477 fn bench_slowtimeout_is_independent(
478 config_contents: &str,
479 expected_test_timeout: SlowTimeout,
480 expected_bench_timeout: ExpectedBenchTimeout,
481 ) {
482 let workspace_dir = tempdir().unwrap();
483
484 let graph = temp_workspace(&workspace_dir, config_contents);
485
486 let pcx = ParseContext::new(&graph);
487
488 let nextest_config = NextestConfig::from_sources(
489 graph.workspace().root(),
490 &pcx,
491 None,
492 &[][..],
493 &Default::default(),
494 )
495 .expect("config file should parse");
496
497 let profile = nextest_config
498 .profile("default")
499 .expect("default profile should exist")
500 .apply_build_platforms(&build_platforms());
501
502 assert_eq!(
503 profile.slow_timeout(NextestRunMode::Test),
504 expected_test_timeout,
505 "Test mode slow-timeout mismatch"
506 );
507
508 let actual_bench_timeout = profile.slow_timeout(NextestRunMode::Benchmark);
509 match expected_bench_timeout {
510 ExpectedBenchTimeout::Exact(expected) => {
511 assert_eq!(
512 actual_bench_timeout, expected,
513 "Benchmark mode slow-timeout mismatch"
514 );
515 }
516 ExpectedBenchTimeout::VeryLarge => {
517 assert!(
520 actual_bench_timeout.period >= SlowTimeout::VERY_LARGE.period,
521 "Benchmark mode slow-timeout should be >= VERY_LARGE, got {:?}",
522 actual_bench_timeout.period
523 );
524 assert_eq!(
525 actual_bench_timeout.terminate_after, None,
526 "Benchmark mode terminate_after should be None"
527 );
528 }
529 }
530 }
531}