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