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
21fn 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
37pub 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;
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
78async 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 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 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
123fn 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 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 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 servers.spawn(async {
236 std::future::pending::<()>().await;
237 Ok(())
238 });
239 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}