Skip to main content

baukit_runtime/
lib.rs

1//! Process-wide lifecycle and listener composition for Baukit services.
2//!
3//! This crate owns mechanics shared by an entire process: shutdown signaling,
4//! bounded draining, background-task supervision, build identity, and the thin
5//! composition of public and operations listeners. Products continue to own
6//! configuration, routes, and application state.
7//!
8//! # Composition example
9//!
10//! A composition root can wire the crate to `baukit-config` and
11//! `baukit-telemetry` without making runtime own either product configuration or
12//! telemetry policy:
13//!
14//! ```no_run
15//! use std::sync::Arc;
16//! use axum::Router;
17//! use baukit_runtime::{
18//!     ProcessKind, RestartPolicy, ServiceInfo, ShutdownToken, TaskSupervisor,
19//!     ShutdownOrder, build_info, serve_listener_pair_with_shutdown_order,
20//! };
21//! use baukit_telemetry::{DeploymentEnvironment, TelemetryBuilder};
22//! use tokio::net::TcpListener;
23//! # use std::time::Duration;
24//! # async fn run_outbox_until(_: ShutdownToken) {}
25//! # fn product_routes() -> Router { Router::new() }
26//! # fn operations_routes() -> Router { Router::new() }
27//!
28//! #[tokio::main]
29//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
30//!     let service = ServiceInfo::new(
31//!         "orders",
32//!         ProcessKind::Api,
33//!         build_info!(),
34//!         DeploymentEnvironment::Local,
35//!     );
36//!     let telemetry = Arc::new(
37//!         TelemetryBuilder::new(service.telemetry_identity().clone()).init()?,
38//!     );
39//!     let shutdown = ShutdownToken::new(Duration::from_secs(30));
40//!     let signal_task = shutdown.spawn_signal_listener();
41//!
42//!     let mut workers = TaskSupervisor::new(shutdown.clone());
43//!     let worker_shutdown = shutdown.child_token();
44//!     workers.spawn("outbox", RestartPolicy::Restart { max_restarts: 3 }, move || {
45//!         let worker_shutdown = worker_shutdown.clone();
46//!         async move { run_outbox_until(worker_shutdown).await }
47//!     });
48//!
49//!     let api = TcpListener::bind("127.0.0.1:0").await?;
50//!     let ops = TcpListener::bind("127.0.0.1:0").await?;
51//!     let listener_result = serve_listener_pair_with_shutdown_order(
52//!         api,
53//!         Router::new().merge(product_routes()),
54//!         ops,
55//!         Router::new().merge(operations_routes()),
56//!         shutdown.clone(),
57//!         ShutdownOrder::OpsOutlivesApi,
58//!     ).await;
59//!     shutdown.trigger();
60//!     let _ = workers.join().await;
61//!
62//!     // Telemetry shutdown is synchronous, so keep it off the async executor.
63//!     // `run_during_drain` still bounds it by the process-wide drain deadline.
64//!     let telemetry_for_flush = Arc::clone(&telemetry);
65//!     shutdown.run_during_drain(async move {
66//!         tokio::task::spawn_blocking(move || telemetry_for_flush.shutdown()).await
67//!     }).await???;
68//!
69//!     signal_task.abort();
70//!     listener_result?;
71//!     Ok(())
72//! }
73//! ```
74
75#![deny(missing_docs)]
76
77use std::{
78    fmt,
79    future::Future,
80    io,
81    panic::{AssertUnwindSafe, catch_unwind},
82    sync::{Arc, Mutex, OnceLock},
83    time::Duration,
84};
85
86use axum::Router;
87pub use baukit_core::{BuildInfo, DeploymentEnvironment, ProcessKind, ServiceIdentity};
88use thiserror::Error;
89use tokio::{
90    net::TcpListener,
91    task::{JoinError, JoinHandle, JoinSet},
92    time::{Instant, timeout},
93};
94use tokio_util::sync::CancellationToken;
95
96/// The Rust compiler version used to build this crate.
97///
98/// [`build_info!`] combines this with package metadata captured at its call site.
99pub const RUST_VERSION: &str = env!("BAUKIT_RUST_VERSION");
100
101/// Captures build metadata from the binary crate in which the macro is invoked.
102///
103/// `GIT_COMMIT` should be set by the build or release pipeline. It falls back to
104/// `"unknown"` for local builds. The Rust compiler version is recorded by
105/// `baukit-runtime`'s build script.
106#[macro_export]
107macro_rules! build_info {
108    () => {
109        $crate::BuildInfo::new(
110            env!("CARGO_PKG_VERSION"),
111            option_env!("GIT_COMMIT").unwrap_or("unknown"),
112            $crate::RUST_VERSION,
113        )
114    };
115}
116
117/// Canonical service identity plus its complete process build metadata.
118///
119/// The service name follows the telemetry convention `<product>-<process>`.
120/// Keeping the canonical [`ServiceIdentity`] inside this value ensures runtime,
121/// logging, metrics, and traces cannot disagree about the process identity.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct ServiceInfo {
124    name: String,
125    identity: ServiceIdentity,
126    build: BuildInfo,
127}
128
129impl ServiceInfo {
130    /// Creates service information using Baukit's canonical service-name format.
131    pub fn new(
132        product: impl Into<String>,
133        process: ProcessKind,
134        build: BuildInfo,
135        environment: DeploymentEnvironment,
136    ) -> Self {
137        let product = product.into();
138        let identity = ServiceIdentity::new(
139            product,
140            process,
141            build.version().to_owned(),
142            build.commit().to_owned(),
143            environment,
144        );
145        let name = identity.service_name();
146        Self {
147            name,
148            identity,
149            build,
150        }
151    }
152
153    /// Returns the canonical `<product>-<process>` service name.
154    pub fn name(&self) -> &str {
155        &self.name
156    }
157
158    /// Returns the stable product identifier.
159    pub fn product(&self) -> &str {
160        self.identity.product()
161    }
162
163    /// Returns this process's kind.
164    pub const fn process(&self) -> ProcessKind {
165        self.identity.process()
166    }
167
168    /// Returns the service's build metadata.
169    pub const fn build(&self) -> &BuildInfo {
170        &self.build
171    }
172
173    /// Returns the identity accepted directly by `baukit-telemetry`.
174    pub const fn telemetry_identity(&self) -> &ServiceIdentity {
175        &self.identity
176    }
177}
178
179struct ShutdownState {
180    root: CancellationToken,
181    started_at: OnceLock<Instant>,
182    drain_hooks: Mutex<Vec<DrainHook>>,
183}
184
185type DrainHook = Box<dyn FnOnce() + Send + 'static>;
186
187impl fmt::Debug for ShutdownState {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        let hook_count = self
190            .drain_hooks
191            .lock()
192            .unwrap_or_else(std::sync::PoisonError::into_inner)
193            .len();
194        formatter
195            .debug_struct("ShutdownState")
196            .field("root", &self.root)
197            .field("started_at", &self.started_at)
198            .field("drain_hook_count", &hook_count)
199            .finish()
200    }
201}
202
203/// A cloneable, awaitable process-shutdown handle with one shared drain deadline.
204///
205/// Child tokens observe parent shutdown, while calling [`ShutdownToken::trigger`]
206/// on any child still initiates process-wide shutdown. The deadline begins with
207/// the first trigger and is shared by all clones and children.
208#[derive(Clone, Debug)]
209pub struct ShutdownToken {
210    observed: CancellationToken,
211    state: Arc<ShutdownState>,
212    drain_timeout: Duration,
213}
214
215/// A concise alias for [`ShutdownToken`].
216pub type Shutdown = ShutdownToken;
217
218impl ShutdownToken {
219    /// Creates a shutdown handle with the maximum duration allowed for draining.
220    pub fn new(drain_timeout: Duration) -> Self {
221        let root = CancellationToken::new();
222        Self {
223            observed: root.clone(),
224            state: Arc::new(ShutdownState {
225                root,
226                started_at: OnceLock::new(),
227                drain_hooks: Mutex::new(Vec::new()),
228            }),
229            drain_timeout,
230        }
231    }
232
233    /// Returns a child which is cancelled by this token.
234    ///
235    /// Triggering the child deliberately triggers the shared process token too.
236    pub fn child_token(&self) -> Self {
237        Self {
238            observed: self.observed.child_token(),
239            state: Arc::clone(&self.state),
240            drain_timeout: self.drain_timeout,
241        }
242    }
243
244    /// Initiates process-wide shutdown and returns whether this was the first trigger.
245    pub fn trigger(&self) -> bool {
246        let first = self.state.started_at.set(Instant::now()).is_ok();
247        if first {
248            let hooks = std::mem::take(
249                &mut *self
250                    .state
251                    .drain_hooks
252                    .lock()
253                    .unwrap_or_else(std::sync::PoisonError::into_inner),
254            );
255            for hook in hooks {
256                if catch_unwind(AssertUnwindSafe(hook)).is_err() {
257                    tracing::error!("a shutdown drain hook panicked");
258                }
259            }
260            self.state.root.cancel();
261        }
262        first
263    }
264
265    /// Registers a fast synchronous action to run when drain begins.
266    ///
267    /// Hooks run exactly once on the first [`ShutdownToken::trigger`], before
268    /// cancellation wakes asynchronous drain work. A hook registered after
269    /// drain has started runs immediately. This is the dependency-neutral
270    /// composition seam for closing `baukit_ops::TrafficGate` without a watcher
271    /// task:
272    ///
273    /// ```no_run
274    /// # use std::time::Duration;
275    /// # use baukit_runtime::ShutdownToken;
276    /// # #[derive(Clone)] struct TrafficGate;
277    /// # impl TrafficGate { fn stop_accepting(&self) {} }
278    /// # let traffic_gate = TrafficGate;
279    /// let shutdown = ShutdownToken::new(Duration::from_secs(30));
280    /// shutdown.on_drain({
281    ///     let traffic_gate = traffic_gate.clone();
282    ///     move || traffic_gate.stop_accepting()
283    /// });
284    /// ```
285    ///
286    /// Hooks should only flip in-memory state or perform similarly bounded work;
287    /// asynchronous cleanup belongs in [`ShutdownToken::run_during_drain`].
288    pub fn on_drain<F>(&self, hook: F)
289    where
290        F: FnOnce() + Send + 'static,
291    {
292        let mut hook = Some(Box::new(hook) as DrainHook);
293        {
294            let mut hooks = self
295                .state
296                .drain_hooks
297                .lock()
298                .unwrap_or_else(std::sync::PoisonError::into_inner);
299            if self.state.started_at.get().is_none() {
300                hooks.push(hook.take().expect("drain hook is available"));
301            }
302        }
303        if let Some(hook) = hook
304            && catch_unwind(AssertUnwindSafe(hook)).is_err()
305        {
306            tracing::error!("a late shutdown drain hook panicked");
307        }
308    }
309
310    /// Returns whether this token has observed shutdown.
311    pub fn is_cancelled(&self) -> bool {
312        self.observed.is_cancelled()
313    }
314
315    /// Waits until process-wide shutdown reaches this token.
316    pub async fn cancelled(&self) {
317        self.observed.cancelled().await;
318    }
319
320    /// Returns the configured drain timeout.
321    pub const fn drain_timeout(&self) -> Duration {
322        self.drain_timeout
323    }
324
325    /// Returns the shared absolute drain deadline after shutdown has begun.
326    pub fn deadline(&self) -> Option<Instant> {
327        self.state
328            .started_at
329            .get()
330            .map(|started_at| *started_at + self.drain_timeout)
331    }
332
333    /// Waits for shutdown, then runs `future` within the remaining drain budget.
334    ///
335    /// Every call uses the same absolute deadline, so sequential cleanup steps
336    /// cannot accidentally receive a fresh timeout. On expiry, the future is
337    /// dropped and [`DrainTimeout`] is returned.
338    pub async fn run_during_drain<F>(&self, future: F) -> Result<F::Output, DrainTimeout>
339    where
340        F: Future,
341    {
342        self.cancelled().await;
343        let remaining = self.remaining();
344        timeout(remaining, future)
345            .await
346            .map_err(|_| DrainTimeout::new(self.drain_timeout))
347    }
348
349    /// Waits for SIGINT/SIGTERM (or the platform Ctrl-C event), then triggers shutdown.
350    pub async fn shutdown_on_signal(&self) -> io::Result<ShutdownSignal> {
351        let signal = wait_for_shutdown_signal().await?;
352        self.trigger();
353        Ok(signal)
354    }
355
356    /// Spawns a Tokio task which triggers shutdown on the first process signal.
357    ///
358    /// The returned task reports signal-handler setup failures when joined.
359    #[must_use = "retain or join the signal listener so setup failures are observed"]
360    pub fn spawn_signal_listener(&self) -> tokio::task::JoinHandle<io::Result<ShutdownSignal>> {
361        let shutdown = self.clone();
362        tokio::spawn(async move { shutdown.shutdown_on_signal().await })
363    }
364
365    fn remaining(&self) -> Duration {
366        self.deadline()
367            .map(|deadline| deadline.saturating_duration_since(Instant::now()))
368            .unwrap_or(self.drain_timeout)
369    }
370}
371
372/// The operating-system event which initiated shutdown.
373#[derive(Clone, Copy, Debug, Eq, PartialEq)]
374pub enum ShutdownSignal {
375    /// Ctrl-C or SIGINT was received.
376    Interrupt,
377    /// SIGTERM was received on Unix.
378    Terminate,
379}
380
381/// Waits for the first supported process shutdown signal without triggering a token.
382///
383/// Factoring signal observation from [`ShutdownToken::trigger`] keeps lifecycle
384/// behavior directly testable without delivering real process signals.
385#[cfg(unix)]
386pub async fn wait_for_shutdown_signal() -> io::Result<ShutdownSignal> {
387    use tokio::signal::unix::{SignalKind, signal};
388
389    let mut terminate = signal(SignalKind::terminate())?;
390    tokio::select! {
391        ctrl_c = tokio::signal::ctrl_c() => {
392            ctrl_c?;
393            Ok(ShutdownSignal::Interrupt)
394        }
395        received = terminate.recv() => received.map_or_else(
396            || Err(io::Error::other("SIGTERM signal stream ended")),
397            |_| Ok(ShutdownSignal::Terminate),
398        ),
399    }
400}
401
402/// Waits for the platform Ctrl-C event without triggering a token.
403#[cfg(not(unix))]
404pub async fn wait_for_shutdown_signal() -> io::Result<ShutdownSignal> {
405    tokio::signal::ctrl_c().await?;
406    Ok(ShutdownSignal::Interrupt)
407}
408
409/// Indicates that process draining exceeded its shared deadline.
410#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
411#[error("shutdown drain deadline expired after {timeout:?}")]
412pub struct DrainTimeout {
413    timeout: Duration,
414}
415
416impl DrainTimeout {
417    const fn new(timeout: Duration) -> Self {
418        Self { timeout }
419    }
420
421    /// Returns the configured total drain timeout.
422    pub const fn timeout(self) -> Duration {
423        self.timeout
424    }
425}
426
427/// The response to a supervised task's unexpected termination.
428#[derive(Clone, Copy, Debug, Eq, PartialEq)]
429pub enum RestartPolicy {
430    /// Trigger process shutdown immediately.
431    FailProcess,
432    /// Restart up to `max_restarts` times, then trigger process shutdown.
433    Restart {
434        /// Maximum starts after the initial attempt.
435        max_restarts: usize,
436    },
437}
438
439/// Owns named background tasks tied to one [`ShutdownToken`].
440pub struct TaskSupervisor {
441    shutdown: ShutdownToken,
442    tasks: JoinSet<()>,
443}
444
445impl TaskSupervisor {
446    /// Creates an empty task supervisor.
447    pub fn new(shutdown: ShutdownToken) -> Self {
448        Self {
449            shutdown,
450            tasks: JoinSet::new(),
451        }
452    }
453
454    /// Spawns a named task from a restartable future factory.
455    ///
456    /// Both a normal return and a panic are unexpected before shutdown. The
457    /// selected `policy` either restarts the factory or triggers process shutdown.
458    pub fn spawn<F, Fut>(&mut self, name: impl Into<String>, policy: RestartPolicy, factory: F)
459    where
460        F: Fn() -> Fut + Send + Sync + 'static,
461        Fut: Future<Output = ()> + Send + 'static,
462    {
463        let shutdown = self.shutdown.clone();
464        let name = name.into();
465        let factory = Arc::new(factory);
466        self.tasks.spawn(async move {
467            supervise_task(name, policy, factory, shutdown).await;
468        });
469    }
470
471    /// Waits for shutdown and joins all supervised tasks within its drain deadline.
472    ///
473    /// Any tasks still alive when the deadline expires are aborted before this
474    /// method returns [`DrainTimeout`].
475    pub async fn join(mut self) -> Result<(), DrainTimeout> {
476        self.shutdown.cancelled().await;
477        let remaining = self.shutdown.remaining();
478        let joined = timeout(remaining, async {
479            while let Some(result) = self.tasks.join_next().await {
480                if let Err(error) = result {
481                    tracing::error!(%error, "task supervisor failed while joining a worker");
482                }
483            }
484        })
485        .await;
486
487        if joined.is_err() {
488            self.tasks.abort_all();
489            while self.tasks.join_next().await.is_some() {}
490            return Err(DrainTimeout::new(self.shutdown.drain_timeout));
491        }
492
493        Ok(())
494    }
495
496    /// Returns the number of supervised task loops currently owned.
497    pub fn len(&self) -> usize {
498        self.tasks.len()
499    }
500
501    /// Returns whether no supervised tasks are currently owned.
502    pub fn is_empty(&self) -> bool {
503        self.tasks.is_empty()
504    }
505}
506
507async fn supervise_task<F, Fut>(
508    name: String,
509    policy: RestartPolicy,
510    factory: Arc<F>,
511    shutdown: ShutdownToken,
512) where
513    F: Fn() -> Fut + Send + Sync + 'static,
514    Fut: Future<Output = ()> + Send + 'static,
515{
516    let mut restarts = 0_usize;
517    loop {
518        let mut attempt = AbortOnDropTask(tokio::spawn(factory()));
519        let outcome = tokio::select! {
520            _ = shutdown.cancelled() => {
521                if let Err(error) = (&mut attempt.0).await
522                    && error.is_panic()
523                {
524                    tracing::error!(task = %name, %error, "supervised task panicked while draining");
525                }
526                return;
527            }
528            result = &mut attempt.0 => result,
529        };
530
531        if shutdown.is_cancelled() {
532            return;
533        }
534
535        match &outcome {
536            Ok(()) => tracing::error!(task = %name, "supervised task exited unexpectedly"),
537            Err(error) if error.is_panic() => {
538                tracing::error!(task = %name, %error, "supervised task panicked")
539            }
540            Err(error) => tracing::error!(task = %name, %error, "supervised task was cancelled"),
541        }
542
543        match policy {
544            RestartPolicy::FailProcess => {
545                shutdown.trigger();
546                return;
547            }
548            RestartPolicy::Restart { max_restarts } if restarts < max_restarts => {
549                restarts += 1;
550                tracing::warn!(task = %name, restarts, max_restarts, "restarting supervised task");
551            }
552            RestartPolicy::Restart { max_restarts } => {
553                tracing::error!(task = %name, restarts, max_restarts, "task restart limit exhausted");
554                shutdown.trigger();
555                return;
556            }
557        }
558    }
559}
560
561struct AbortOnDropTask<T>(JoinHandle<T>);
562
563impl<T> Drop for AbortOnDropTask<T> {
564    fn drop(&mut self) {
565        self.0.abort();
566    }
567}
568
569/// Identifies one member of an API/operations listener pair.
570#[derive(Clone, Copy, Debug, Eq, PartialEq)]
571pub enum ListenerKind {
572    /// The product API listener.
573    Api,
574    /// The operations listener.
575    Operations,
576}
577
578impl fmt::Display for ListenerKind {
579    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580        formatter.write_str(match self {
581            Self::Api => "API",
582            Self::Operations => "operations",
583        })
584    }
585}
586
587/// An error returned while running the API and operations listeners.
588#[derive(Debug, Error)]
589pub enum ListenerPairError {
590    /// A listener returned an I/O error.
591    #[error("{listener} listener failed: {source}")]
592    Listener {
593        /// The listener which failed.
594        listener: ListenerKind,
595        /// The underlying server error.
596        #[source]
597        source: io::Error,
598    },
599    /// A listener task panicked or was externally cancelled.
600    #[error("listener task failed: {0}")]
601    Task(#[source] JoinError),
602    /// A listener stopped successfully before shutdown was requested.
603    #[error("{0} listener exited before shutdown")]
604    UnexpectedExit(ListenerKind),
605    /// One or both listeners did not drain before the process deadline.
606    #[error(transparent)]
607    Drain(#[from] DrainTimeout),
608}
609
610type ListenerResult = Result<ListenerKind, (ListenerKind, io::Error)>;
611
612/// Controls when the operations listener stops relative to the public API.
613#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
614pub enum ShutdownOrder {
615    /// Stops both listeners together when process shutdown begins.
616    #[default]
617    Together,
618    /// Keeps operations serving until the API has drained or the deadline expires.
619    OpsOutlivesApi,
620}
621
622/// Runs public API and operations routers concurrently with graceful shutdown.
623///
624/// Both TCP listeners are supplied already bound, keeping addresses and socket
625/// policy in the composition root. If either listener fails, process shutdown is
626/// triggered and the peer is drained. Once shutdown begins, both Axum servers
627/// receive the token through `with_graceful_shutdown`; remaining work is bounded
628/// by the token's shared deadline and forcefully aborted on expiry.
629pub async fn serve_listener_pair(
630    api_listener: TcpListener,
631    api_router: Router,
632    operations_listener: TcpListener,
633    operations_router: Router,
634    shutdown: ShutdownToken,
635) -> Result<(), ListenerPairError> {
636    serve_listener_pair_with_shutdown_order(
637        api_listener,
638        api_router,
639        operations_listener,
640        operations_router,
641        shutdown,
642        ShutdownOrder::Together,
643    )
644    .await
645}
646
647/// Runs an API/operations listener pair with an explicit shutdown order.
648///
649/// [`ShutdownOrder::Together`] is identical to [`serve_listener_pair`]. With
650/// [`ShutdownOrder::OpsOutlivesApi`], process shutdown first stops the API from
651/// accepting new work and lets its in-flight requests drain. The operations
652/// listener continues serving readiness and metrics until the API server exits;
653/// it is then gracefully stopped within the same process-wide drain deadline.
654/// If that deadline expires first, both listener tasks are aborted.
655pub async fn serve_listener_pair_with_shutdown_order(
656    api_listener: TcpListener,
657    api_router: Router,
658    operations_listener: TcpListener,
659    operations_router: Router,
660    shutdown: ShutdownToken,
661    shutdown_order: ShutdownOrder,
662) -> Result<(), ListenerPairError> {
663    let mut listeners = JoinSet::new();
664    let operations_shutdown = match shutdown_order {
665        ShutdownOrder::Together => shutdown.observed.clone(),
666        ShutdownOrder::OpsOutlivesApi => CancellationToken::new(),
667    };
668    spawn_listener(
669        &mut listeners,
670        ListenerKind::Api,
671        api_listener,
672        api_router,
673        shutdown.observed.clone(),
674    );
675    spawn_listener(
676        &mut listeners,
677        ListenerKind::Operations,
678        operations_listener,
679        operations_router,
680        operations_shutdown.clone(),
681    );
682
683    let mut first_error = None;
684    while !listeners.is_empty() && !shutdown.is_cancelled() {
685        tokio::select! {
686            _ = shutdown.cancelled() => {}
687            result = listeners.join_next() => {
688                stop_operations_after_api(
689                    result.as_ref(),
690                    shutdown_order,
691                    &operations_shutdown,
692                );
693                record_listener_result(result, &shutdown, &mut first_error);
694            }
695        }
696    }
697
698    if listeners.is_empty() {
699        return first_error.map_or(Ok(()), Err);
700    }
701
702    let remaining = shutdown.remaining();
703    let drained = timeout(remaining, async {
704        while let Some(result) = listeners.join_next().await {
705            stop_operations_after_api(Some(&result), shutdown_order, &operations_shutdown);
706            record_listener_result(Some(result), &shutdown, &mut first_error);
707        }
708    })
709    .await;
710
711    if drained.is_err() {
712        listeners.abort_all();
713        while listeners.join_next().await.is_some() {}
714        return Err(DrainTimeout::new(shutdown.drain_timeout).into());
715    }
716
717    first_error.map_or(Ok(()), Err)
718}
719
720fn spawn_listener(
721    listeners: &mut JoinSet<ListenerResult>,
722    kind: ListenerKind,
723    listener: TcpListener,
724    router: Router,
725    shutdown: CancellationToken,
726) {
727    listeners.spawn(async move {
728        axum::serve(listener, router)
729            .with_graceful_shutdown(async move { shutdown.cancelled().await })
730            .await
731            .map(|()| kind)
732            .map_err(|error| (kind, error))
733    });
734}
735
736fn stop_operations_after_api(
737    result: Option<&Result<ListenerResult, JoinError>>,
738    shutdown_order: ShutdownOrder,
739    operations_shutdown: &CancellationToken,
740) {
741    if shutdown_order != ShutdownOrder::OpsOutlivesApi {
742        return;
743    }
744
745    let listener = match result {
746        Some(Ok(Ok(kind) | Err((kind, _)))) => Some(*kind),
747        Some(Err(_)) | None => None,
748    };
749    if listener == Some(ListenerKind::Api) {
750        operations_shutdown.cancel();
751    }
752}
753
754fn record_listener_result(
755    result: Option<Result<ListenerResult, JoinError>>,
756    shutdown: &ShutdownToken,
757    first_error: &mut Option<ListenerPairError>,
758) {
759    let error = match result {
760        Some(Ok(Ok(kind))) if !shutdown.is_cancelled() => {
761            Some(ListenerPairError::UnexpectedExit(kind))
762        }
763        Some(Ok(Ok(_))) | None => None,
764        Some(Ok(Err((listener, source)))) => Some(ListenerPairError::Listener { listener, source }),
765        Some(Err(error)) => Some(ListenerPairError::Task(error)),
766    };
767
768    if let Some(error) = error {
769        tracing::error!(%error, "listener pair initiated process shutdown");
770        if first_error.is_none() {
771            *first_error = Some(error);
772        }
773        shutdown.trigger();
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use std::{
780        future::pending,
781        net::{IpAddr, Ipv4Addr, SocketAddr},
782        sync::{
783            Arc,
784            atomic::{AtomicUsize, Ordering},
785        },
786    };
787
788    use axum::routing::get;
789    use tokio::{
790        io::{AsyncReadExt as _, AsyncWriteExt as _},
791        net::TcpStream,
792        sync::Notify,
793    };
794
795    use super::*;
796
797    async fn http_get(address: SocketAddr, path: &str) -> String {
798        let mut stream = TcpStream::connect(address)
799            .await
800            .expect("connect to test listener");
801        let request =
802            format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n");
803        stream
804            .write_all(request.as_bytes())
805            .await
806            .expect("write test request");
807        let mut response = Vec::new();
808        stream
809            .read_to_end(&mut response)
810            .await
811            .expect("read test response");
812        String::from_utf8(response).expect("HTTP response is UTF-8")
813    }
814
815    #[tokio::test]
816    async fn token_cancels_children() {
817        let shutdown = ShutdownToken::new(Duration::from_secs(5));
818        let child = shutdown.child_token();
819        assert!(!child.is_cancelled());
820
821        assert!(shutdown.trigger());
822        child.cancelled().await;
823
824        assert!(child.is_cancelled());
825        assert!(!shutdown.trigger());
826    }
827
828    #[tokio::test]
829    async fn drain_hooks_run_before_cancellation_and_late_hooks_run_immediately() {
830        let shutdown = ShutdownToken::new(Duration::from_secs(5));
831        let calls = Arc::new(AtomicUsize::new(0));
832        shutdown.on_drain({
833            let calls = Arc::clone(&calls);
834            let shutdown = shutdown.clone();
835            move || {
836                assert!(!shutdown.is_cancelled());
837                calls.fetch_add(1, Ordering::SeqCst);
838            }
839        });
840
841        shutdown.trigger();
842        assert!(shutdown.is_cancelled());
843        assert_eq!(calls.load(Ordering::SeqCst), 1);
844
845        shutdown.on_drain({
846            let calls = Arc::clone(&calls);
847            move || {
848                calls.fetch_add(1, Ordering::SeqCst);
849            }
850        });
851        assert_eq!(calls.load(Ordering::SeqCst), 2);
852        assert!(!shutdown.trigger());
853        assert_eq!(calls.load(Ordering::SeqCst), 2);
854    }
855
856    #[tokio::test(start_paused = true)]
857    async fn drain_deadline_is_enforced() {
858        let shutdown = ShutdownToken::new(Duration::from_secs(7));
859        shutdown.trigger();
860        let drain = tokio::spawn({
861            let shutdown = shutdown.clone();
862            async move { shutdown.run_during_drain(pending::<()>()).await }
863        });
864
865        tokio::time::advance(Duration::from_secs(7)).await;
866        let error = drain
867            .await
868            .expect("drain task should join")
869            .expect_err("pending cleanup should exceed the deadline");
870        assert_eq!(error.timeout(), Duration::from_secs(7));
871    }
872
873    #[tokio::test]
874    async fn supervisor_restarts_until_a_worker_stays_running() {
875        let shutdown = ShutdownToken::new(Duration::from_secs(2));
876        let worker_shutdown = shutdown.child_token();
877        let attempts = Arc::new(AtomicUsize::new(0));
878        let drained = Arc::new(AtomicUsize::new(0));
879        let mut supervisor = TaskSupervisor::new(shutdown.clone());
880        supervisor.spawn("restartable", RestartPolicy::Restart { max_restarts: 2 }, {
881            let attempts = Arc::clone(&attempts);
882            let drained = Arc::clone(&drained);
883            move || {
884                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
885                let worker_shutdown = worker_shutdown.clone();
886                let drained = Arc::clone(&drained);
887                async move {
888                    if attempt < 2 {
889                        return;
890                    }
891                    worker_shutdown.cancelled().await;
892                    drained.fetch_add(1, Ordering::SeqCst);
893                }
894            }
895        });
896
897        tokio::time::timeout(Duration::from_secs(1), async {
898            while attempts.load(Ordering::SeqCst) < 3 {
899                tokio::task::yield_now().await;
900            }
901        })
902        .await
903        .expect("worker should be restarted twice");
904
905        shutdown.trigger();
906        supervisor.join().await.expect("worker should drain");
907        assert_eq!(attempts.load(Ordering::SeqCst), 3);
908        assert_eq!(drained.load(Ordering::SeqCst), 1);
909    }
910
911    #[tokio::test]
912    async fn exhausted_restart_policy_triggers_process_shutdown() {
913        let shutdown = ShutdownToken::new(Duration::from_secs(2));
914        let attempts = Arc::new(AtomicUsize::new(0));
915        let mut supervisor = TaskSupervisor::new(shutdown.clone());
916        supervisor.spawn("exhausted", RestartPolicy::Restart { max_restarts: 1 }, {
917            let attempts = Arc::clone(&attempts);
918            move || {
919                attempts.fetch_add(1, Ordering::SeqCst);
920                async {}
921            }
922        });
923
924        tokio::time::timeout(Duration::from_secs(1), shutdown.cancelled())
925            .await
926            .expect("restart exhaustion should trigger shutdown");
927        supervisor.join().await.expect("supervisor should join");
928        assert_eq!(attempts.load(Ordering::SeqCst), 2);
929    }
930
931    #[tokio::test]
932    async fn supervisor_panic_triggers_process_shutdown() {
933        let shutdown = ShutdownToken::new(Duration::from_secs(2));
934        let mut supervisor = TaskSupervisor::new(shutdown.clone());
935        supervisor.spawn("panicking", RestartPolicy::FailProcess, || async {
936            panic!("intentional worker panic");
937        });
938
939        tokio::time::timeout(Duration::from_secs(1), shutdown.cancelled())
940            .await
941            .expect("panic should trigger shutdown");
942        supervisor.join().await.expect("supervisor should join");
943    }
944
945    #[tokio::test]
946    async fn listener_pair_shuts_down_when_token_is_triggered() {
947        let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
948        let api = TcpListener::bind(address).await.expect("bind API listener");
949        let operations = TcpListener::bind(address)
950            .await
951            .expect("bind operations listener");
952        let shutdown = ShutdownToken::new(Duration::from_secs(2));
953        let pair = tokio::spawn(serve_listener_pair(
954            api,
955            Router::new(),
956            operations,
957            Router::new(),
958            shutdown.clone(),
959        ));
960
961        tokio::task::yield_now().await;
962        shutdown.trigger();
963
964        tokio::time::timeout(Duration::from_secs(1), pair)
965            .await
966            .expect("listeners should finish promptly")
967            .expect("listener task should join")
968            .expect("listeners should drain successfully");
969    }
970
971    #[tokio::test]
972    async fn operations_listener_responds_while_api_is_draining() {
973        let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
974        let api = TcpListener::bind(address).await.expect("bind API listener");
975        let api_address = api.local_addr().expect("API address");
976        let operations = TcpListener::bind(address)
977            .await
978            .expect("bind operations listener");
979        let operations_address = operations.local_addr().expect("operations address");
980        let request_started = Arc::new(Notify::new());
981        let release_request = Arc::new(Notify::new());
982        let api_router = Router::new().route(
983            "/drain",
984            get({
985                let request_started = Arc::clone(&request_started);
986                let release_request = Arc::clone(&release_request);
987                move || {
988                    let request_started = Arc::clone(&request_started);
989                    let release_request = Arc::clone(&release_request);
990                    async move {
991                        request_started.notify_one();
992                        release_request.notified().await;
993                        "drained"
994                    }
995                }
996            }),
997        );
998        let operations_router = Router::new().route("/readyz", get(|| async { "ready" }));
999        let shutdown = ShutdownToken::new(Duration::from_secs(2));
1000        let pair = tokio::spawn(serve_listener_pair_with_shutdown_order(
1001            api,
1002            api_router,
1003            operations,
1004            operations_router,
1005            shutdown.clone(),
1006            ShutdownOrder::OpsOutlivesApi,
1007        ));
1008        let api_request = tokio::spawn(http_get(api_address, "/drain"));
1009
1010        request_started.notified().await;
1011        shutdown.trigger();
1012        assert!(shutdown.is_cancelled());
1013
1014        let operations_response = http_get(operations_address, "/readyz").await;
1015        assert!(operations_response.starts_with("HTTP/1.1 200 OK"));
1016        assert!(operations_response.ends_with("ready"));
1017
1018        release_request.notify_one();
1019        let api_response = api_request.await.expect("API request task joins");
1020        assert!(api_response.starts_with("HTTP/1.1 200 OK"));
1021        tokio::time::timeout(Duration::from_secs(1), pair)
1022            .await
1023            .expect("listeners should finish after the API drains")
1024            .expect("listener task should join")
1025            .expect("listeners should drain successfully");
1026    }
1027
1028    #[test]
1029    fn service_info_reuses_telemetry_identity() {
1030        let build = BuildInfo::new("1.2.3", "abc123", "1.95.0");
1031        let service = ServiceInfo::new(
1032            "orders",
1033            ProcessKind::Worker,
1034            build,
1035            DeploymentEnvironment::Staging,
1036        );
1037
1038        assert_eq!(service.name(), "orders-worker");
1039        assert_eq!(service.product(), "orders");
1040        assert_eq!(service.build().rust_version(), "1.95.0");
1041        assert_eq!(service.telemetry_identity().version(), "1.2.3");
1042    }
1043}
1044
1045// Compiles the README's examples so they cannot drift from the API.
1046#[doc = include_str!("../README.md")]
1047#[cfg(doctest)]
1048struct ReadmeDoctests;