1use std::collections::HashMap;
10use std::future::Future;
11use std::hash::Hash;
12use std::io;
13use std::pin::Pin;
14use std::time::Duration;
15
16use tokio::signal;
17use tokio::signal::unix;
18use tokio::task;
19use tokio::task::JoinSet;
20use tokio::time;
21use tokio::time::Instant;
22use tokio_util::sync::CancellationToken;
23use tracing::{error, info, warn};
24
25use crate::collector::Collector;
26use crate::pipeline::Pipeline;
27use crate::sink::Sink;
28
29type PipelineFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
30type PipelineFactory = Box<dyn Fn(CancellationToken) -> PipelineFuture + Send + Sync>;
31
32#[derive(Debug, thiserror::Error)]
34pub enum RuntimeError {
35 #[error("failed to install signal handler: {0}")]
36 Signal(#[source] io::Error),
37}
38
39pub struct MeathookBuilder {
41 factories: Vec<PipelineFactory>,
42 max_consecutive_failures: u32,
43 base_backoff: Duration,
44 failure_window: Duration,
46}
47
48impl Default for MeathookBuilder {
49 fn default() -> Self {
50 Self {
51 factories: vec![],
52 max_consecutive_failures: 5,
53 base_backoff: Duration::from_secs(1),
54 failure_window: Duration::from_secs(300),
55 }
56 }
57}
58
59impl MeathookBuilder {
60 #[must_use]
64 pub fn pipeline<C, S, F, K, Make>(mut self, factory: Make) -> Self
65 where
66 Make: Fn() -> Pipeline<C, S, F, K> + Send + Sync + 'static,
67 C: Collector + Send + 'static,
68 S: Sink<C::Record> + Send + 'static,
69 F: FnMut(&C::Record) -> K + Send + 'static,
70 K: Eq + Hash + Send + 'static,
71 {
72 self.factories
73 .push(Box::new(move |cancel| Box::pin(factory().run(cancel))));
74 self
75 }
76
77 #[must_use]
80 pub fn max_consecutive_failures(mut self, max: u32) -> Self {
81 self.max_consecutive_failures = max;
82 self
83 }
84
85 #[must_use]
87 pub fn base_backoff(mut self, base: Duration) -> Self {
88 self.base_backoff = base;
89 self
90 }
91
92 #[must_use]
93 pub fn build(self) -> Meathook {
94 Meathook { builder: self }
95 }
96
97 pub async fn run(self) -> Result<(), RuntimeError> {
103 self.build().run().await
104 }
105}
106
107pub struct Meathook {
109 builder: MeathookBuilder,
110}
111
112struct PipelineState {
113 failures: u32,
114 spawned_at: Instant,
115 gave_up: bool,
116}
117
118impl Meathook {
119 #[must_use]
120 pub fn builder() -> MeathookBuilder {
121 MeathookBuilder::default()
122 }
123
124 pub async fn run(self) -> Result<(), RuntimeError> {
131 let cancel = CancellationToken::new();
132 let signal_cancel = cancel.clone();
133
134 #[cfg(unix)]
135 let mut sigterm =
136 unix::signal(unix::SignalKind::terminate()).map_err(RuntimeError::Signal)?;
137 tokio::spawn(async move {
138 #[cfg(unix)]
139 tokio::select! {
140 _ = signal::ctrl_c() => info!("received ctrl-c; shutting down"),
141 _ = sigterm.recv() => info!("received SIGTERM; shutting down"),
142 }
143 #[cfg(not(unix))]
144 if signal::ctrl_c().await.is_ok() {
145 info!("received ctrl-c; shutting down");
146 }
147 signal_cancel.cancel();
148 });
149
150 self.run_with_shutdown(cancel).await;
151 Ok(())
152 }
153
154 pub async fn run_with_shutdown(self, cancel: CancellationToken) {
157 let MeathookBuilder {
158 factories,
159 max_consecutive_failures,
160 base_backoff,
161 failure_window,
162 } = self.builder;
163
164 let mut join_set = JoinSet::new();
165 let mut states: Vec<PipelineState> = Vec::with_capacity(factories.len());
166 let mut task_pipeline: HashMap<task::Id, usize> = HashMap::new();
167
168 for (index, factory) in factories.iter().enumerate() {
169 let handle = join_set.spawn(factory(cancel.child_token()));
170 task_pipeline.insert(handle.id(), index);
171 states.push(PipelineState {
172 failures: 0,
173 spawned_at: Instant::now(),
174 gave_up: false,
175 });
176 }
177
178 info!(pipelines = factories.len(), "meathook runtime started");
179
180 while let Some(joined) = join_set.join_next_with_id().await {
181 let (task_id, panicked) = match joined {
182 Ok((id, ())) => (id, false),
183 Err(join_error) => {
184 if join_error.is_cancelled() {
185 continue;
186 }
187 (join_error.id(), true)
188 }
189 };
190 let Some(&index) = task_pipeline.get(&task_id) else {
191 continue;
192 };
193
194 if !panicked || cancel.is_cancelled() {
195 continue;
198 }
199
200 let state = &mut states[index];
201 if state.spawned_at.elapsed() >= failure_window {
202 state.failures = 0;
203 }
204 state.failures += 1;
205
206 if state.failures > max_consecutive_failures {
207 if !state.gave_up {
208 state.gave_up = true;
209 error!(
210 pipeline = index,
211 failures = state.failures,
212 "pipeline keeps panicking; giving up on it"
213 );
214 }
215 continue;
216 }
217
218 let backoff = base_backoff.saturating_mul(1 << (state.failures - 1).min(16));
219 warn!(
220 pipeline = index,
221 failures = state.failures,
222 ?backoff,
223 "pipeline panicked; respawning from factory"
224 );
225
226 tokio::select! {
227 () = cancel.cancelled() => continue,
228 () = time::sleep(backoff) => {}
229 }
230
231 state.spawned_at = Instant::now();
232 let handle = join_set.spawn(factories[index](cancel.child_token()));
233 task_pipeline.insert(handle.id(), index);
234 }
235
236 info!("meathook runtime stopped");
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use std::convert::Infallible;
243 use std::sync::Arc;
244 use std::sync::atomic::{AtomicUsize, Ordering};
245
246 use super::*;
247 use crate::test_util::SharedSink;
248
249 struct PanicsOnce {
252 global_calls: Arc<AtomicUsize>,
253 }
254
255 impl Collector for PanicsOnce {
256 type Record = usize;
257 type Error = Infallible;
258
259 fn name(&self) -> &'static str {
260 "panics-once"
261 }
262
263 async fn collect(&mut self) -> Result<Vec<usize>, Infallible> {
264 let call = self.global_calls.fetch_add(1, Ordering::SeqCst);
265 assert!(call != 0, "boom");
266 Ok(vec![call])
267 }
268 }
269
270 #[tokio::test(start_paused = true)]
271 async fn respawns_panicked_pipeline_from_factory() {
272 let sink = SharedSink::<usize>::new();
273 let calls = Arc::new(AtomicUsize::new(0));
274 let built = Arc::new(AtomicUsize::new(0));
275
276 let runtime = {
277 let sink = sink.clone();
278 let calls = Arc::clone(&calls);
279 let built = Arc::clone(&built);
280 Meathook::builder()
281 .pipeline(move || {
282 built.fetch_add(1, Ordering::SeqCst);
283 Pipeline::new(
284 PanicsOnce {
285 global_calls: Arc::clone(&calls),
286 },
287 sink.clone(),
288 Duration::from_secs(60),
289 )
290 })
291 .build()
292 };
293
294 let cancel = CancellationToken::new();
295 let handle = tokio::spawn(runtime.run_with_shutdown(cancel.clone()));
296
297 time::sleep(Duration::from_secs(200)).await;
298 cancel.cancel();
299 handle.await.unwrap();
300
301 assert!(built.load(Ordering::SeqCst) >= 2, "factory not re-invoked");
302 assert!(!sink.records().is_empty(), "no records after respawn");
303 assert!(sink.flushed(), "stack not drained on shutdown");
304 }
305
306 #[tokio::test(start_paused = true)]
307 async fn gives_up_after_max_consecutive_failures() {
308 struct AlwaysPanics;
309 impl Collector for AlwaysPanics {
310 type Record = usize;
311 type Error = Infallible;
312 fn name(&self) -> &'static str {
313 "always-panics"
314 }
315 async fn collect(&mut self) -> Result<Vec<usize>, Infallible> {
316 panic!("boom");
317 }
318 }
319
320 let built = Arc::new(AtomicUsize::new(0));
321 let runtime = {
322 let built = Arc::clone(&built);
323 Meathook::builder()
324 .max_consecutive_failures(2)
325 .pipeline(move || {
326 built.fetch_add(1, Ordering::SeqCst);
327 Pipeline::new(
328 AlwaysPanics,
329 SharedSink::<usize>::new(),
330 Duration::from_secs(1),
331 )
332 })
333 .build()
334 };
335
336 let cancel = CancellationToken::new();
337 let handle = tokio::spawn(runtime.run_with_shutdown(cancel.clone()));
338 time::sleep(Duration::from_secs(3600)).await;
339 cancel.cancel();
340 handle.await.unwrap();
341
342 assert_eq!(built.load(Ordering::SeqCst), 3);
344 }
345}