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