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]
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
44pub 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;
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
85async 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 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 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
130fn 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 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 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 servers.spawn(async {
243 std::future::pending::<()>().await;
244 Ok(())
245 });
246 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}