polyc_runtime/
supervise.rs1use std::time::Duration;
9
10use tokio::task::JoinSet;
11use tokio_util::sync::CancellationToken;
12
13use crate::health::Health;
14
15const DEFAULT_DRAIN_GRACE: Duration = Duration::from_secs(25);
20
21#[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
52pub 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
85pub 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;
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
126async 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 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 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
171fn 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 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 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 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 servers.spawn(async {
318 std::future::pending::<()>().await;
319 Ok(())
320 });
321 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}