Skip to main content

polyc_runtime/
supervise.rs

1//! Server-task supervision: run a binary's spawned servers until a shutdown
2//! signal, treating any earlier exit as fatal.
3//!
4//! A process whose serving task has died must flip readiness off and exit
5//! (so the orchestrator restarts it) rather than keep answering `/readyz`
6//! over a dead surface.
7
8use std::time::Duration;
9
10use tokio::task::JoinSet;
11use tokio_util::sync::CancellationToken;
12
13use crate::health::Health;
14
15/// Default grace before remaining tasks are hard-aborted, when
16/// `POLYCHROME_DRAIN_GRACE_SECS` is unset. Comfortably under the Kubernetes
17/// default `terminationGracePeriodSeconds` (30s) so a wedged task can't drag the
18/// drain past the grace window and force a SIGKILL.
19const DEFAULT_DRAIN_GRACE: Duration = Duration::from_secs(25);
20
21/// Resolve the drain grace from `POLYCHROME_DRAIN_GRACE_SECS`, falling back to
22/// [`DEFAULT_DRAIN_GRACE`].
23///
24/// The right budget is **per-deployment**: a binary running long in-flight work
25/// (the harness drains LLM turns and sets `terminationGracePeriodSeconds: 150`)
26/// must drain for most of its grace, while edges/control-plane keep the 30s
27/// default. A single compile-time const would either truncate the harness's
28/// drain or overrun an edge's grace, so it is read from the environment and the
29/// deployment sets it to sit just under its own `terminationGracePeriodSeconds`.
30fn drain_grace() -> Duration {
31    std::env::var("POLYCHROME_DRAIN_GRACE_SECS")
32        .ok()
33        .and_then(|v| v.parse::<u64>().ok())
34        .map_or(DEFAULT_DRAIN_GRACE, Duration::from_secs)
35}
36
37/// Block until `shutdown` is cancelled or any task in `servers` finishes.
38///
39/// Either way, readiness is flipped off, `shutdown` is cancelled, and the
40/// remaining tasks are drained. A task finishing before the shutdown signal —
41/// even cleanly — is an error: servers run until told to stop.
42///
43/// # Errors
44///
45/// Returns the first server failure: an exit before shutdown, a serve error
46/// surfaced during drain, or a panic.
47pub async fn until_shutdown(
48    mut servers: JoinSet<anyhow::Result<()>>,
49    health: &Health,
50    shutdown: &CancellationToken,
51) -> anyhow::Result<()> {
52    let early = tokio::select! {
53        // Biased so an already-delivered shutdown signal reads as graceful
54        // even when a server task has (consequently) already finished.
55        biased;
56        () = shutdown.cancelled() => None,
57        res = servers.join_next() => res,
58    };
59    health.set_ready(false);
60    shutdown.cancel();
61
62    let mut failure = early.map(|res| {
63        task_failure(res).map_or_else(
64            || anyhow::anyhow!("server exited cleanly before shutdown"),
65            |err| err.context("server exited before shutdown"),
66        )
67    });
68    if failure.is_some() {
69        tracing::error!("server task exited before shutdown; draining and exiting");
70    } else {
71        tracing::info!("shutdown signal received; draining in-flight work");
72    }
73
74    drain_bounded(&mut servers, &mut failure, drain_grace()).await;
75    failure.map_or(Ok(()), Err)
76}
77
78/// Drain `servers`, recording the first failure into `failure`, but no longer
79/// than `grace`. If the grace window expires with tasks still running, they are
80/// hard-aborted and reaped so a wedged task can't block shutdown indefinitely
81/// (an abort is our own doing, so cancelled tasks are not counted as failures).
82async fn drain_bounded(
83    servers: &mut JoinSet<anyhow::Result<()>>,
84    failure: &mut Option<anyhow::Error>,
85    grace: Duration,
86) {
87    let reap = async {
88        while let Some(res) = servers.join_next().await {
89            if let Some(err) = task_failure(res) {
90                tracing::error!(error = ?err, "server task failed during drain");
91                failure.get_or_insert(err);
92            }
93        }
94    };
95    if tokio::time::timeout(grace, reap).await.is_err() {
96        tracing::error!(
97            grace_secs = grace.as_secs(),
98            "drain exceeded grace window; aborting remaining server tasks"
99        );
100        servers.abort_all();
101        // Reap the remaining tasks. A genuine failure that surfaced right at the
102        // grace boundary (a serve error or panic) must still be recorded — only
103        // the cancellations from our own `abort_all` are not failures.
104        while let Some(res) = servers.join_next().await {
105            match res {
106                Ok(Ok(())) => {}
107                Ok(Err(err)) => {
108                    tracing::error!(error = ?err, "server task failed during bounded drain");
109                    failure.get_or_insert(err);
110                }
111                // Our own abort — expected, not a failure.
112                Err(join) if join.is_cancelled() => {}
113                Err(join) => {
114                    let err = anyhow::Error::new(join).context("server task panicked");
115                    tracing::error!(error = ?err, "server task panicked during bounded drain");
116                    failure.get_or_insert(err);
117                }
118            }
119        }
120    }
121}
122
123/// The error inside a finished task's join outcome, if any: a serve error or
124/// a panic. A clean `Ok(())` exit returns `None`.
125fn task_failure(res: Result<anyhow::Result<()>, tokio::task::JoinError>) -> Option<anyhow::Error> {
126    match res {
127        Ok(Ok(())) => None,
128        Ok(Err(err)) => Some(err),
129        Err(join) => Some(anyhow::Error::new(join).context("server task panicked")),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
136
137    use std::time::Duration;
138
139    use tokio::task::JoinSet;
140    use tokio_util::sync::CancellationToken;
141
142    use super::{drain_bounded, until_shutdown};
143    use crate::health::Health;
144
145    #[tokio::test]
146    async fn graceful_shutdown_drains_and_returns_ok() {
147        let health = Health::new();
148        let shutdown = CancellationToken::new();
149        let mut servers = JoinSet::new();
150        for _ in 0..2 {
151            let shutdown = shutdown.clone();
152            servers.spawn(async move {
153                shutdown.cancelled().await;
154                Ok(())
155            });
156        }
157        health.set_ready(true);
158
159        shutdown.cancel();
160        until_shutdown(servers, &health, &shutdown)
161            .await
162            .expect("graceful shutdown is not an error");
163    }
164
165    #[tokio::test]
166    async fn failing_server_is_fatal_and_cancels_the_rest() {
167        let health = Health::new();
168        let shutdown = CancellationToken::new();
169        let mut servers = JoinSet::new();
170        servers.spawn(async { Err(anyhow::anyhow!("bind lost")) });
171        // The healthy peer must be drained via the cancelled token, proving
172        // the early exit propagates shutdown to the rest.
173        let peer = shutdown.clone();
174        servers.spawn(async move {
175            peer.cancelled().await;
176            Ok(())
177        });
178        health.set_ready(true);
179
180        let err = until_shutdown(servers, &health, &shutdown)
181            .await
182            .expect_err("a dead server is fatal");
183        assert!(err.to_string().contains("server exited before shutdown"));
184        assert!(
185            shutdown.is_cancelled(),
186            "remaining servers are told to stop"
187        );
188    }
189
190    #[tokio::test]
191    async fn clean_early_exit_is_still_fatal() {
192        let health = Health::new();
193        let shutdown = CancellationToken::new();
194        let mut servers = JoinSet::new();
195        servers.spawn(async { Ok(()) });
196
197        let err = until_shutdown(servers, &health, &shutdown)
198            .await
199            .expect_err("servers run until told to stop");
200        assert!(err.to_string().contains("exited cleanly before shutdown"));
201    }
202
203    #[tokio::test]
204    async fn drain_aborts_a_wedged_task_after_grace() {
205        let mut servers = JoinSet::new();
206        // A task that never completes (ignores the shutdown signal) — without a
207        // bounded drain this would hang until the orchestrator SIGKILLs the pod.
208        servers.spawn(async {
209            std::future::pending::<()>().await;
210            Ok(())
211        });
212        let mut failure = None;
213
214        let start = std::time::Instant::now();
215        drain_bounded(&mut servers, &mut failure, Duration::from_millis(50)).await;
216
217        assert!(
218            start.elapsed() < Duration::from_secs(5),
219            "drain is bounded by the grace window, not the wedged task"
220        );
221        assert!(
222            failure.is_none(),
223            "a task we aborted ourselves is not counted as a failure"
224        );
225        assert!(
226            servers.is_empty(),
227            "remaining tasks were aborted and reaped"
228        );
229    }
230
231    #[tokio::test]
232    async fn drain_records_a_failure_even_when_grace_expires() {
233        let mut servers = JoinSet::new();
234        // A wedged task forces the grace-timeout / abort path…
235        servers.spawn(async {
236            std::future::pending::<()>().await;
237            Ok(())
238        });
239        // …while a real serve error must still be surfaced, not swallowed as
240        // "our own abort".
241        servers.spawn(async { Err(anyhow::anyhow!("serve error during drain")) });
242        let mut failure = None;
243
244        drain_bounded(&mut servers, &mut failure, Duration::from_millis(50)).await;
245
246        let err = failure.expect("a genuine failure during the bounded drain must be recorded");
247        assert!(err.to_string().contains("serve error during drain"));
248    }
249}