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 awaits, #1370 review finding 3 — can bound those awaits against
35/// this budget instead of inventing one.
36///
37/// Such a caller must spend what is LEFT of this budget. It must not take a
38/// fresh copy. The post-drain awaits run after `until_shutdown` has already
39/// spent part of the budget. They also run one after another. A fresh copy per
40/// phase lets the total reach a multiple of what the deployment sized. The
41/// kubelet then kills the pod mid-drain. `polyc-control-plane` stamps an
42/// `Instant` before `until_shutdown`. It subtracts the elapsed time at each
43/// later phase.
44#[must_use]
45pub fn drain_grace() -> Duration {
46    std::env::var("POLYCHROME_DRAIN_GRACE_SECS")
47        .ok()
48        .and_then(|v| v.parse::<u64>().ok())
49        .map_or(DEFAULT_DRAIN_GRACE, Duration::from_secs)
50}
51
52/// Run `future` inside what is LEFT of the drain budget, measured from
53/// `started`.
54///
55/// Post-drain work runs after [`until_shutdown`]. That call has already spent
56/// part of the budget. Each later phase also runs after the last one. A phase
57/// that takes its own copy of [`drain_grace`] lets the total reach a multiple
58/// of what the deployment sized. The kubelet then kills the pod mid-drain.
59///
60/// Stamp one `Instant` before [`until_shutdown`]. Pass it to every phase. This
61/// function computes the budget. A caller passes a future and a start time. It
62/// never passes a duration. There is no budget argument to get wrong.
63///
64/// This does not make the rule unbreakable. A caller can still reach for
65/// `tokio::time::timeout` with a duration of its own. The compiler accepts
66/// that. What this removes is the easy mistake. A reviewer sees one call shape
67/// in the shutdown path. Anything else stands out.
68///
69/// # Errors
70///
71/// Returns [`tokio::time::error::Elapsed`] when the remaining budget runs out
72/// before `future` finishes.
73///
74/// The budget can be zero. `tokio::time::timeout` polls the future once before
75/// it reads the delay. A future that is ready on that first poll still returns
76/// `Ok`. A zero budget denies further time to become ready. It does not deny
77/// the first poll.
78pub async fn within_drain_budget<F: std::future::Future>(
79    started: std::time::Instant,
80    future: F,
81) -> Result<F::Output, tokio::time::error::Elapsed> {
82    tokio::time::timeout(drain_grace().saturating_sub(started.elapsed()), future).await
83}
84
85/// Block until `shutdown` is cancelled or any task in `servers` finishes.
86///
87/// Either way, readiness is flipped off, `shutdown` is cancelled, and the
88/// remaining tasks are drained. A task finishing before the shutdown signal —
89/// even cleanly — is an error: servers run until told to stop.
90///
91/// # Errors
92///
93/// Returns the first server failure: an exit before shutdown, a serve error
94/// surfaced during drain, or a panic.
95pub async fn until_shutdown(
96    mut servers: JoinSet<anyhow::Result<()>>,
97    health: &Health,
98    shutdown: &CancellationToken,
99) -> anyhow::Result<()> {
100    let early = tokio::select! {
101        // Biased so an already-delivered shutdown signal reads as graceful
102        // even when a server task has (consequently) already finished.
103        biased;
104        () = shutdown.cancelled() => None,
105        res = servers.join_next() => res,
106    };
107    health.set_ready(false);
108    shutdown.cancel();
109
110    let mut failure = early.map(|res| {
111        task_failure(res).map_or_else(
112            || anyhow::anyhow!("server exited cleanly before shutdown"),
113            |err| err.context("server exited before shutdown"),
114        )
115    });
116    if failure.is_some() {
117        tracing::error!("server task exited before shutdown; draining and exiting");
118    } else {
119        tracing::info!("shutdown signal received; draining in-flight work");
120    }
121
122    drain_bounded(&mut servers, &mut failure, drain_grace()).await;
123    failure.map_or(Ok(()), Err)
124}
125
126/// Drain `servers`, recording the first failure into `failure`, but no longer
127/// than `grace`. If the grace window expires with tasks still running, they are
128/// hard-aborted and reaped so a wedged task can't block shutdown indefinitely
129/// (an abort is our own doing, so cancelled tasks are not counted as failures).
130async fn drain_bounded(
131    servers: &mut JoinSet<anyhow::Result<()>>,
132    failure: &mut Option<anyhow::Error>,
133    grace: Duration,
134) {
135    let reap = async {
136        while let Some(res) = servers.join_next().await {
137            if let Some(err) = task_failure(res) {
138                tracing::error!(error = ?err, "server task failed during drain");
139                failure.get_or_insert(err);
140            }
141        }
142    };
143    if tokio::time::timeout(grace, reap).await.is_err() {
144        tracing::error!(
145            grace_secs = grace.as_secs(),
146            "drain exceeded grace window; aborting remaining server tasks"
147        );
148        servers.abort_all();
149        // Reap the remaining tasks. A genuine failure that surfaced right at the
150        // grace boundary (a serve error or panic) must still be recorded — only
151        // the cancellations from our own `abort_all` are not failures.
152        while let Some(res) = servers.join_next().await {
153            match res {
154                Ok(Ok(())) => {}
155                Ok(Err(err)) => {
156                    tracing::error!(error = ?err, "server task failed during bounded drain");
157                    failure.get_or_insert(err);
158                }
159                // Our own abort — expected, not a failure.
160                Err(join) if join.is_cancelled() => {}
161                Err(join) => {
162                    let err = anyhow::Error::new(join).context("server task panicked");
163                    tracing::error!(error = ?err, "server task panicked during bounded drain");
164                    failure.get_or_insert(err);
165                }
166            }
167        }
168    }
169}
170
171/// The error inside a finished task's join outcome, if any: a serve error or
172/// a panic. A clean `Ok(())` exit returns `None`.
173fn task_failure(res: Result<anyhow::Result<()>, tokio::task::JoinError>) -> Option<anyhow::Error> {
174    match res {
175        Ok(Ok(())) => None,
176        Ok(Err(err)) => Some(err),
177        Err(join) => Some(anyhow::Error::new(join).context("server task panicked")),
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
184
185    use std::time::Duration;
186
187    use tokio::task::JoinSet;
188    use tokio_util::sync::CancellationToken;
189
190    use super::{drain_bounded, until_shutdown, within_drain_budget};
191    use crate::health::Health;
192
193    #[tokio::test]
194    async fn graceful_shutdown_drains_and_returns_ok() {
195        let health = Health::new();
196        let shutdown = CancellationToken::new();
197        let mut servers = JoinSet::new();
198        for _ in 0..2 {
199            let shutdown = shutdown.clone();
200            servers.spawn(async move {
201                shutdown.cancelled().await;
202                Ok(())
203            });
204        }
205        health.set_ready(true);
206
207        shutdown.cancel();
208        until_shutdown(servers, &health, &shutdown)
209            .await
210            .expect("graceful shutdown is not an error");
211    }
212
213    #[tokio::test]
214    async fn failing_server_is_fatal_and_cancels_the_rest() {
215        let health = Health::new();
216        let shutdown = CancellationToken::new();
217        let mut servers = JoinSet::new();
218        servers.spawn(async { Err(anyhow::anyhow!("bind lost")) });
219        // The healthy peer must be drained via the cancelled token, proving
220        // the early exit propagates shutdown to the rest.
221        let peer = shutdown.clone();
222        servers.spawn(async move {
223            peer.cancelled().await;
224            Ok(())
225        });
226        health.set_ready(true);
227
228        let err = until_shutdown(servers, &health, &shutdown)
229            .await
230            .expect_err("a dead server is fatal");
231        assert!(err.to_string().contains("server exited before shutdown"));
232        assert!(
233            shutdown.is_cancelled(),
234            "remaining servers are told to stop"
235        );
236    }
237
238    #[tokio::test]
239    async fn clean_early_exit_is_still_fatal() {
240        let health = Health::new();
241        let shutdown = CancellationToken::new();
242        let mut servers = JoinSet::new();
243        servers.spawn(async { Ok(()) });
244
245        let err = until_shutdown(servers, &health, &shutdown)
246            .await
247            .expect_err("servers run until told to stop");
248        assert!(err.to_string().contains("exited cleanly before shutdown"));
249    }
250
251    #[tokio::test]
252    async fn drain_aborts_a_wedged_task_after_grace() {
253        let mut servers = JoinSet::new();
254        // A task that never completes (ignores the shutdown signal) — without a
255        // bounded drain this would hang until the orchestrator SIGKILLs the pod.
256        servers.spawn(async {
257            std::future::pending::<()>().await;
258            Ok(())
259        });
260        let mut failure = None;
261
262        let start = std::time::Instant::now();
263        drain_bounded(&mut servers, &mut failure, Duration::from_millis(50)).await;
264
265        assert!(
266            start.elapsed() < Duration::from_secs(5),
267            "drain is bounded by the grace window, not the wedged task"
268        );
269        assert!(
270            failure.is_none(),
271            "a task we aborted ourselves is not counted as a failure"
272        );
273        assert!(
274            servers.is_empty(),
275            "remaining tasks were aborted and reaped"
276        );
277    }
278
279    #[tokio::test]
280    async fn a_spent_budget_denies_further_time_but_not_the_first_poll() {
281        // `tokio::time::timeout` polls the future before it reads the delay, so
282        // a zero budget still admits one poll. The doc on
283        // `within_drain_budget` states this, and the shutdown path relies on
284        // it: a lease release that can settle immediately still settles.
285        let spent = std::time::Instant::now()
286            .checked_sub(Duration::from_secs(3600))
287            .expect("an hour before now is representable");
288
289        let ready = within_drain_budget(spent, async { 7 })
290            .await
291            .expect("a future that is ready on the first poll survives a spent budget");
292        assert_eq!(ready, 7);
293
294        let pending = within_drain_budget(spent, std::future::pending::<()>()).await;
295        assert!(
296            pending.is_err(),
297            "a pending future gets no further time once the budget is spent"
298        );
299    }
300
301    #[tokio::test]
302    async fn an_unspent_budget_lets_a_slow_future_finish() {
303        let started = std::time::Instant::now();
304        let done = within_drain_budget(started, async {
305            tokio::time::sleep(Duration::from_millis(20)).await;
306            "settled"
307        })
308        .await
309        .expect("a short future fits inside a fresh budget");
310        assert_eq!(done, "settled");
311    }
312
313    #[tokio::test]
314    async fn drain_records_a_failure_even_when_grace_expires() {
315        let mut servers = JoinSet::new();
316        // A wedged task forces the grace-timeout / abort path…
317        servers.spawn(async {
318            std::future::pending::<()>().await;
319            Ok(())
320        });
321        // …while a real serve error must still be surfaced, not swallowed as
322        // "our own abort".
323        servers.spawn(async { Err(anyhow::anyhow!("serve error during drain")) });
324        let mut failure = None;
325
326        drain_bounded(&mut servers, &mut failure, Duration::from_millis(50)).await;
327
328        let err = failure.expect("a genuine failure during the bounded drain must be recorded");
329        assert!(err.to_string().contains("serve error during drain"));
330    }
331}