1use std::{collections::HashMap, future::Future, time::Duration};
2
3use color_eyre::eyre::{WrapErr as _, eyre};
4use tokio::task::{Id, JoinError, JoinSet};
5use tokio_util::sync::CancellationToken;
6
7pub struct NamedTask {
10 name: &'static str,
11 handle: tokio::task::JoinHandle<crate::Result<()>>,
12}
13
14impl NamedTask {
15 pub fn spawn<F>(name: &'static str, future: F) -> Self
17 where
18 F: Future<Output = crate::Result<()>> + Send + 'static,
19 {
20 Self {
21 name,
22 handle: tokio::spawn(future),
23 }
24 }
25
26 pub fn name(&self) -> &'static str {
28 self.name
29 }
30}
31
32pub async fn wait_for_first_task(
38 tasks: Vec<NamedTask>,
39) -> (
40 &'static str,
41 Result<crate::Result<()>, tokio::task::JoinError>,
42) {
43 let (handles, names): (Vec<_>, Vec<_>) = tasks.into_iter().map(|t| (t.handle, t.name)).unzip();
44
45 let (result, index, _remaining) = futures::future::select_all(handles).await;
46 (names[index], result)
47}
48
49pub async fn wait_for_first_error(tasks: Vec<NamedTask>) -> crate::Result<()> {
55 if tasks.is_empty() {
56 return Ok(());
57 }
58
59 let (name, result) = wait_for_first_task(tasks).await;
60
61 match result {
62 Ok(Ok(())) => {
63 tracing::error!(task = name, "Task exited unexpectedly");
64 Err(eyre!("Task '{}' exited unexpectedly", name))
65 }
66 Ok(Err(e)) => {
67 tracing::error!(task = name, error = ?e, "Task failed with error");
68 Err(e)
69 }
70 Err(join_error) => {
71 tracing::error!(task = name, error = ?join_error, "Task panicked");
72 Err(eyre!("Task '{}' panicked: {}", name, join_error))
73 }
74 }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct ShutdownBudget {
88 pub job_drain: Duration,
92 pub exit_grace: Duration,
95}
96
97impl Default for ShutdownBudget {
98 fn default() -> Self {
99 Self {
100 job_drain: Duration::from_secs(2),
101 exit_grace: Duration::from_secs(2),
102 }
103 }
104}
105
106impl ShutdownBudget {
107 #[must_use]
110 pub fn from_env() -> Self {
111 let defaults = Self::default();
112 Self {
113 job_drain: env_secs("CJA_SHUTDOWN_JOB_DRAIN_SECS").unwrap_or(defaults.job_drain),
114 exit_grace: env_secs("CJA_SHUTDOWN_EXIT_GRACE_SECS").unwrap_or(defaults.exit_grace),
115 }
116 }
117
118 #[must_use]
120 pub fn deadline(&self) -> Duration {
121 self.job_drain + self.exit_grace
122 }
123
124 fn warn_if_over_platform_window(&self) {
128 const CLOUD_RUN_WINDOW: Duration = Duration::from_secs(10);
129
130 if std::env::var_os("K_SERVICE").is_some() && self.deadline() >= CLOUD_RUN_WINDOW {
131 tracing::warn!(
132 deadline_secs = self.deadline().as_secs(),
133 window_secs = CLOUD_RUN_WINDOW.as_secs(),
134 "Shutdown budget does not fit Cloud Run's termination window; \
135 in-flight job locks will be stranded on every deploy"
136 );
137 }
138 }
139}
140
141fn env_secs(name: &str) -> Option<Duration> {
142 std::env::var(name)
143 .ok()?
144 .parse()
145 .ok()
146 .map(Duration::from_secs)
147}
148
149pub struct Supervisor {
193 tasks: JoinSet<crate::Result<()>>,
194 names: HashMap<Id, &'static str>,
195 shutdown: CancellationToken,
196 budget: ShutdownBudget,
197 signals: ShutdownSignals,
198}
199
200impl Supervisor {
201 pub fn new(budget: ShutdownBudget) -> crate::Result<Self> {
208 budget.warn_if_over_platform_window();
209 Ok(Self {
210 tasks: JoinSet::new(),
211 names: HashMap::new(),
212 shutdown: CancellationToken::new(),
213 budget,
214 signals: ShutdownSignals::register()?,
215 })
216 }
217
218 #[must_use]
221 pub fn shutdown_token(&self) -> CancellationToken {
222 self.shutdown.clone()
223 }
224
225 #[must_use]
226 pub fn budget(&self) -> ShutdownBudget {
227 self.budget
228 }
229
230 pub fn spawn<F>(&mut self, name: &'static str, future: F)
232 where
233 F: Future<Output = crate::Result<()>> + Send + 'static,
234 {
235 let handle = self.tasks.spawn(future);
236 self.names.insert(handle.id(), name);
237 }
238
239 pub async fn run(mut self) -> crate::Result<()> {
247 let signals = std::mem::take(&mut self.signals);
248 self.run_until(signals.recv()).await
249 }
250
251 async fn run_until(mut self, signal: impl Future<Output = &'static str>) -> crate::Result<()> {
252 if self.tasks.is_empty() {
253 return Ok(());
254 }
255
256 let result = tokio::select! {
257 signal = signal => {
258 tracing::info!(
259 signal,
260 deadline_secs = self.budget.deadline().as_secs(),
261 "Shutdown signal received; draining"
262 );
263 Ok(())
264 }
265 Some(outcome) = self.tasks.join_next_with_id() => {
266 let (name, result) = self.outcome(outcome);
267 let error = match result {
268 Ok(()) => eyre!("Task '{name}' exited unexpectedly"),
269 Err(error) => error,
270 };
271 tracing::error!(task = name, error = %format!("{error:#}"), "Task ended the process");
272 Err(error)
273 }
274 };
275
276 self.shutdown.cancel();
277 let drained = tokio::time::timeout(self.budget.deadline(), async {
278 while let Some(outcome) = self.tasks.join_next_with_id().await {
279 match self.outcome(outcome) {
280 (name, Ok(())) => tracing::debug!(task = name, "Task stopped"),
281 (name, Err(error)) => {
282 tracing::warn!(task = name, error = %format!("{error:#}"), "Task failed while stopping");
283 }
284 }
285 }
286 })
287 .await;
288
289 if drained.is_ok() {
290 tracing::info!("All tasks stopped");
291 } else {
292 let mut stragglers: Vec<_> = self.names.values().copied().collect();
293 stragglers.sort_unstable();
294 tracing::warn!(
295 ?stragglers,
296 "Shutdown deadline reached; aborting remaining tasks"
297 );
298 self.tasks.shutdown().await;
299 }
300
301 result
302 }
303
304 fn outcome(
306 &mut self,
307 outcome: Result<(Id, crate::Result<()>), JoinError>,
308 ) -> (&'static str, crate::Result<()>) {
309 match outcome {
310 Ok((id, result)) => {
311 let name = self.names.remove(&id).unwrap_or("unknown");
312 (
313 name,
314 result.wrap_err_with(|| format!("Task '{name}' failed")),
315 )
316 }
317 Err(error) => {
318 let name = self.names.remove(&error.id()).unwrap_or("unknown");
319 (name, Err(eyre!("Task '{name}' panicked: {error}")))
320 }
321 }
322 }
323}
324
325#[derive(Default)]
328struct ShutdownSignals {
329 #[cfg(unix)]
330 unix: Option<(tokio::signal::unix::Signal, tokio::signal::unix::Signal)>,
331}
332
333impl ShutdownSignals {
334 #[cfg(unix)]
335 fn register() -> crate::Result<Self> {
336 use tokio::signal::unix::{SignalKind, signal};
337
338 let sigterm = signal(SignalKind::terminate()).wrap_err("Failed to register SIGTERM")?;
339 let sigint = signal(SignalKind::interrupt()).wrap_err("Failed to register SIGINT")?;
340 Ok(Self {
341 unix: Some((sigterm, sigint)),
342 })
343 }
344
345 #[cfg(not(unix))]
346 #[allow(clippy::unnecessary_wraps)]
347 fn register() -> crate::Result<Self> {
348 Ok(Self {})
349 }
350
351 #[cfg(unix)]
352 async fn recv(self) -> &'static str {
353 let Some((mut sigterm, mut sigint)) = self.unix else {
354 return std::future::pending().await;
355 };
356 tokio::select! {
357 _ = sigterm.recv() => "SIGTERM",
358 _ = sigint.recv() => "SIGINT",
359 }
360 }
361
362 #[cfg(not(unix))]
363 async fn recv(self) -> &'static str {
364 match tokio::signal::ctrl_c().await {
365 Ok(()) => "Ctrl-C",
366 Err(_) => std::future::pending().await,
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use std::sync::{
374 Arc,
375 atomic::{AtomicBool, Ordering},
376 };
377
378 use super::*;
379
380 const BUDGET: ShutdownBudget = ShutdownBudget {
381 job_drain: Duration::from_secs(5),
382 exit_grace: Duration::from_secs(3),
383 };
384
385 fn spawn_cooperative(supervisor: &mut Supervisor, name: &'static str) -> Arc<AtomicBool> {
387 let stopped = Arc::new(AtomicBool::new(false));
388 let flag = stopped.clone();
389 let shutdown = supervisor.shutdown_token();
390 supervisor.spawn(name, async move {
391 shutdown.cancelled().await;
392 flag.store(true, Ordering::SeqCst);
393 Ok(())
394 });
395 stopped
396 }
397
398 #[tokio::test(start_paused = true)]
399 async fn signal_stops_every_task_and_exits_cleanly() {
400 let mut supervisor = Supervisor::new(BUDGET).unwrap();
401 let shutdown = supervisor.shutdown_token();
402 let jobs = spawn_cooperative(&mut supervisor, "jobs");
403 let cron = spawn_cooperative(&mut supervisor, "cron");
404
405 let result = supervisor.run_until(async { "SIGTERM" }).await;
406
407 assert!(result.is_ok(), "a signal is a clean exit: {result:?}");
408 assert!(shutdown.is_cancelled());
409 assert!(jobs.load(Ordering::SeqCst) && cron.load(Ordering::SeqCst));
410 }
411
412 #[tokio::test(start_paused = true)]
413 async fn task_ignoring_shutdown_is_aborted_at_the_deadline() {
414 let mut supervisor = Supervisor::new(BUDGET).unwrap();
415 spawn_cooperative(&mut supervisor, "jobs");
416 let finished = Arc::new(AtomicBool::new(false));
417 let flag = finished.clone();
418 supervisor.spawn("server", async move {
419 tokio::time::sleep(Duration::from_hours(1)).await;
420 flag.store(true, Ordering::SeqCst);
421 Ok(())
422 });
423
424 let started = tokio::time::Instant::now();
425 let result = supervisor.run_until(async { "SIGTERM" }).await;
426
427 assert!(
428 result.is_ok(),
429 "stragglers do not turn a signal into a failure"
430 );
431 assert_eq!(started.elapsed(), BUDGET.deadline());
432 tokio::time::sleep(Duration::from_hours(2)).await;
433 assert!(
434 !finished.load(Ordering::SeqCst),
435 "the straggler must be aborted, not left running"
436 );
437 }
438
439 #[tokio::test(start_paused = true)]
440 async fn task_ending_on_its_own_fails_the_process_but_still_drains_its_peers() {
441 for mode in ["error", "exit", "panic"] {
442 let mut supervisor = Supervisor::new(BUDGET).unwrap();
443 let peer = spawn_cooperative(&mut supervisor, "jobs");
444 supervisor.spawn("cron", async move {
445 match mode {
446 "error" => Err(eyre!("database went away")),
447 "panic" => panic!("cron blew up"),
448 _ => Ok(()),
449 }
450 });
451
452 let error = supervisor
453 .run_until(std::future::pending())
454 .await
455 .expect_err("a task ending by itself is a failure");
456
457 let rendered = format!("{error:#}");
458 let expected = match mode {
459 "error" => "database went away",
460 "panic" => "cron blew up",
461 _ => "exited unexpectedly",
462 };
463 assert!(rendered.contains("'cron'"), "{mode}: {rendered}");
464 assert!(rendered.contains(expected), "{mode}: {rendered}");
465 assert!(
466 peer.load(Ordering::SeqCst),
467 "{mode}: surviving workers must get the chance to release their locks"
468 );
469 }
470 }
471
472 #[tokio::test]
473 async fn no_tasks_returns_immediately() {
474 let supervisor = Supervisor::new(BUDGET).unwrap();
475 assert!(supervisor.run_until(std::future::pending()).await.is_ok());
476 }
477
478 #[test]
479 fn default_budget_fits_the_smallest_platform_window() {
480 assert!(ShutdownBudget::default().deadline() < Duration::from_secs(5));
482 }
483}