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